Skip to content

Commit a5abe89

Browse files
jcjpJ-Sek
andauthored
fix(VSelects): add aria-controls and aria-expanded (#22025)
Co-authored-by: J-Sek <[email protected]> fixes #22017
1 parent bb54746 commit a5abe89

File tree

6 files changed

+96
-27
lines changed

6 files changed

+96
-27
lines changed

packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { useForm } from '@/composables/form'
2222
import { forwardRefs } from '@/composables/forwardRefs'
2323
import { useItems } from '@/composables/list-items'
2424
import { useLocale } from '@/composables/locale'
25+
import { useMenuActivator } from '@/composables/menuActivator'
2526
import { useProxiedModel } from '@/composables/proxiedModel'
2627
import { makeTransitionProps } from '@/composables/transition'
2728

@@ -181,7 +182,7 @@ export const VAutocomplete = genericComponent<new <
181182
},
182183
})
183184

184-
const label = computed(() => menu.value ? props.closeText : props.openText)
185+
const { menuId, ariaExpanded, ariaControls, ariaLabel } = useMenuActivator(props, menu)
185186

186187
const listRef = ref<VList>()
187188
const listEvents = useScrolling(listRef, vTextFieldRef)
@@ -444,12 +445,15 @@ export const VAutocomplete = genericComponent<new <
444445
onClick:clear={ onClear }
445446
onMousedown:control={ onMousedownControl }
446447
onKeydown={ onKeydown }
448+
aria-expanded={ ariaExpanded.value }
449+
aria-controls={ ariaControls.value }
447450
>
448451
{{
449452
...slots,
450453
default: () => (
451454
<>
452455
<VMenu
456+
id={ menuId.value }
453457
ref={ vMenuRef }
454458
v-model={ menu.value }
455459
activator="parent"
@@ -649,8 +653,8 @@ export const VAutocomplete = genericComponent<new <
649653
icon={ props.menuIcon }
650654
onMousedown={ onMousedownMenuIcon }
651655
onClick={ noop }
652-
aria-label={ t(label.value) }
653-
title={ t(label.value) }
656+
aria-label={ ariaLabel.value }
657+
title={ ariaLabel.value }
654658
tabindex="-1"
655659
/>
656660
) : undefined }

packages/vuetify/src/components/VAutocomplete/__tests__/VAutocomplete.spec.browser.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -494,14 +494,14 @@ describe('VAutocomplete', () => {
494494

495495
it('should not open menu when closing a chip', async () => {
496496
const { element } = render(() => (
497-
<VAutocomplete
498-
chips
499-
closableChips
500-
items={['foo', 'bar']}
501-
label="Autocomplete"
502-
modelValue={['foo', 'bar']}
503-
multiple
504-
/>
497+
<VAutocomplete
498+
chips
499+
closableChips
500+
items={['foo', 'bar']}
501+
label="Autocomplete"
502+
modelValue={['foo', 'bar']}
503+
multiple
504+
/>
505505
))
506506

507507
expect(screen.queryByRole('listbox')).toBeNull()

packages/vuetify/src/components/VCombobox/VCombobox.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ import { useForm } from '@/composables/form'
2323
import { forwardRefs } from '@/composables/forwardRefs'
2424
import { transformItem, useItems } from '@/composables/list-items'
2525
import { useLocale } from '@/composables/locale'
26+
import { useMenuActivator } from '@/composables/menuActivator'
2627
import { useProxiedModel } from '@/composables/proxiedModel'
2728
import { makeTransitionProps } from '@/composables/transition'
2829

2930
// Utilities
30-
import { computed, mergeProps, nextTick, ref, shallowRef, toRef, watch } from 'vue'
31+
import { computed, mergeProps, nextTick, ref, shallowRef, watch } from 'vue'
3132
import {
3233
checkPrintable,
3334
deepEqual,
@@ -215,7 +216,7 @@ export const VCombobox = genericComponent<new <
215216
},
216217
})
217218

218-
const label = toRef(() => menu.value ? props.closeText : props.openText)
219+
const { menuId, ariaExpanded, ariaControls, ariaLabel } = useMenuActivator(props, menu)
219220

220221
watch(_search, value => {
221222
showAllItemsForNoMatch.value = false
@@ -507,12 +508,15 @@ export const VCombobox = genericComponent<new <
507508
onClick:clear={ onClear }
508509
onMousedown:control={ onMousedownControl }
509510
onKeydown={ onKeydown }
511+
aria-expanded={ ariaExpanded.value }
512+
aria-controls={ ariaControls.value }
510513
>
511514
{{
512515
...slots,
513516
default: () => (
514517
<>
515518
<VMenu
519+
id={ menuId.value }
516520
ref={ vMenuRef }
517521
v-model={ menu.value }
518522
activator="parent"
@@ -711,8 +715,8 @@ export const VCombobox = genericComponent<new <
711715
icon={ props.menuIcon }
712716
onMousedown={ onMousedownMenuIcon }
713717
onClick={ noop }
714-
aria-label={ t(label.value) }
715-
title={ t(label.value) }
718+
aria-label={ ariaLabel.value }
719+
title={ ariaLabel.value }
716720
tabindex="-1"
717721
/>
718722
) : undefined }

packages/vuetify/src/components/VSelect/VSelect.tsx

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import { forwardRefs } from '@/composables/forwardRefs'
2121
import { IconValue } from '@/composables/icons'
2222
import { makeItemsProps, useItems } from '@/composables/list-items'
2323
import { useLocale } from '@/composables/locale'
24+
import { makeMenuActivatorProps, useMenuActivator } from '@/composables/menuActivator'
2425
import { useProxiedModel } from '@/composables/proxiedModel'
2526
import { makeTransitionProps } from '@/composables/transition'
2627

2728
// Utilities
28-
import { computed, mergeProps, nextTick, ref, shallowRef, toRef, watch } from 'vue'
29+
import { computed, mergeProps, nextTick, ref, shallowRef, watch } from 'vue'
2930
import {
3031
camelizeProps,
3132
checkPrintable,
@@ -61,14 +62,6 @@ type Value <T, ReturnObject extends boolean, Multiple extends boolean> =
6162
export const makeSelectProps = propsFactory({
6263
chips: Boolean,
6364
closableChips: Boolean,
64-
closeText: {
65-
type: String,
66-
default: '$vuetify.close',
67-
},
68-
openText: {
69-
type: String,
70-
default: '$vuetify.open',
71-
},
7265
eager: Boolean,
7366
hideNoData: Boolean,
7467
hideSelected: Boolean,
@@ -92,6 +85,7 @@ export const makeSelectProps = propsFactory({
9285
itemColor: String,
9386
noAutoScroll: Boolean,
9487

88+
...makeMenuActivatorProps(),
9589
...makeItemsProps({ itemChildren: false }),
9690
}, 'Select')
9791

@@ -194,7 +188,7 @@ export const VSelect = genericComponent<new <
194188
},
195189
})
196190

197-
const label = toRef(() => menu.value ? props.closeText : props.openText)
191+
const { menuId, ariaExpanded, ariaControls, ariaLabel } = useMenuActivator(props, menu)
198192

199193
const computedMenuProps = computed(() => {
200194
return {
@@ -417,14 +411,17 @@ export const VSelect = genericComponent<new <
417411
onMousedown:control={ onMousedownControl }
418412
onBlur={ onBlur }
419413
onKeydown={ onKeydown }
420-
aria-label={ t(label.value) }
421-
title={ t(label.value) }
414+
aria-expanded={ ariaExpanded.value }
415+
aria-controls={ ariaControls.value }
416+
aria-label={ ariaLabel.value }
417+
title={ ariaLabel.value }
422418
>
423419
{{
424420
...slots,
425421
default: () => (
426422
<>
427423
<VMenu
424+
id={ menuId.value }
428425
ref={ vMenuRef }
429426
v-model={ menu.value }
430427
activator="parent"

packages/vuetify/src/components/VSelect/__tests__/VSelect.spec.browser.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,27 @@ describe('VSelect', () => {
729729
})
730730
})
731731

732+
it('should have reactive accessibility attributes', async () => {
733+
const { getByRole } = render(() => (
734+
<VSelect items={['Foo']} />
735+
))
736+
737+
const inputField = getByRole('combobox', { expanded: false })
738+
expect(inputField).toHaveAttribute('aria-expanded', 'false')
739+
expect(inputField).toHaveAttribute('aria-label', 'Open')
740+
expect(inputField.getAttribute('aria-controls')).toMatch(/^menu-v-\d+/)
741+
742+
await userEvent.click(inputField)
743+
744+
expect(inputField).toHaveAttribute('aria-expanded', 'true')
745+
expect(inputField).toHaveAttribute('aria-label', 'Close')
746+
747+
await userEvent.click(screen.getAllByRole('option')[0])
748+
749+
expect(inputField).toHaveAttribute('aria-expanded', 'false')
750+
expect(inputField).toHaveAttribute('aria-label', 'Open')
751+
})
752+
732753
describe('Showcase', () => {
733754
generate({ stories })
734755
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Utilities
2+
import { computed, toRef, toValue, useId } from 'vue'
3+
import { propsFactory } from '@/util'
4+
5+
// Types
6+
import type { MaybeRefOrGetter } from 'vue'
7+
import { useLocale } from './locale'
8+
9+
// Types
10+
export interface MenuActivatorProps {
11+
closeText: string
12+
openText: string
13+
}
14+
15+
// Composables
16+
export const makeMenuActivatorProps = propsFactory({
17+
closeText: {
18+
type: String,
19+
default: '$vuetify.close',
20+
},
21+
openText: {
22+
type: String,
23+
default: '$vuetify.open',
24+
},
25+
}, 'autocomplete')
26+
27+
export function useMenuActivator (props: MenuActivatorProps, isOpen: MaybeRefOrGetter<boolean>) {
28+
const { t } = useLocale()
29+
30+
const uid = useId()
31+
const menuId = computed(() => `menu-${uid}`)
32+
33+
const ariaExpanded = toRef(() => toValue(isOpen))
34+
const ariaControls = toRef(() => menuId.value)
35+
const ariaLabel = toRef(() => t(toValue(isOpen) ? props.closeText : props.openText))
36+
37+
return {
38+
menuId,
39+
ariaExpanded,
40+
ariaControls,
41+
ariaLabel,
42+
}
43+
}

0 commit comments

Comments
 (0)