-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Description
Provide a general summary of the issue here
The ComboBox announces the currently selected item when you interact with it in the open state (for example with up-down arrow keys). For each item (ListBoxItem
), an aria-label
prop can be used to set an accessible name. On Mac OSX this announcement does not respect the aria-label
prop of the ListBoxItem
and announces the regular name.
🤔 Expected Behavior?
When focusing an Option given by a ListBoxItem that has an aria-label
text set, the VoiceOver announcement should use the text set via aria-label
as title of the option, not the text provided via children
/textContent
😯 Current Behavior
When focusing an Option given by a ListBoxItem that has an aria-label
text set, the VoiceOver announcement does not take aria-label
of the ListBoxItem into account and hence uses the wrong title.
🔦 Context
The ComboBox component/hook has a special codepath for MacOSX VoiceOver which manually generates feedback for focus changes in the option list:
react-spectrum/packages/@react-aria/combobox/src/useComboBox.ts
Lines 277 to 302 in 7332bd6
// VoiceOver has issues with announcing aria-activedescendant properly on change | |
// (especially on iOS). We use a live region announcer to announce focus changes | |
// manually. In addition, section titles are announced when navigating into a new section. | |
let focusedItem = state.selectionManager.focusedKey != null && state.isOpen | |
? state.collection.getItem(state.selectionManager.focusedKey) | |
: undefined; | |
let sectionKey = focusedItem?.parentKey ?? null; | |
let itemKey = state.selectionManager.focusedKey ?? null; | |
let lastSection = useRef(sectionKey); | |
let lastItem = useRef(itemKey); | |
useEffect(() => { | |
if (isAppleDevice() && focusedItem != null && itemKey != null && itemKey !== lastItem.current) { | |
let isSelected = state.selectionManager.isSelected(itemKey); | |
let section = sectionKey != null ? state.collection.getItem(sectionKey) : null; | |
let sectionTitle = section?.['aria-label'] || (typeof section?.rendered === 'string' ? section.rendered : '') || ''; | |
let announcement = stringFormatter.format('focusAnnouncement', { | |
isGroupChange: (section && sectionKey !== lastSection.current) ?? false, | |
groupTitle: sectionTitle, | |
groupCount: section ? [...getChildNodes(section, state.collection)].length : 0, | |
optionText: focusedItem['aria-label'] || focusedItem.textValue || '', | |
isSelected | |
}); | |
announce(announcement); | |
} |
This codepath attempt to take a possible existingaria-label
of each item into account:
optionText: focusedItem['aria-label'] || focusedItem.textValue || '', |
focusedItem
comes from a collection
of the available items, which is build by a CollectionBuilder
from the JSX inside the ComboBox
:
react-spectrum/packages/react-aria-components/src/ComboBox.tsx
Lines 90 to 92 in 7332bd6
<CollectionBuilder content={content}> | |
{collection => <ComboBoxInner props={props} collection={collection} comboBoxRef={ref} />} | |
</CollectionBuilder> |
However, this CollectionBuilder
never populates the aria-label
attribute of the collection nodes:
react-spectrum/packages/@react-aria/collections/src/Document.ts
Lines 332 to 359 in 7332bd6
setProps<E extends Element>(obj: {[key: string]: any}, ref: ForwardedRef<E>, CollectionNodeClass: CollectionNodeClass<any>, rendered?: ReactNode, render?: (node: Node<T>) => ReactElement): void { | |
let node; | |
let {value, textValue, id, ...props} = obj; | |
if (this.node == null) { | |
node = new CollectionNodeClass(id ?? `react-aria-${++this.ownerDocument.nodeId}`); | |
this.node = node; | |
} else { | |
node = this.getMutableNode(); | |
} | |
props.ref = ref; | |
node.props = props; | |
node.rendered = rendered; | |
node.render = render; | |
node.value = value; | |
node.textValue = textValue || (typeof props.children === 'string' ? props.children : '') || obj['aria-label'] || ''; | |
if (id != null && id !== node.key) { | |
throw new Error('Cannot change the id of an item'); | |
} | |
if (props.colSpan != null) { | |
node.colSpan = props.colSpan; | |
} | |
if (this.isConnected) { | |
this.ownerDocument.queueUpdate(); | |
} | |
} |
Hence the announcement does not change if a aria-label
is used on an ListBoxItem
and continues to use the existing label/ticket.
🖥️ Steps to Reproduce
Codesandbox: https://codesandbox.io/p/sandbox/funny-wu-ryrjtl
Create a ComboBox in the following way
<ComboBox>
<Label>Preferred fruit or vegetable</Label>
<Input />
<Button />
<Popover>
<ListBox>
<ListBoxItem id="apple" textValue="Entry Apple" aria-label="Apple">
Entry: <b>Apple</b>
</ListBoxItem>
<ListBoxItem id="banana" textValue="Entry Banana" aria-label="Banana">
Entry: <b>Banana</b>
</ListBoxItem>
</ListBox>
</Popover>
</ComboBox>
Activate VoiceOver, then open the combobox via Keyboard. Navigate over the items via arrow keys. You will hear that VoiceOver will read "Entry Apple" / "Entry Banana", despite the aria-label being "Apple" / "Banana".
💁 Possible Solution
I see two ways to fix this:
-
modify
CollectionBuilder
implementation to correctly initialize thearia-label
attributed from theListBoxItem
. I created a PR with this solution strategy: fix: Combobox VoiceOver announcement not respectingaria-label
ofListBoxItem
#8908 -
Modify the custom ComboBox VoiceOver codepath to read
focusedItem.props.['aria-label']
instead offocusedItem['aria-label']
. There seeem to be some inconsistencies whetheraria-label
is read from theprop
or thearia-label
attribute from the collection item.
Version
1.12.2
What browsers are you seeing the problem on?
Chrome, Safari, Firefox
What operating system are you using?
Mac OSX 15.6.1