Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c498289
fix: settings toggle should use input
abbeyperini Feb 5, 2026
439a09e
fixes: animation, reacty code, and styling
abbeyperini Feb 9, 2026
a6a084f
fix: support rtl
abbeyperini Feb 10, 2026
0db4fda
Fix: use theme colors, toggle size
abbeyperini Feb 10, 2026
52963f3
Merge branch 'main' into fix/1028
abbeyperini Feb 10, 2026
19c31f2
fix: unneeded code, tests
abbeyperini Feb 10, 2026
4bcbce9
fix: use useID instead of adding label to "toggle"
abbeyperini Feb 10, 2026
98e4265
Merge branch 'main' into fix/1028
abbeyperini Feb 10, 2026
bac8f7b
fix: dot position
abbeyperini Feb 11, 2026
4182e9f
Update app/components/Settings/Toggle.client.vue
abbeyperini Feb 11, 2026
6528caa
Update app/components/Settings/Toggle.client.vue
abbeyperini Feb 11, 2026
e82dccd
Update app/components/Settings/Toggle.client.vue
abbeyperini Feb 11, 2026
a36bd55
Update app/components/Settings/Toggle.client.vue
abbeyperini Feb 11, 2026
ef96b93
Update app/components/Settings/Toggle.client.vue
abbeyperini Feb 11, 2026
df6a9d0
Update app/components/Settings/Toggle.client.vue
abbeyperini Feb 11, 2026
e1887e2
Update app/components/Settings/Toggle.client.vue
abbeyperini Feb 11, 2026
177d4cc
fix: css instead of js for dir, unneeded code
abbeyperini Feb 11, 2026
9d39717
Update app/components/Settings/Toggle.client.vue
abbeyperini Feb 11, 2026
d02f011
Update app/components/Settings/Toggle.client.vue
abbeyperini Feb 11, 2026
cfed8ca
chore: clean up slightly
danielroe Feb 11, 2026
96afb3c
chore: move into atomic css, respect `prefers-reduced-motion: reduce`…
danielroe Feb 11, 2026
e0836ba
Merge remote-tracking branch 'origin/main' into fix/1028
danielroe Feb 11, 2026
383cfc0
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 11, 2026
ee9b8d5
chore: remove class prop
danielroe Feb 11, 2026
ea8723f
chore: update server equivalent following merge
danielroe Feb 11, 2026
4bf2934
refactor: rewrite without extra element
danielroe Feb 11, 2026
ab07136
chore: remove cursor-pointer
danielroe Feb 11, 2026
837bc10
fix: forced contrast mode styling, server style tag
abbeyperini Feb 11, 2026
61a0040
fix: forced contrast colors are hard
abbeyperini Feb 11, 2026
a5cdb76
fix: force colors are really hard, for real
abbeyperini Feb 11, 2026
8c140aa
fix: animation flash
abbeyperini Feb 11, 2026
219ce0a
Merge branch 'main' into fix/1028
abbeyperini Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 107 additions & 80 deletions app/components/Settings/Toggle.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import TooltipApp from '~/components/Tooltip/App.vue'

const props = withDefaults(
defineProps<{
label?: string
label: string
description?: string
class?: string
justify?: 'between' | 'start'
tooltip?: string
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right'
Expand All @@ -20,45 +19,47 @@ const props = withDefaults(
)

const checked = defineModel<boolean>({
default: false,
required: true,
})
const id = useId()
</script>

<template>
<button
type="button"
class="w-full flex items-center gap-4 group focus-visible:outline-none py-1 -my-1"
:class="[justify === 'start' ? 'justify-start' : 'justify-between', $props.class]"
role="switch"
:aria-checked="checked"
@click="checked = !checked"
<label
:for="id"
class="grid items-center gap-4 py-1 -my-1 grid-cols-[auto_1fr_auto]"
:class="[justify === 'start' ? 'justify-start' : '']"
:style="
props.reverseOrder
? 'grid-template-areas: \'toggle . label-text\''
: 'grid-template-areas: \'label-text . toggle\''
"
>
<template v-if="props.reverseOrder">
<span
class="inline-flex items-center h-6 w-11 shrink-0 rounded-full border p-0.25 transition-colors duration-200 shadow-sm ease-in-out motion-reduce:transition-none group-focus-visible:(outline-accent/70 outline-offset-2 outline-solid)"
:class="
checked
? 'bg-accent border-accent group-hover:bg-accent/80'
: 'bg-fg/50 border-fg/50 group-hover:bg-fg/70'
"
aria-hidden="true"
>
<span
class="block h-5 w-5 rounded-full bg-bg shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none"
/>
</span>
<input
role="switch"
type="checkbox"
:id
v-model="checked"
class="toggle appearance-none h-6 w-11 rounded-full border border-fg relative shrink-0 bg-fg-subtle checked:bg-fg checked:border-fg focus-visible:(outline-2 outline-fg outline-offset-2) before:content-[''] before:absolute before:h-5 before:w-5 before:top-1px before:rounded-full before:bg-bg"
style="grid-area: toggle"
/>
<TooltipApp
v-if="tooltip && label"
:text="tooltip"
:position="tooltipPosition ?? 'top'"
:to="tooltipTo"
:offset="tooltipOffset"
>
<span class="text-sm text-fg font-medium text-start">
<span class="text-sm text-fg font-medium text-start" style="grid-area: label-text">
{{ label }}
</span>
</TooltipApp>
<span v-else-if="label" class="text-sm text-fg font-medium text-start">
<span
v-else-if="label"
class="text-sm text-fg font-medium text-start"
style="grid-area: label-text"
>
{{ label }}
</span>
</template>
Expand All @@ -70,83 +71,109 @@ const checked = defineModel<boolean>({
:to="tooltipTo"
:offset="tooltipOffset"
>
<span class="text-sm text-fg font-medium text-start">
<span class="text-sm text-fg font-medium text-start" style="grid-area: label-text">
{{ label }}
</span>
</TooltipApp>
<span v-else-if="label" class="text-sm text-fg font-medium text-start">
{{ label }}
</span>
<span
class="inline-flex items-center h-6 w-11 shrink-0 rounded-full border p-0.25 transition-colors duration-200 shadow-sm ease-in-out motion-reduce:transition-none group-focus-visible:(outline-accent/70 outline-offset-2 outline-solid)"
:class="
checked
? 'bg-accent border-accent group-hover:bg-accent/80'
: 'bg-fg/50 border-fg/50 group-hover:bg-fg/70'
"
aria-hidden="true"
v-else-if="label"
class="text-sm text-fg font-medium text-start"
style="grid-area: label-text"
>
<span
class="block h-5 w-5 rounded-full bg-bg shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none"
/>
{{ label }}
</span>
<input
role="switch"
type="checkbox"
:id
v-model="checked"
class="toggle appearance-none h-6 w-11 rounded-full border border-fg relative shrink-0 bg-fg-subtle checked:bg-fg checked:border-fg focus-visible:(outline-2 outline-fg outline-offset-2) before:content-[''] before:absolute before:h-5 before:w-5 before:top-1px before:rounded-full before:bg-bg"
style="grid-area: toggle; justify-self: end"
/>
</template>
</button>
</label>
<p v-if="description" class="text-sm text-fg-muted mt-2">
{{ description }}
</p>
</template>

<style scoped>
/* Default order: label first, toggle last */
button[aria-checked='false'] > span:last-of-type > span {
translate: 0;
/* Thumb position: logical property for RTL support */
.toggle::before {
inset-inline-start: 1px;
}
button[aria-checked='true'] > span:last-of-type > span {
translate: calc(100%);

/* Track transition */
.toggle {
transition:
background-color 200ms ease-in-out,
border-color 100ms ease-in-out;
}
html[dir='rtl'] button[aria-checked='true'] > span:last-of-type > span {
translate: calc(-100%);

.toggle::before {
transition:
background-color 200ms ease-in-out,
translate 200ms ease-in-out;
}

/* Reverse order: toggle first, label last */
button[aria-checked='false'] > span:first-of-type > span {
translate: 0;
/* Hover states */
.toggle:hover:not(:checked) {
background: var(--fg-muted);
}
button[aria-checked='true'] > span:first-of-type > span {
translate: calc(100%);

.toggle:checked:hover {
background: var(--fg-muted);
border-color: var(--fg-muted);
}
html[dir='rtl'] button[aria-checked='true'] > span:first-of-type > span {
translate: calc(-100%);

/* RTL-aware checked thumb position */
:dir(ltr) .toggle:checked::before {
translate: 20px;
}

:dir(rtl) .toggle:checked::before {
translate: -20px;
}

@media (prefers-reduced-motion: reduce) {
.toggle,
.toggle::before {
transition: none;
}
}

/* Support forced colors */
@media (forced-colors: active) {
/* make toggle tracks and thumb visible in forced colors. */
button[role='switch'] {
& > span:last-of-type,
& > span:first-of-type {
forced-color-adjust: none;
}

&[aria-checked='false'] > span:last-of-type,
&[aria-checked='false'] > span:first-of-type {
background: Canvas;
border-color: CanvasText;

& > span {
background: CanvasText;
}
}

&[aria-checked='true'] > span:last-of-type,
&[aria-checked='true'] > span:first-of-type {
background: Highlight;
border-color: Highlight;

& > span {
background: HighlightText;
}
}
label > span {
background: Canvas;
color: Highlight;
forced-color-adjust: none;
}

label:has(.toggle:checked) > span {
background: Highlight;
color: Canvas;
}

.toggle::before {
forced-color-adjust: none;
background-color: Highlight;
}

.toggle,
.toggle:hover {
background: Canvas;
border-color: CanvasText;
}

.toggle:checked,
.toggle:checked:hover {
background: Highlight;
border-color: CanvasText;
}

.toggle:checked::before {
background: Canvas;
}
}
</style>
134 changes: 125 additions & 9 deletions app/components/Settings/Toggle.server.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,134 @@
<script setup lang="ts">
defineProps<{
label?: string
description?: string
}>()
const props = withDefaults(
defineProps<{
label: string
description?: string
justify?: 'between' | 'start'
reverseOrder?: boolean
}>(),
{
justify: 'between',
reverseOrder: false,
},
)
</script>

<template>
<div class="w-full flex items-center justify-between gap-4 py-1 -my-1">
<span v-if="label" class="text-sm text-fg font-medium text-start">
{{ label }}
</span>
<SkeletonBlock class="h-6 w-11 shrink-0 rounded-full" />
<div
class="grid items-center gap-4 py-1 -my-1 grid-cols-[auto_1fr_auto]"
:class="[justify === 'start' ? 'justify-start' : '']"
:style="
props.reverseOrder
? 'grid-template-areas: \'toggle . label-text\''
: 'grid-template-areas: \'label-text . toggle\''
"
>
<template v-if="props.reverseOrder">
<SkeletonBlock class="h-6 w-11 shrink-0 rounded-full" style="grid-area: toggle" />
<span
v-if="label"
class="text-sm text-fg font-medium text-start"
style="grid-area: label-text"
>
{{ label }}
</span>
</template>
<template v-else>
<span
v-if="label"
class="text-sm text-fg font-medium text-start"
style="grid-area: label-text"
>
{{ label }}
</span>
<SkeletonBlock
class="h-6 w-11 shrink-0 rounded-full"
style="grid-area: toggle; justify-self: end"
/>
</template>
</div>
<p v-if="description" class="text-sm text-fg-muted mt-2">
{{ description }}
</p>
</template>

<style scoped>
/* Thumb position: logical property for RTL support */
.toggle::before {
inset-inline-start: 1px;
}

/* Track transition */
.toggle {
transition:
background-color 200ms ease-in-out,
border-color 100ms ease-in-out;
}

.toggle::before {
transition:
background-color 200ms ease-in-out,
translate 200ms ease-in-out;
}

/* Hover states */
.toggle:hover:not(:checked) {
background: var(--fg-muted);
}

.toggle:checked:hover {
background: var(--fg-muted);
border-color: var(--fg-muted);
}

/* RTL-aware checked thumb position */
:dir(ltr) .toggle:checked::before {
translate: 20px;
}

:dir(rtl) .toggle:checked::before {
translate: -20px;
}

@media (prefers-reduced-motion: reduce) {
.toggle,
.toggle::before {
transition: none;
}
}

/* Support forced colors */
@media (forced-colors: active) {
label > span {
background: Canvas;
color: Highlight;
forced-color-adjust: none;
}

label:has(.toggle:checked) > span {
background: Highlight;
color: Canvas;
}

.toggle::before {
forced-color-adjust: none;
background-color: Highlight;
}

.toggle,
.toggle:hover {
background: Canvas;
border-color: CanvasText;
}

.toggle:checked,
.toggle:checked:hover {
background: Highlight;
border-color: CanvasText;
}

.toggle:checked::before {
background: Canvas;
}
}
</style>
Loading
Loading