Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 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
162 changes: 90 additions & 72 deletions app/components/Settings/Toggle.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ const props = withDefaults(
defineProps<{
label?: string
description?: string
class?: string
justify?: 'between' | 'start'
tooltip?: string
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right'
Expand All @@ -20,33 +19,32 @@ 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"
:class="[
justify === 'start' ? 'justify-start' : '',
props.reverseOrder ? 'toggle-reverse' : 'toggle-default',
]"
>
<template v-if="props.reverseOrder">
<input
role="switch"
type="checkbox"
:id
class="toggle-checkbox opacity-0"
style="grid-row: 1; grid-column: 1; justify-self: start"
v-model="checked"
/>
Comment thread
abbeyperini marked this conversation as resolved.
<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>
class="toggle-background h-6 w-11 rounded-full border border-fg relative flex shrink-0"
></span>
Comment thread
abbeyperini marked this conversation as resolved.
Outdated
<TooltipApp
v-if="tooltip && label"
:text="tooltip"
Expand Down Expand Up @@ -77,76 +75,96 @@ const checked = defineModel<boolean>({
<span v-else-if="label" class="text-sm text-fg font-medium text-start">
{{ label }}
</span>
<input
role="switch"
type="checkbox"
:id
class="toggle-checkbox opacity-0"
Comment thread
abbeyperini marked this conversation as resolved.
Outdated
style="grid-row: 1; grid-column: 3; justify-self: end"
v-model="checked"
/>
<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>
class="toggle-background h-6 w-11 rounded-full border border-fg relative flex shrink-0"
style="grid-area: toggle-background; justify-self: end"
></span>
</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;
}
button[aria-checked='true'] > span:last-of-type > span {
translate: calc(100%);
/* Layout: default order (label first, toggle last) */
.toggle-default {
grid-template-areas: 'label-text . toggle-background';
grid-template-columns: auto 1fr auto;
}
html[dir='rtl'] button[aria-checked='true'] > span:last-of-type > span {
translate: calc(-100%);

/* Layout: reverse order (toggle first, label last) */
.toggle-reverse {
grid-template-areas: 'toggle-background . label-text';
grid-template-columns: auto 1fr auto;
}

/* Reverse order: toggle first, label last */
button[aria-checked='false'] > span:first-of-type > span {
translate: 0;
/* Track background */
.toggle-background {
background: var(--fg-subtle);
transition:
background-color 100ms ease-in,
border-color 100ms ease-in;
}
button[aria-checked='true'] > span:first-of-type > span {
translate: calc(100%);

@media (prefers-reduced-motion: reduce) {
.toggle-background {
transition: none;
}
}
Comment thread
abbeyperini marked this conversation as resolved.
html[dir='rtl'] button[aria-checked='true'] > span:first-of-type > span {
translate: calc(-100%);

label:has(input:focus-visible) .toggle-background {
outline: solid 2px var(--fg);
outline-offset: 2px;
}

@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;
}
label:has(input:checked) .toggle-background {
background: var(--fg);
border-color: var(--fg);
}

&[aria-checked='false'] > span:last-of-type,
&[aria-checked='false'] > span:first-of-type {
background: Canvas;
border-color: CanvasText;
label:has(input:hover:not(:checked)) .toggle-background {
background: var(--fg-muted);
}

& > span {
background: CanvasText;
}
}
label:has(input:checked:hover) .toggle-background {
background: var(--fg-muted);
border-color: var(--fg-muted);
}

&[aria-checked='true'] > span:last-of-type,
&[aria-checked='true'] > span:first-of-type {
background: Highlight;
border-color: Highlight;
/* Circle that moves */
.toggle-background::before {
transition: translate 200ms ease-in-out;
content: '';
width: 20px;
height: 20px;
top: 1px;
inset-inline-start: 1px;
position: absolute;
border-radius: 9999px;
background: var(--bg);
}

& > span {
background: HighlightText;
}
}
@media (prefers-reduced-motion: reduce) {
.toggle-background::before {
transition: none;
}
}

/* Support rtl locales */
:dir(ltr) input:checked + .toggle-background::before {
translate: 20px;
}

:dir(rtl) input:checked + .toggle-background::before {
translate: -20px;
}
</style>
63 changes: 54 additions & 9 deletions app/components/Settings/Toggle.server.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,63 @@
<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"
:class="[
justify === 'start' ? 'justify-start' : '',
props.reverseOrder ? 'toggle-reverse' : 'toggle-default',
]"
>
<template v-if="props.reverseOrder">
<SkeletonBlock class="h-6 w-11 shrink-0 rounded-full" style="grid-area: toggle-background" />
<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-background; justify-self: end"
/>
</template>
</div>
<p v-if="description" class="text-sm text-fg-muted mt-2">
{{ description }}
</p>
</template>

<style scoped>
.toggle-default {
grid-template-areas: 'label-text . toggle-background';
grid-template-columns: auto 1fr auto;
}

.toggle-reverse {
grid-template-areas: 'toggle-background . label-text';
grid-template-columns: auto 1fr auto;
}
</style>
5 changes: 3 additions & 2 deletions test/nuxt/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2390,7 +2390,7 @@ describe('component accessibility audits', () => {
describe('Toggle', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(SettingsToggle, {
props: { label: 'Enable feature' },
props: { label: 'Enable feature', modelValue: false },
})
const results = await runAxe(component)
expect(results.violations).toEqual([])
Expand All @@ -2401,6 +2401,7 @@ describe('component accessibility audits', () => {
props: {
label: 'Enable feature',
description: 'This enables the feature',
modelValue: false,
},
})
const results = await runAxe(component)
Expand Down Expand Up @@ -2538,7 +2539,7 @@ describe('background theme accessibility', () => {
name: 'SettingsToggle',
mount: () =>
mountSuspended(SettingsToggle, {
props: { label: 'Feature', description: 'Desc' },
props: { label: 'Feature', description: 'Desc', modelValue: false },
}),
},
{
Expand Down
Loading