fix(accessibility): Improve screen reader support for combobox and listbox components#3432
Conversation
|
🕸 Preview deploys |
There was a problem hiding this comment.
Pull request overview
This PR improves accessibility of grouped options across sl-select, sl-combobox, and the underlying listbox components, aiming to resolve Safari/VoiceOver issues by flattening semantics, hiding group headers from AT, and ensuring options expose consistent ARIA state/positioning.
Changes:
- Removed
role="group"/ group-level labeling fromsl-option-group, and madesl-option-group-headerconsistentlyaria-hidden="true". - Added flattened
aria-selected,aria-posinset, andaria-setsizeto options and augmented option accessible names with group context. - Added/updated tests for grouped-option accessibility behaviors.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/components/select/src/select.ts | Sets flattened ARIA attributes and group-context accessible names for select options. |
| packages/components/select/src/select.spec.ts | Adds assertions for grouped-options ARIA behavior in sl-select. |
| packages/components/listbox/src/option-group.ts | Removes role="group"/group labeling logic from option groups. |
| packages/components/listbox/src/option-group.spec.ts | Updates expectations for removed group role and hidden headers. |
| packages/components/listbox/src/option-group-header.ts | Ensures group headers are hidden from assistive technology. |
| packages/components/listbox/src/listbox.ts | Sets flattened ARIA attributes for virtualized listbox items and adds group context labels. |
| packages/components/combobox/src/combobox.ts | Sets flattened ARIA attributes and group-context accessible names in both light DOM and virtual list modes. |
| packages/components/combobox/src/combobox.spec.ts | Adds grouped-options accessibility tests for sl-combobox. |
a11ymiko
left a comment
There was a problem hiding this comment.
Wow! I'm huge fan of how change between 'selected'/'not selected' in Combobox is announced right now on Safari 😍
Right now the only small issue or improvement in that announcement would be to announce 'OPTION NAME, removed from selection X items selected' after user expand listbox again and deselect one option.
Right now when user expand listbox again and deselect one of options the only announcements they get is 'X items selected' without information about option removed from selection. (This is clearly visible without screen capture turned on, but when screen is being recorded VoiceOver behaves differently, which is VO issue more than Listbox issue)
Untitled.mov
…s and group headers
| const metadata = options.map(option => ({ | ||
| group: option.closest<OptionGroup>('sl-option-group')?.label, | ||
| label: option.textContent?.trim() || '', | ||
| option | ||
| })); |
| option.setAttribute('aria-posinset', position.toString()); | ||
| option.setAttribute('aria-setsize', setSize.toString()); | ||
| option.setAttribute('aria-selected', Boolean(selected).toString()); | ||
|
|
||
| if (group) { | ||
| option.setAttribute('aria-label', `${label} (${group})`); | ||
| } | ||
| } |
…stbox options by optimizing flattened position caching
…ormation-about-options-being-selected-and-not-selected-are-not-announced-by-screen-readers
| option.setAttribute('aria-posinset', position.toString()); | ||
| option.setAttribute('aria-setsize', setSize.toString()); | ||
| option.setAttribute('aria-selected', Boolean(selected).toString()); | ||
|
|
||
| if (group) { | ||
| option.setAttribute('aria-label', `${label} (${group})`); | ||
| } |
| // Add group context to accessible name for Safari/VoiceOver compatibility | ||
| if (item.group) { | ||
| el.setAttribute('aria-label', `${item.group}, ${item.label}`); | ||
| } |
| it('should group the selected options', () => { | ||
| expect(selectedGroup).to.exist; | ||
| expect(selectedGroup).to.have.attribute('aria-label', 'Selected'); | ||
| // Note: aria-label is no longer set since we removed role="group" for Safari/VoiceOver compatibility | ||
|
|
| it('should not have role="group" on group wrappers', () => { | ||
| const listbox = el.querySelector('sl-listbox'); | ||
| const groups = listbox?.shadowRoot?.querySelectorAll('[role="group"]'); | ||
|
|
||
| expect(groups).to.have.lengthOf(0); | ||
| }); |
| it('should include group context in option accessible names', () => { | ||
| // Verify that items have group information | ||
| const options = el.items.filter(item => 'option' in item); | ||
|
|
||
| expect(options[0].group).to.equal('Fruits'); | ||
| expect(options[1].group).to.equal('Fruits'); | ||
| expect(options[2].group).to.equal('Vegetables'); | ||
| expect(options[3].group).to.equal('Vegetables'); | ||
|
|
||
| // The aria-label with group context is set in #renderItem when virtualizer renders | ||
| }); |
…n listbox and clear stale labels when options move out of groups
…stbox by improving element filtering and aria-label handling
| /** Cache mapping each option item to its 0-based flattened position (excludes group headers). */ | ||
| #flattenedPositionCache?: WeakMap<ListboxItem<T, U>, number>; | ||
|
|
||
| /** Cache version matching the items version when the cache was last built. */ | ||
| #flattenedPositionCacheVersion = -1; | ||
|
|
||
| /** Monotonically increasing version, incremented whenever `items` changes. */ | ||
| #itemsVersion = 0; | ||
|
|
||
| /** Total number of option items in the last built cache. */ | ||
| #flattenedSetSize = 0; |
| getFlattenedPosition(item: ListboxItem<T, U>): number { | ||
| if (!this.items || !('option' in item)) return -1; | ||
|
|
||
| if ( | ||
| this.#flattenedPositionCacheVersion !== this.#itemsVersion || | ||
| !this.#flattenedPositionCache | ||
| ) { | ||
| this.#buildFlattenedPositionCache(); | ||
| } | ||
|
|
||
| return this.#flattenedPositionCache!.get(item) ?? -1; | ||
| } |
| getFlattenedSetSize(): number { | ||
| if (!this.items) return 0; | ||
|
|
||
| if ( | ||
| this.#flattenedPositionCacheVersion !== this.#itemsVersion || | ||
| !this.#flattenedPositionCache | ||
| ) { | ||
| this.#buildFlattenedPositionCache(); | ||
| } | ||
|
|
||
| return this.#flattenedSetSize; | ||
| } |
| #buildFlattenedPositionCache(): void { | ||
| this.#flattenedPositionCache = new WeakMap<ListboxItem<T, U>, number>(); | ||
| this.#flattenedPositionCacheVersion = this.#itemsVersion; | ||
|
|
||
| let position = 0; | ||
| (this.items ?? []).forEach(i => { | ||
| if ('option' in i) { | ||
| this.#flattenedPositionCache!.set(i, position++); | ||
| } | ||
| }); | ||
|
|
||
| this.#flattenedSetSize = position; | ||
| } |
| const currentGroup = option.closest<OptionGroup>('sl-option-group')?.label; | ||
| const hasGeneratedLabel = | ||
| option.hasAttribute('aria-label') && | ||
| option.getAttribute('aria-label')!.includes('(') && | ||
| option.getAttribute('aria-label')!.includes(')'); | ||
|
|
||
| // If option has a generated aria-label but no current group, it's stale | ||
| if (hasGeneratedLabel && !currentGroup) { | ||
| option.removeAttribute('aria-label'); | ||
| } |
| // Add group context to accessible name for Safari/VoiceOver compatibility | ||
| if (item.group) { | ||
| el.setAttribute('aria-label', `${item.group}, ${item.label}`); | ||
| } |
| it('should include group context in option accessible names', () => { | ||
| // Verify that items have group information | ||
| const options = el.items.filter(item => 'option' in item); | ||
|
|
||
| expect(options[0].group).to.equal('Fruits'); | ||
| expect(options[1].group).to.equal('Fruits'); | ||
| expect(options[2].group).to.equal('Vegetables'); | ||
| expect(options[3].group).to.equal('Vegetables'); | ||
|
|
||
| // The aria-label with group context is set in #renderItem when virtualizer renders | ||
| }); |
This pull request focuses on improving accessibility for grouped options in
sl-combobox,sl-listbox, andsl-selectcomponents, specifically targeting better compatibility with Safari/VoiceOver. The main changes remove problematic ARIA roles, ensure correct ARIA attributes for all options, and enhance accessible names to include group context. Comprehensive tests were added to verify these behaviors.Accessibility improvements for grouped options:
role="group"fromsl-option-groupelements and their headers, as it caused issues with Safari/VoiceOver, and stopped settingaria-labelon group elements. Group context is now conveyed through option labels instead. [1] [2] [3] [4]sl-option-group-header) are hidden from assistive technology by settingaria-hidden="true". [1] [2]ARIA attribute management and accessible names:
aria-selected,aria-posinset, andaria-setsizeattributes set, reflecting their flattened position in the list, for improved screen reader support. [1] [2] [3] [4]Test coverage:
sl-combobox,sl-listbox, andsl-selectto verify the absence ofrole="group", presence ofaria-hidden="true"on headers, correct ARIA attributes on options, and inclusion of group context in accessible names. [1] [2] [3] [4]These changes collectively ensure that grouped options are presented in a way that is accessible and compatible with major screen readers, with a particular focus on Safari/VoiceOver.