Skip to content

Commit 1460e5a

Browse files
zernoniaclaude
andauthored
fix(form-controls): un-nest hidden form input to fix nested-interactive a11y (#2720)
* fix(form-controls): un-nest hidden form input to fix nested-interactive a11y Checkbox, Switch, Toggle and Radio rendered their VisuallyHiddenInput inside the interactive control (button[role]), which axe flags as `nested-interactive` when used in a form; the hidden input also failed the `label` rule. Render the hidden input as a sibling of the control (multi-root), and mark VisuallyHidden `fully-hidden` as aria-hidden. To keep consumer `<style scoped>` working once the component becomes multi-root (Vue only auto-forwards the parent scope id to single-root components), add `useForwardScopeId()` and forward the parent scope id onto the control. - VisuallyHidden: `fully-hidden` now sets aria-hidden="true" (it is tabindex=-1, so there is no aria-hidden-focus risk); `focusable` is unchanged. - Radio: default aria-checked to false (role=radio requires aria-checked). - BEHAVIOUR CHANGE: these four components are now multi-root (fragment) in all cases, not only inside forms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(Toggle): await trigger in beforeEach hooks Await the `trigger('click')` calls in the toggling `beforeEach` blocks so Vue's reactive updates flush before assertions run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(shared): cover useForwardScopeId no-op when parent has no scoped styles Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e13bf62 commit 1460e5a

15 files changed

Lines changed: 243 additions & 66 deletions

packages/core/src/Checkbox/Checkbox.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,14 @@ describe('given checkbox in a form', async () => {
226226
expect(wrapper.find('[type="checkbox"]').exists()).toBe(true)
227227
})
228228

229+
it('should pass axe accessibility tests', async () => {
230+
expect(await axe(wrapper.element)).toHaveNoViolations()
231+
})
232+
233+
it('should not nest the hidden input inside the interactive control', () => {
234+
expect(wrapper.find('button input').exists()).toBe(false)
235+
})
236+
229237
describe('after clicking submit button', () => {
230238
beforeEach(async () => {
231239
await wrapper.find('button').trigger('click')

packages/core/src/Checkbox/CheckboxRoot.vue

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { CheckedState } from './utils'
44
import type { PrimitiveProps } from '@/Primitive'
55
import type { AcceptableValue, FormFieldProps } from '@/shared/types'
66
import { useVModel } from '@vueuse/core'
7-
import { createContext, isNullish, isValueEqualOrExist, useFormControl, useForwardExpose } from '@/shared'
7+
import { createContext, isNullish, isValueEqualOrExist, useFormControl, useForwardExpose, useForwardScopeId } from '@/shared'
88
import { injectCheckboxGroupRootContext } from './CheckboxGroupRoot.vue'
99
1010
export interface CheckboxRootProps<T = boolean> extends PrimitiveProps, FormFieldProps {
@@ -122,6 +122,10 @@ function handleClick() {
122122
}
123123
124124
const isFormControl = useFormControl(currentElement)
125+
// The hidden form input is rendered as a sibling (not nested) of the interactive
126+
// control to avoid the `nested-interactive` a11y violation. That makes this a
127+
// multi-root component, so the parent's scoped-style id must be forwarded manually.
128+
const scopeIdAttrs = useForwardScopeId()
125129
const attrs = useAttrs()
126130
const ariaLabel = computed(() => {
127131
// An explicit `aria-label` always wins, so skip the (potentially expensive)
@@ -141,7 +145,7 @@ provideCheckboxRootContext({
141145

142146
<template>
143147
<component
144-
v-bind="$attrs"
148+
v-bind="{ ...$attrs, ...scopeIdAttrs }"
145149
:is="checkboxGroupContext?.rovingFocus.value ? RovingFocusItem : Primitive"
146150
:id="id"
147151
:ref="forwardRef"
@@ -165,15 +169,16 @@ provideCheckboxRootContext({
165169
:model-value="modelValue"
166170
:state="checkboxState"
167171
/>
168-
169-
<VisuallyHiddenInput
170-
v-if="isFormControl && name && !checkboxGroupContext"
171-
type="checkbox"
172-
:checked="!!checkboxState"
173-
:name="name"
174-
:value="value"
175-
:disabled="disabled"
176-
:required="required"
177-
/>
178172
</component>
173+
174+
<VisuallyHiddenInput
175+
v-if="isFormControl && name && !checkboxGroupContext"
176+
type="checkbox"
177+
:checked="!!checkboxState"
178+
:name="name"
179+
:value="value"
180+
:disabled="disabled"
181+
:required="required"
182+
v-bind="scopeIdAttrs"
183+
/>
179184
</template>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<script setup lang="ts">
2+
import { CheckboxRoot } from '..'
3+
</script>
4+
5+
<template>
6+
<!-- This parent has no `<style scoped>`, so there is no scope id to forward. -->
7+
<CheckboxRoot
8+
name="test"
9+
aria-label="no-scope"
10+
class="plain-checkbox"
11+
/>
12+
</template>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script setup lang="ts">
2+
import { CheckboxRoot } from '..'
3+
</script>
4+
5+
<template>
6+
<!-- `marker` receives this SFC's scoped-style id; the Checkbox root must receive
7+
the same id (forwarded manually) even though it is now a multi-root component. -->
8+
<span class="marker" />
9+
<CheckboxRoot
10+
name="test"
11+
aria-label="scoped"
12+
class="scoped-checkbox"
13+
/>
14+
</template>
15+
16+
<style scoped>
17+
.marker {
18+
color: red;
19+
}
20+
.scoped-checkbox {
21+
color: rgb(1, 2, 3);
22+
}
23+
</style>

packages/core/src/RadioGroup/Radio.vue

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ export interface RadioProps extends PrimitiveProps, FormFieldProps {
2222
import { useVModel } from '@vueuse/core'
2323
import { computed, toRefs } from 'vue'
2424
import { Primitive } from '@/Primitive'
25-
import { useFormControl, useForwardExpose } from '@/shared'
25+
import { useFormControl, useForwardExpose, useForwardScopeId } from '@/shared'
2626
import { VisuallyHiddenInput } from '@/VisuallyHidden'
2727
import { handleSelect } from './utils'
2828
29+
defineOptions({
30+
inheritAttrs: false,
31+
})
32+
2933
const props = withDefaults(defineProps<RadioProps>(), {
3034
disabled: false,
3135
checked: undefined,
@@ -47,6 +51,9 @@ const checked = useVModel(props, 'checked', emits, {
4751
const { value } = toRefs(props)
4852
const { forwardRef, currentElement: triggerElement } = useForwardExpose()
4953
const isFormControl = useFormControl(triggerElement)
54+
// Hidden form input is a sibling (not nested) of the control to avoid the
55+
// `nested-interactive` a11y violation; forward the parent scope id for scoped styles.
56+
const scopeIdAttrs = useForwardScopeId()
5057
5158
const ariaLabel = computed(() => props.id && triggerElement.value ? (document.querySelector(`[for="${props.id}"]`) as HTMLLabelElement)?.innerText ?? props.value : undefined)
5259
@@ -72,13 +79,12 @@ function handleClick(event: MouseEvent) {
7279

7380
<template>
7481
<Primitive
75-
v-bind="$attrs"
7682
:id="id"
7783
:ref="forwardRef"
7884
role="radio"
7985
:type="as === 'button' ? 'button' : undefined"
8086
:as="as"
81-
:aria-checked="checked"
87+
:aria-checked="checked ?? false"
8288
:aria-label="ariaLabel"
8389
:as-child="asChild"
8490
:disabled="disabled ? '' : undefined"
@@ -87,19 +93,21 @@ function handleClick(event: MouseEvent) {
8793
:value="value"
8894
:required="required"
8995
:name="name"
96+
v-bind="{ ...scopeIdAttrs, ...$attrs }"
9097
@click.stop="handleClick"
9198
>
9299
<slot :checked="checked" />
93-
94-
<VisuallyHiddenInput
95-
v-if="isFormControl && name"
96-
type="radio"
97-
tabindex="-1"
98-
:value="value"
99-
:checked="!!checked"
100-
:name="name"
101-
:disabled="disabled"
102-
:required="required"
103-
/>
104100
</Primitive>
101+
102+
<VisuallyHiddenInput
103+
v-if="isFormControl && name"
104+
type="radio"
105+
tabindex="-1"
106+
:value="value"
107+
:checked="!!checked"
108+
:name="name"
109+
:disabled="disabled"
110+
:required="required"
111+
v-bind="scopeIdAttrs"
112+
/>
105113
</template>

packages/core/src/RadioGroup/RadioGroup.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils'
44
import { beforeEach, describe, expect, it } from 'vitest'
55
import { axe } from 'vitest-axe'
66
import { handleSubmit, sleep } from '@/test'
7+
import { RadioGroupItem } from '..'
78
import Radio from './story/_Radio.vue'
89
import RadioGroup from './story/_RadioGroup.vue'
910

@@ -37,7 +38,7 @@ describe('given a default RadioGroup', () => {
3738
})
3839

3940
it('should emit `select` event', async () => {
40-
const radiosComponent = wrapper.findAllComponents('button') as VueWrapper[]
41+
const radiosComponent = wrapper.findAllComponents(RadioGroupItem) as VueWrapper[]
4142
expect(radiosComponent[2].emitted('select')?.[0]?.[0]).toBeTruthy()
4243
})
4344

@@ -105,6 +106,14 @@ describe('given radio in a form', async () => {
105106
expect(wrapper.find('[type="Radio"]').exists()).toBe(true)
106107
})
107108

109+
it('should pass axe accessibility tests', async () => {
110+
expect(await axe(wrapper.element)).toHaveNoViolations()
111+
})
112+
113+
it('should not nest the hidden input inside the interactive control', () => {
114+
expect(wrapper.find('button input').exists()).toBe(false)
115+
})
116+
108117
describe('after clicking submit button', () => {
109118
beforeEach(async () => {
110119
await wrapper.find('button').trigger('click')

packages/core/src/Switch/Switch.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ describe('given switch in a form', async () => {
6363
expect(wrapper.find('[type="checkbox"]').exists()).toBe(true)
6464
})
6565

66+
it('should pass axe accessibility tests', async () => {
67+
expect(await axe(wrapper.element)).toHaveNoViolations()
68+
})
69+
70+
it('should not nest the hidden input inside the interactive control', () => {
71+
expect(wrapper.find('button input').exists()).toBe(false)
72+
})
73+
6674
describe('after clicking submit button', () => {
6775
beforeEach(async () => {
6876
await wrapper.find('button').trigger('click')

packages/core/src/Switch/SwitchRoot.vue

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { ComputedRef, Ref } from 'vue'
33
import type { PrimitiveProps } from '@/Primitive'
44
import type { FormFieldProps } from '@/shared/types'
5-
import { createContext, useFormControl, useForwardExpose } from '@/shared'
5+
import { createContext, useFormControl, useForwardExpose, useForwardScopeId } from '@/shared'
66
77
export interface SwitchRootProps<T = boolean> extends PrimitiveProps, FormFieldProps {
88
/** The state of the switch when it is initially rendered. Use when you do not need to control its state. */
@@ -45,6 +45,10 @@ import { computed, toRefs } from 'vue'
4545
import { Primitive } from '@/Primitive'
4646
import { VisuallyHiddenInput } from '@/VisuallyHidden'
4747
48+
defineOptions({
49+
inheritAttrs: false,
50+
})
51+
4852
const props = withDefaults(defineProps<SwitchRootProps<T>>(), {
4953
as: 'button',
5054
modelValue: undefined,
@@ -81,6 +85,9 @@ function toggleCheck() {
8185
8286
const { forwardRef, currentElement } = useForwardExpose()
8387
const isFormControl = useFormControl(currentElement)
88+
// Hidden form input is a sibling (not nested) of the control to avoid the
89+
// `nested-interactive` a11y violation; forward the parent scope id for scoped styles.
90+
const scopeIdAttrs = useForwardScopeId()
8491
const ariaLabel = computed(() => props.id && currentElement.value ? (document.querySelector(`[for="${props.id}"]`) as HTMLLabelElement)?.innerText : undefined)
8592
8693
provideSwitchRootContext({
@@ -92,7 +99,6 @@ provideSwitchRootContext({
9299

93100
<template>
94101
<Primitive
95-
v-bind="$attrs"
96102
:id="id"
97103
:ref="forwardRef"
98104
role="switch"
@@ -106,22 +112,24 @@ provideSwitchRootContext({
106112
:as-child="asChild"
107113
:as="as"
108114
:disabled="disabled"
115+
v-bind="{ ...scopeIdAttrs, ...$attrs }"
109116
@click="toggleCheck"
110117
@keydown.enter.prevent="toggleCheck"
111118
>
112119
<slot
113120
:model-value="modelValue"
114121
:checked="checked"
115122
/>
116-
117-
<VisuallyHiddenInput
118-
v-if="isFormControl && name"
119-
type="checkbox"
120-
:name="name"
121-
:disabled="disabled"
122-
:required="required"
123-
:value="value"
124-
:checked="checked"
125-
/>
126123
</Primitive>
124+
125+
<VisuallyHiddenInput
126+
v-if="isFormControl && name"
127+
type="checkbox"
128+
:name="name"
129+
:disabled="disabled"
130+
:required="required"
131+
:value="value"
132+
:checked="checked"
133+
v-bind="scopeIdAttrs"
134+
/>
127135
</template>

packages/core/src/Toggle/Toggle.test.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,29 @@ describe('given default Toggle', () => {
1414
})
1515

1616
it('should pass axe accessibility tests', async () => {
17-
expect(await axe(wrapper.element)).toHaveNoViolations()
17+
expect(await axe(wrapper.get('button').element)).toHaveNoViolations()
1818
})
1919

2020
it('should not be toggled yet', () => {
21-
expect(wrapper.attributes('data-state')).toBe('off')
21+
expect(wrapper.get('button').attributes('data-state')).toBe('off')
2222
})
2323

2424
describe('after toggling', () => {
25-
beforeEach(() => {
26-
wrapper.trigger('click')
25+
beforeEach(async () => {
26+
await wrapper.get('button').trigger('click')
2727
})
2828

2929
it('should be toggled on', () => {
30-
expect(wrapper.attributes('data-state')).toBe('on')
30+
expect(wrapper.get('button').attributes('data-state')).toBe('on')
3131
})
3232

3333
describe('after toggling again', () => {
34-
beforeEach(() => {
35-
wrapper.trigger('click')
34+
beforeEach(async () => {
35+
await wrapper.get('button').trigger('click')
3636
})
3737

3838
it('should be toggled off', () => {
39-
expect(wrapper.attributes('data-state')).toBe('off')
39+
expect(wrapper.get('button').attributes('data-state')).toBe('off')
4040
})
4141
})
4242
})
@@ -53,25 +53,44 @@ describe('given disabled Toggle', () => {
5353
})
5454

5555
it('should pass axe accessibility tests', async () => {
56-
expect(await axe(wrapper.element)).toHaveNoViolations()
56+
expect(await axe(wrapper.get('button').element)).toHaveNoViolations()
5757
})
5858

5959
it('should not be toggled yet', () => {
60-
expect(wrapper.attributes('data-state')).toBe('off')
60+
expect(wrapper.get('button').attributes('data-state')).toBe('off')
6161
})
6262

6363
describe('try toggling', () => {
64-
beforeEach(() => {
65-
wrapper.trigger('click')
64+
beforeEach(async () => {
65+
await wrapper.get('button').trigger('click')
6666
})
6767

6868
it('should be toggled off', () => {
69-
expect(wrapper.attributes('data-state')).toBe('off')
69+
expect(wrapper.get('button').attributes('data-state')).toBe('off')
7070
})
7171

7272
it('should render disable attributes', () => {
73-
expect(wrapper.attributes('data-disabled')).toBe('')
74-
expect(wrapper.attributes('disabled')).toBe('')
73+
expect(wrapper.get('button').attributes('data-disabled')).toBe('')
74+
expect(wrapper.get('button').attributes('disabled')).toBe('')
7575
})
7676
})
7777
})
78+
79+
describe('given Toggle in a form', () => {
80+
const wrapper = mount({
81+
components: { Toggle },
82+
template: '<form><Toggle name="test" aria-label="Toggle italic" /></form>',
83+
})
84+
85+
it('should have hidden input field', () => {
86+
expect(wrapper.find('[type="checkbox"]').exists()).toBe(true)
87+
})
88+
89+
it('should pass axe accessibility tests', async () => {
90+
expect(await axe(wrapper.element)).toHaveNoViolations()
91+
})
92+
93+
it('should not nest the hidden input inside the interactive control', () => {
94+
expect(wrapper.find('button input').exists()).toBe(false)
95+
})
96+
})

0 commit comments

Comments
 (0)