diff --git a/packages/eui/changelogs/upcoming/8829.md b/packages/eui/changelogs/upcoming/8829.md new file mode 100644 index 00000000000..7013ae68ccf --- /dev/null +++ b/packages/eui/changelogs/upcoming/8829.md @@ -0,0 +1,6 @@ +- Added `setListOptionRefs` prop on `EuiComboBoxList` + +**Accessibility** + +- Fixed missing screen reader output for `EuiComboBox` with `options` that have custom `id` attributes + diff --git a/packages/eui/src/components/combo_box/combo_box.a11y.tsx b/packages/eui/src/components/combo_box/combo_box.a11y.tsx index e38436d010b..ee24997a2d6 100644 --- a/packages/eui/src/components/combo_box/combo_box.a11y.tsx +++ b/packages/eui/src/components/combo_box/combo_box.a11y.tsx @@ -14,50 +14,53 @@ import React, { useState } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from './index'; interface ComboBoxOption { + id?: string; label: string; 'data-test-subj': string; } type ComboBoxOptions = Array>; -const ComboBox = () => { - const [options] = useState([ - { - label: 'Titan', - 'data-test-subj': 'titanOption', - }, - { - label: 'Enceladus', - 'data-test-subj': 'enceladusOption', - }, - { - label: 'Mimas', - 'data-test-subj': 'mimasOption', - }, - { - label: 'Dione', - 'data-test-subj': 'dioneOption', - }, - { - label: 'Iapetus', - 'data-test-subj': 'iapetusOption', - }, - { - label: 'Phoebe', - 'data-test-subj': 'phoebeOption', - }, - { - label: 'Rhea', - 'data-test-subj': 'rheaOption', - }, - { - label: 'Tethys', - 'data-test-subj': 'tethysOption', - }, - { - label: 'Hyperion', - 'data-test-subj': 'hyperionOption', - }, - ]); +const ComboBox = ({ initialOptions }: { initialOptions?: ComboBoxOptions }) => { + const [options] = useState( + initialOptions ?? [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + }, + { + label: 'Enceladus', + 'data-test-subj': 'enceladusOption', + }, + { + label: 'Mimas', + 'data-test-subj': 'mimasOption', + }, + { + label: 'Dione', + 'data-test-subj': 'dioneOption', + }, + { + label: 'Iapetus', + 'data-test-subj': 'iapetusOption', + }, + { + label: 'Phoebe', + 'data-test-subj': 'phoebeOption', + }, + { + label: 'Rhea', + 'data-test-subj': 'rheaOption', + }, + { + label: 'Tethys', + 'data-test-subj': 'tethysOption', + }, + { + label: 'Hyperion', + 'data-test-subj': 'hyperionOption', + }, + ] + ); const [selectedOptions, setSelected] = useState([]); @@ -125,4 +128,87 @@ describe('EuiComboBox', () => { cy.checkAxe(); }); }); + + describe('Manual Accessibility check', () => { + it('sets the correct aria-activedescendant id', () => { + cy.realPress('Tab'); + cy.get('input[data-test-subj="comboBoxSearchInput"]').should( + 'have.focus' + ); + cy.get('button[data-test-subj="titanOption"]').should('exist'); + cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); + + cy.get('input[data-test-subj="comboBoxSearchInput"]') + .invoke('attr', 'aria-activedescendant') + .should('include', 'option-2'); + + cy.realPress('Enter'); + cy.realPress('ArrowDown'); + + cy.get('input[data-test-subj="comboBoxSearchInput"]') + .invoke('attr', 'aria-activedescendant') + .should('include', 'option-3'); + }); + + it('sets the correct aria-activedescendant id with custom option ids', () => { + cy.realMount( + + ); + cy.get('input[data-test-subj="comboBoxSearchInput"]').should('exist'); + + cy.realPress('Tab'); + cy.get('input[data-test-subj="comboBoxSearchInput"]').should( + 'have.focus' + ); + cy.get('button[data-test-subj="titanOption"]').should('exist'); + cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); + + cy.get('input[data-test-subj="comboBoxSearchInput"]').should( + 'have.attr', + 'aria-activedescendant', + 'mimas' + ); + + cy.realPress('Enter'); + cy.realPress('ArrowDown'); + + cy.get('input[data-test-subj="comboBoxSearchInput"]').should( + 'have.attr', + 'aria-activedescendant', + 'iapetus' + ); + }); + }); }); diff --git a/packages/eui/src/components/combo_box/combo_box.stories.tsx b/packages/eui/src/components/combo_box/combo_box.stories.tsx index 188bdd91389..56712a38a16 100644 --- a/packages/eui/src/components/combo_box/combo_box.stories.tsx +++ b/packages/eui/src/components/combo_box/combo_box.stories.tsx @@ -80,6 +80,31 @@ export const Playground: Story = { render: (args) => , }; +export const WithCustomOptionIds: Story = { + parameters: { + controls: { + include: ['options', 'selectedOptions', 'onChange'], + }, + // This story is visually effectively the same as Playground + loki: { skip: true }, + }, + args: { + options: [ + { id: 'item-1', label: 'Item 1' }, + { id: 'item-2', label: 'Item 2' }, + { id: 'item-3', label: 'Item 3' }, + { id: 'item-4', label: 'Item 4', disabled: true }, + { id: 'item-5', label: 'Item 5' }, + { id: 'item-6', label: 'Item 6' }, + { id: 'item-7', label: 'Item 7' }, + { id: 'item-8', label: 'Item 8' }, + { id: 'item-9', label: 'Item 9' }, + { id: 'item-10', label: 'Item 10' }, + ], + }, + render: (args) => , +}; + export const WithTooltip: Story = { parameters: { controls: { diff --git a/packages/eui/src/components/combo_box/combo_box.tsx b/packages/eui/src/components/combo_box/combo_box.tsx index 6e923b9f8e9..c5cfbf33cab 100644 --- a/packages/eui/src/components/combo_box/combo_box.tsx +++ b/packages/eui/src/components/combo_box/combo_box.tsx @@ -212,6 +212,7 @@ interface EuiComboBoxState { hasFocus: boolean; isListOpen: boolean; matchingOptions: Array>; + listOptionRefs: Array; searchValue: string; } @@ -249,6 +250,7 @@ export class EuiComboBox extends Component< showPrevSelected: Boolean(this.props.singleSelection), sortMatchesBy: this.props.sortMatchesBy, }), + listOptionRefs: [], searchValue: initialSearchValue, }; @@ -271,6 +273,17 @@ export class EuiComboBox extends Component< this.listRefInstance = ref; }; + setListOptionRefs = (node: HTMLButtonElement | null, index: number) => { + this.setState(({ listOptionRefs }) => { + const _listOptionRefs = listOptionRefs; + _listOptionRefs[index] = node; + + return { + listOptionRefs: _listOptionRefs, + }; + }); + }; + openList = () => { this.setState({ isListOpen: true, @@ -604,9 +617,10 @@ export class EuiComboBox extends Component< if (singleSelection) { requestAnimationFrame(() => this.closeList()); } else { - this.setState({ - activeOptionIndex: this.state.matchingOptions.indexOf(addedOption), - }); + this.setState(({ listOptionRefs, matchingOptions }) => ({ + listOptionRefs: listOptionRefs.slice(0, matchingOptions.length - 1), + activeOptionIndex: matchingOptions.indexOf(addedOption), + })); } }; @@ -809,6 +823,7 @@ export class EuiComboBox extends Component< isCaseSensitive={isCaseSensitive} isLoading={isLoading} listRef={this.listRefCallback} + setListOptionRefs={this.setListOptionRefs} matchingOptions={matchingOptions} onCloseList={this.closeList} onCreateOption={onCreateOption} @@ -876,7 +891,10 @@ export class EuiComboBox extends Component< compressed={compressed} focusedOptionId={ this.hasActiveOption() - ? this.rootId(`_option-${this.state.activeOptionIndex}`) + ? this.state.listOptionRefs[ + this.state.activeOptionIndex + ]?.id ?? + this.rootId(`_option-${this.state.activeOptionIndex}`) : undefined } fullWidth={fullWidth} diff --git a/packages/eui/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx b/packages/eui/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx index 3b80aae71a6..c5a9915fbdc 100644 --- a/packages/eui/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx +++ b/packages/eui/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx @@ -65,6 +65,7 @@ export type EuiComboBoxOptionsListProps = CommonProps & { isCaseSensitive?: boolean; isLoading?: boolean; listRef: RefCallback; + setListOptionRefs: (ref: HTMLButtonElement | null, index: number) => void; matchingOptions: Array>; onCloseList: (event: Event) => void; onCreateOption?: ( @@ -168,6 +169,7 @@ export class EuiComboBoxOptionsList extends Component< searchValue, rootId, matchingOptions, + setListOptionRefs, } = this.props; const optionIndex = matchingOptions.indexOf(option); @@ -220,6 +222,7 @@ export class EuiComboBoxOptionsList extends Component< title={label} aria-setsize={matchingOptions.length} aria-posinset={optionIndex + 1} + forwardRef={(ref) => setListOptionRefs(ref, index)} {...rest} > @@ -337,6 +340,7 @@ export class EuiComboBoxOptionsList extends Component< delimiter, truncationProps, listboxAriaLabel, + setListOptionRefs, ...rest } = this.props; diff --git a/packages/eui/src/components/filter_group/filter_select_item.tsx b/packages/eui/src/components/filter_group/filter_select_item.tsx index b7901a8c54a..a43df33777a 100644 --- a/packages/eui/src/components/filter_group/filter_select_item.tsx +++ b/packages/eui/src/components/filter_group/filter_select_item.tsx @@ -28,6 +28,7 @@ export interface EuiFilterSelectItemProps isFocused?: boolean; toolTipContent?: EuiComboBoxOptionOption['toolTipContent']; toolTipProps?: EuiComboBoxOptionOption['toolTipProps']; + forwardRef?: (ref: HTMLButtonElement | null) => void; } const resolveIconAndColor = (checked?: FilterChecked) => { @@ -65,6 +66,11 @@ export class EuiFilterSelectItemClass extends Component< hasFocus: false, }; + setButtonRef = (node: HTMLButtonElement | null) => { + this.buttonRef = node; + this.props.forwardRef?.(node); + }; + focus = () => { if (this.buttonRef) { this.buttonRef.focus(); @@ -95,6 +101,7 @@ export class EuiFilterSelectItemClass extends Component< toolTipContent, toolTipProps, style, + forwardRef, ...rest } = this.props; @@ -140,7 +147,7 @@ export class EuiFilterSelectItemClass extends Component< const optionItem = (