Skip to content

Commit ee1082e

Browse files
committed
fix(a11y): improve toggle switches
1 parent f18d64e commit ee1082e

2 files changed

Lines changed: 44 additions & 164 deletions

File tree

app/components/Settings/Toggle.client.vue

Lines changed: 39 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,39 @@ const id = useId()
2727
<template>
2828
<label
2929
:for="id"
30-
class="grid items-center gap-1.5 py-1 -my-1 grid-cols-[auto_1fr_auto]"
30+
class="grid items-center gap-1.5 py-1 -my-1 grid-cols-[auto_1fr_auto] cursor-pointer group"
3131
:class="[justify === 'start' ? 'justify-start' : '']"
3232
:style="
3333
props.reverseOrder
3434
? 'grid-template-areas: \'toggle . label-text\''
3535
: 'grid-template-areas: \'label-text . toggle\''
3636
"
3737
>
38+
<span
39+
class="relative inline-flex items-center shrink-0"
40+
style="grid-area: toggle; justify-self: end"
41+
>
42+
<input type="checkbox" :id="id" role="switch" v-model="checked" class="sr-only peer" />
43+
44+
<!-- Track -->
45+
<span
46+
class="w-11 h-6 bg-fg-subtle peer-focus:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-fg peer-focus-visible:ring-offset-2 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-bg after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-fg transition-colors duration-200 ease-in-out"
47+
></span>
48+
49+
<!-- Thumb Icon (Check) - Positioned absolutely over the thumb area -->
50+
<span
51+
class="absolute top-[2px] start-[2px] h-5 w-5 rounded-full flex items-center justify-center transition-transform duration-200 ease-in-out pointer-events-none"
52+
:class="checked ? 'translate-x-5 rtl:-translate-x-5' : 'translate-x-0'"
53+
>
54+
<span
55+
class="i-lucide:check w-3.5 h-3.5 text-fg transition-transform duration-200"
56+
:class="checked ? 'scale-100 opacity-100' : 'scale-0 opacity-0'"
57+
aria-hidden="true"
58+
></span>
59+
</span>
60+
</span>
61+
3862
<template v-if="props.reverseOrder">
39-
<input
40-
role="switch"
41-
type="checkbox"
42-
:id
43-
v-model="checked"
44-
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"
45-
style="grid-area: toggle"
46-
/>
4763
<TooltipApp
4864
v-if="tooltip && label"
4965
:text="tooltip"
@@ -82,14 +98,6 @@ const id = useId()
8298
>
8399
{{ label }}
84100
</span>
85-
<input
86-
role="switch"
87-
type="checkbox"
88-
:id
89-
v-model="checked"
90-
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"
91-
style="grid-area: toggle; justify-self: end"
92-
/>
93101
</template>
94102
</label>
95103
<p v-if="description" class="text-sm text-fg-muted mt-2">
@@ -98,82 +106,31 @@ const id = useId()
98106
</template>
99107

100108
<style scoped>
101-
/* Thumb position: logical property for RTL support */
102-
.toggle::before {
103-
inset-inline-start: 1px;
104-
}
105-
106-
/* Track transition */
107-
.toggle {
108-
transition:
109-
background-color 200ms ease-in-out,
110-
border-color 100ms ease-in-out;
111-
}
112-
113-
.toggle::before {
114-
transition:
115-
background-color 200ms ease-in-out,
116-
translate 200ms ease-in-out;
117-
}
118-
119-
/* Hover states */
120-
.toggle:hover:not(:checked) {
121-
background: var(--fg-muted);
122-
}
123-
124-
.toggle:checked:hover {
125-
background: var(--fg-muted);
126-
border-color: var(--fg-muted);
127-
}
128-
129-
/* RTL-aware checked thumb position */
130-
:dir(ltr) .toggle:checked::before {
131-
translate: 20px;
132-
}
133-
134-
:dir(rtl) .toggle:checked::before {
135-
translate: -20px;
136-
}
137-
138-
@media (prefers-reduced-motion: reduce) {
139-
.toggle,
140-
.toggle::before {
141-
transition: none;
142-
}
143-
}
144-
145109
/* Support forced colors */
146110
@media (forced-colors: active) {
147-
label > span {
148-
background: Canvas;
149-
color: Highlight;
150-
forced-color-adjust: none;
151-
}
152-
153-
label:has(.toggle:checked) > span {
111+
/* Track */
112+
input:checked + span {
154113
background: Highlight;
155-
color: Canvas;
156-
}
157-
158-
.toggle::before {
159-
forced-color-adjust: none;
160-
background-color: Highlight;
161114
}
162115
163-
.toggle,
164-
.toggle:hover {
116+
/* Thumb border/bg */
117+
input:checked + span::after {
165118
background: Canvas;
166119
border-color: CanvasText;
167120
}
168121
169-
.toggle:checked,
170-
.toggle:checked:hover {
171-
background: Highlight;
172-
border-color: CanvasText;
122+
/* Icon */
123+
.i-lucide\:check {
124+
color: Highlight;
173125
}
126+
}
174127
175-
.toggle:checked::before {
176-
background: Canvas;
128+
@media (prefers-reduced-motion: reduce) {
129+
span,
130+
span::after,
131+
.i-lucide\:check {
132+
transition: none !important;
133+
animation: none !important;
177134
}
178135
}
179136
</style>

app/components/Settings/Toggle.server.vue

Lines changed: 5 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ const props = withDefaults(
44
label: string
55
description?: string
66
justify?: 'between' | 'start'
7+
tooltip?: string
8+
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right'
9+
tooltipTo?: string
10+
tooltipOffset?: number
711
reverseOrder?: boolean
812
}>(),
913
{
@@ -15,7 +19,7 @@ const props = withDefaults(
1519

1620
<template>
1721
<div
18-
class="grid items-center gap-4 py-1 -my-1 grid-cols-[auto_1fr_auto]"
22+
class="grid items-center gap-1.5 py-1 -my-1 grid-cols-[auto_1fr_auto]"
1923
:class="[justify === 'start' ? 'justify-start' : '']"
2024
:style="
2125
props.reverseOrder
@@ -51,84 +55,3 @@ const props = withDefaults(
5155
{{ description }}
5256
</p>
5357
</template>
54-
55-
<style scoped>
56-
/* Thumb position: logical property for RTL support */
57-
.toggle::before {
58-
inset-inline-start: 1px;
59-
}
60-
61-
/* Track transition */
62-
.toggle {
63-
transition:
64-
background-color 200ms ease-in-out,
65-
border-color 100ms ease-in-out;
66-
}
67-
68-
.toggle::before {
69-
transition:
70-
background-color 200ms ease-in-out,
71-
translate 200ms ease-in-out;
72-
}
73-
74-
/* Hover states */
75-
.toggle:hover:not(:checked) {
76-
background: var(--fg-muted);
77-
}
78-
79-
.toggle:checked:hover {
80-
background: var(--fg-muted);
81-
border-color: var(--fg-muted);
82-
}
83-
84-
/* RTL-aware checked thumb position */
85-
:dir(ltr) .toggle:checked::before {
86-
translate: 20px;
87-
}
88-
89-
:dir(rtl) .toggle:checked::before {
90-
translate: -20px;
91-
}
92-
93-
@media (prefers-reduced-motion: reduce) {
94-
.toggle,
95-
.toggle::before {
96-
transition: none;
97-
}
98-
}
99-
100-
/* Support forced colors */
101-
@media (forced-colors: active) {
102-
label > span {
103-
background: Canvas;
104-
color: Highlight;
105-
forced-color-adjust: none;
106-
}
107-
108-
label:has(.toggle:checked) > span {
109-
background: Highlight;
110-
color: Canvas;
111-
}
112-
113-
.toggle::before {
114-
forced-color-adjust: none;
115-
background-color: Highlight;
116-
}
117-
118-
.toggle,
119-
.toggle:hover {
120-
background: Canvas;
121-
border-color: CanvasText;
122-
}
123-
124-
.toggle:checked,
125-
.toggle:checked:hover {
126-
background: Highlight;
127-
border-color: CanvasText;
128-
}
129-
130-
.toggle:checked::before {
131-
background: Canvas;
132-
}
133-
}
134-
</style>

0 commit comments

Comments
 (0)