[READ-ONLY] a fast, modern browser for the npm registry

fix: settings toggle should use input (#1049)

Co-authored-by: Nathan Knowler <nathan@knowler.dev>
Co-authored-by: Daniel Roe <daniel@roe.dev>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Abbey Perini
Nathan Knowler
Daniel Roe
autofix-ci[bot]
and committed by
GitHub
0d3ea09a ad306bed

+231 -87
+103 -76
app/components/Settings/Toggle.client.vue
··· 3 3 4 4 const props = withDefaults( 5 5 defineProps<{ 6 - label?: string 6 + label: string 7 7 description?: string 8 - class?: string 9 8 justify?: 'between' | 'start' 10 9 tooltip?: string 11 10 tooltipPosition?: 'top' | 'bottom' | 'left' | 'right' ··· 20 19 ) 21 20 22 21 const checked = defineModel<boolean>({ 23 - default: false, 22 + required: true, 24 23 }) 24 + const id = useId() 25 25 </script> 26 26 27 27 <template> 28 - <button 29 - type="button" 30 - class="w-full flex items-center gap-4 group focus-visible:outline-none py-1 -my-1" 31 - :class="[justify === 'start' ? 'justify-start' : 'justify-between', $props.class]" 32 - role="switch" 33 - :aria-checked="checked" 34 - @click="checked = !checked" 28 + <label 29 + :for="id" 30 + class="grid items-center gap-4 py-1 -my-1 grid-cols-[auto_1fr_auto]" 31 + :class="[justify === 'start' ? 'justify-start' : '']" 32 + :style=" 33 + props.reverseOrder 34 + ? 'grid-template-areas: \'toggle . label-text\'' 35 + : 'grid-template-areas: \'label-text . toggle\'' 36 + " 35 37 > 36 38 <template v-if="props.reverseOrder"> 37 - <span 38 - 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)" 39 - :class=" 40 - checked 41 - ? 'bg-accent border-accent group-hover:bg-accent/80' 42 - : 'bg-fg/50 border-fg/50 group-hover:bg-fg/70' 43 - " 44 - aria-hidden="true" 45 - > 46 - <span 47 - class="block h-5 w-5 rounded-full bg-bg shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none" 48 - /> 49 - </span> 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 + /> 50 47 <TooltipApp 51 48 v-if="tooltip && label" 52 49 :text="tooltip" ··· 54 51 :to="tooltipTo" 55 52 :offset="tooltipOffset" 56 53 > 57 - <span class="text-sm text-fg font-medium text-start"> 54 + <span class="text-sm text-fg font-medium text-start" style="grid-area: label-text"> 58 55 {{ label }} 59 56 </span> 60 57 </TooltipApp> 61 - <span v-else-if="label" class="text-sm text-fg font-medium text-start"> 58 + <span 59 + v-else-if="label" 60 + class="text-sm text-fg font-medium text-start" 61 + style="grid-area: label-text" 62 + > 62 63 {{ label }} 63 64 </span> 64 65 </template> ··· 70 71 :to="tooltipTo" 71 72 :offset="tooltipOffset" 72 73 > 73 - <span class="text-sm text-fg font-medium text-start"> 74 + <span class="text-sm text-fg font-medium text-start" style="grid-area: label-text"> 74 75 {{ label }} 75 76 </span> 76 77 </TooltipApp> 77 - <span v-else-if="label" class="text-sm text-fg font-medium text-start"> 78 - {{ label }} 79 - </span> 80 78 <span 81 - 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)" 82 - :class=" 83 - checked 84 - ? 'bg-accent border-accent group-hover:bg-accent/80' 85 - : 'bg-fg/50 border-fg/50 group-hover:bg-fg/70' 86 - " 87 - aria-hidden="true" 79 + v-else-if="label" 80 + class="text-sm text-fg font-medium text-start" 81 + style="grid-area: label-text" 88 82 > 89 - <span 90 - class="block h-5 w-5 rounded-full bg-bg shadow-sm transition-transform duration-200 ease-in-out motion-reduce:transition-none" 91 - /> 83 + {{ label }} 92 84 </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 + /> 93 93 </template> 94 - </button> 94 + </label> 95 95 <p v-if="description" class="text-sm text-fg-muted mt-2"> 96 96 {{ description }} 97 97 </p> 98 98 </template> 99 99 100 100 <style scoped> 101 - /* Default order: label first, toggle last */ 102 - button[aria-checked='false'] > span:last-of-type > span { 103 - translate: 0; 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; 104 111 } 105 - button[aria-checked='true'] > span:last-of-type > span { 106 - translate: calc(100%); 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); 107 122 } 108 - html[dir='rtl'] button[aria-checked='true'] > span:last-of-type > span { 109 - translate: calc(-100%); 123 + 124 + .toggle:checked:hover { 125 + background: var(--fg-muted); 126 + border-color: var(--fg-muted); 110 127 } 111 128 112 - /* Reverse order: toggle first, label last */ 113 - button[aria-checked='false'] > span:first-of-type > span { 114 - translate: 0; 129 + /* RTL-aware checked thumb position */ 130 + :dir(ltr) .toggle:checked::before { 131 + translate: 20px; 115 132 } 116 - button[aria-checked='true'] > span:first-of-type > span { 117 - translate: calc(100%); 133 + 134 + :dir(rtl) .toggle:checked::before { 135 + translate: -20px; 118 136 } 119 - html[dir='rtl'] button[aria-checked='true'] > span:first-of-type > span { 120 - translate: calc(-100%); 137 + 138 + @media (prefers-reduced-motion: reduce) { 139 + .toggle, 140 + .toggle::before { 141 + transition: none; 142 + } 121 143 } 122 144 145 + /* Support forced colors */ 123 146 @media (forced-colors: active) { 124 - /* make toggle tracks and thumb visible in forced colors. */ 125 - button[role='switch'] { 126 - & > span:last-of-type, 127 - & > span:first-of-type { 128 - forced-color-adjust: none; 129 - } 147 + label > span { 148 + background: Canvas; 149 + color: Highlight; 150 + forced-color-adjust: none; 151 + } 130 152 131 - &[aria-checked='false'] > span:last-of-type, 132 - &[aria-checked='false'] > span:first-of-type { 133 - background: Canvas; 134 - border-color: CanvasText; 153 + label:has(.toggle:checked) > span { 154 + background: Highlight; 155 + color: Canvas; 156 + } 135 157 136 - & > span { 137 - background: CanvasText; 138 - } 139 - } 158 + .toggle::before { 159 + forced-color-adjust: none; 160 + background-color: Highlight; 161 + } 140 162 141 - &[aria-checked='true'] > span:last-of-type, 142 - &[aria-checked='true'] > span:first-of-type { 143 - background: Highlight; 144 - border-color: Highlight; 163 + .toggle, 164 + .toggle:hover { 165 + background: Canvas; 166 + border-color: CanvasText; 167 + } 145 168 146 - & > span { 147 - background: HighlightText; 148 - } 149 - } 169 + .toggle:checked, 170 + .toggle:checked:hover { 171 + background: Highlight; 172 + border-color: CanvasText; 173 + } 174 + 175 + .toggle:checked::before { 176 + background: Canvas; 150 177 } 151 178 } 152 179 </style>
+125 -9
app/components/Settings/Toggle.server.vue
··· 1 1 <script setup lang="ts"> 2 - defineProps<{ 3 - label?: string 4 - description?: string 5 - }>() 2 + const props = withDefaults( 3 + defineProps<{ 4 + label: string 5 + description?: string 6 + justify?: 'between' | 'start' 7 + reverseOrder?: boolean 8 + }>(), 9 + { 10 + justify: 'between', 11 + reverseOrder: false, 12 + }, 13 + ) 6 14 </script> 7 15 8 16 <template> 9 - <div class="w-full flex items-center justify-between gap-4 py-1 -my-1"> 10 - <span v-if="label" class="text-sm text-fg font-medium text-start"> 11 - {{ label }} 12 - </span> 13 - <SkeletonBlock class="h-6 w-11 shrink-0 rounded-full" /> 17 + <div 18 + class="grid items-center gap-4 py-1 -my-1 grid-cols-[auto_1fr_auto]" 19 + :class="[justify === 'start' ? 'justify-start' : '']" 20 + :style=" 21 + props.reverseOrder 22 + ? 'grid-template-areas: \'toggle . label-text\'' 23 + : 'grid-template-areas: \'label-text . toggle\'' 24 + " 25 + > 26 + <template v-if="props.reverseOrder"> 27 + <SkeletonBlock class="h-6 w-11 shrink-0 rounded-full" style="grid-area: toggle" /> 28 + <span 29 + v-if="label" 30 + class="text-sm text-fg font-medium text-start" 31 + style="grid-area: label-text" 32 + > 33 + {{ label }} 34 + </span> 35 + </template> 36 + <template v-else> 37 + <span 38 + v-if="label" 39 + class="text-sm text-fg font-medium text-start" 40 + style="grid-area: label-text" 41 + > 42 + {{ label }} 43 + </span> 44 + <SkeletonBlock 45 + class="h-6 w-11 shrink-0 rounded-full" 46 + style="grid-area: toggle; justify-self: end" 47 + /> 48 + </template> 14 49 </div> 15 50 <p v-if="description" class="text-sm text-fg-muted mt-2"> 16 51 {{ description }} 17 52 </p> 18 53 </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>
+3 -2
test/nuxt/a11y.spec.ts
··· 2400 2400 describe('Toggle', () => { 2401 2401 it('should have no accessibility violations', async () => { 2402 2402 const component = await mountSuspended(SettingsToggle, { 2403 - props: { label: 'Enable feature' }, 2403 + props: { label: 'Enable feature', modelValue: false }, 2404 2404 }) 2405 2405 const results = await runAxe(component) 2406 2406 expect(results.violations).toEqual([]) ··· 2411 2411 props: { 2412 2412 label: 'Enable feature', 2413 2413 description: 'This enables the feature', 2414 + modelValue: false, 2414 2415 }, 2415 2416 }) 2416 2417 const results = await runAxe(component) ··· 2548 2549 name: 'SettingsToggle', 2549 2550 mount: () => 2550 2551 mountSuspended(SettingsToggle, { 2551 - props: { label: 'Feature', description: 'Desc' }, 2552 + props: { label: 'Feature', description: 'Desc', modelValue: false }, 2552 2553 }), 2553 2554 }, 2554 2555 {