Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/eui/changelogs/upcoming/8829.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**Accessibility**

- Fixed missing screen reader output for `EuiComboBox` with `options` that have custom `id` attributes

164 changes: 125 additions & 39 deletions packages/eui/src/components/combo_box/combo_box.a11y.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EuiComboBoxOptionOption<ComboBoxOption>>;

const ComboBox = () => {
const [options] = useState<ComboBoxOptions>([
{
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<ComboBoxOptions>(
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<ComboBoxOptions>([]);

Expand Down Expand Up @@ -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(
<ComboBox
initialOptions={[
{
id: 'titan',
label: 'Titan',
'data-test-subj': 'titanOption',
},
{
id: 'enceladus',
label: 'Enceladus',
'data-test-subj': 'enceladusOption',
},
{
id: 'mimas',
label: 'Mimas',
'data-test-subj': 'mimasOption',
},
{
id: 'dione',
label: 'Dione',
'data-test-subj': 'dioneOption',
},
{
id: 'iapetus',
label: 'Iapetus',
'data-test-subj': 'iapetusOption',
},
]}
/>
);
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'
);
});
});
});
25 changes: 25 additions & 0 deletions packages/eui/src/components/combo_box/combo_box.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,31 @@ export const Playground: Story = {
render: (args) => <StatefulComboBox {...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) => <StatefulComboBox {...args} />,
};

export const WithTooltip: Story = {
parameters: {
controls: {
Expand Down
25 changes: 21 additions & 4 deletions packages/eui/src/components/combo_box/combo_box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ interface EuiComboBoxState<T> {
hasFocus: boolean;
isListOpen: boolean;
matchingOptions: Array<EuiComboBoxOptionOption<T>>;
listOptions: Array<HTMLButtonElement | null>;
searchValue: string;
}

Expand Down Expand Up @@ -249,6 +250,7 @@ export class EuiComboBox<T> extends Component<
showPrevSelected: Boolean(this.props.singleSelection),
sortMatchesBy: this.props.sortMatchesBy,
}),
listOptions: [],
searchValue: initialSearchValue,
};

Expand All @@ -271,6 +273,17 @@ export class EuiComboBox<T> extends Component<
this.listRefInstance = ref;
};

setListOptions = (node: HTMLButtonElement | null, index: number) => {
this.setState(({ listOptions }) => {
const _listOptions = listOptions;
_listOptions[index] = node;

return {
listOptions: _listOptions,
};
});
};

openList = () => {
this.setState({
isListOpen: true,
Expand Down Expand Up @@ -604,9 +617,10 @@ export class EuiComboBox<T> extends Component<
if (singleSelection) {
requestAnimationFrame(() => this.closeList());
} else {
this.setState({
activeOptionIndex: this.state.matchingOptions.indexOf(addedOption),
});
this.setState(({ listOptions, matchingOptions }) => ({
listOptions: listOptions.slice(0, matchingOptions.length - 1),
activeOptionIndex: matchingOptions.indexOf(addedOption),
}));
}
};

Expand Down Expand Up @@ -809,6 +823,7 @@ export class EuiComboBox<T> extends Component<
isCaseSensitive={isCaseSensitive}
isLoading={isLoading}
listRef={this.listRefCallback}
setListOptions={this.setListOptions}
matchingOptions={matchingOptions}
onCloseList={this.closeList}
onCreateOption={onCreateOption}
Expand Down Expand Up @@ -876,7 +891,9 @@ export class EuiComboBox<T> extends Component<
compressed={compressed}
focusedOptionId={
this.hasActiveOption()
? this.rootId(`_option-${this.state.activeOptionIndex}`)
? this.state.listOptions[this.state.activeOptionIndex]
?.id ??
this.rootId(`_option-${this.state.activeOptionIndex}`)
: undefined
}
fullWidth={fullWidth}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type EuiComboBoxOptionsListProps<T> = CommonProps & {
isCaseSensitive?: boolean;
isLoading?: boolean;
listRef: RefCallback<HTMLDivElement>;
setListOptions: (ref: HTMLButtonElement | null, index: number) => void;
matchingOptions: Array<EuiComboBoxOptionOption<T>>;
onCloseList: (event: Event) => void;
onCreateOption?: (
Expand Down Expand Up @@ -168,6 +169,7 @@ export class EuiComboBoxOptionsList<T> extends Component<
searchValue,
rootId,
matchingOptions,
setListOptions,
} = this.props;

const optionIndex = matchingOptions.indexOf(option);
Expand Down Expand Up @@ -220,6 +222,7 @@ export class EuiComboBoxOptionsList<T> extends Component<
title={label}
aria-setsize={matchingOptions.length}
aria-posinset={optionIndex + 1}
forwardRef={(ref) => setListOptions(ref, index)}
{...rest}
>
<span className="euiComboBoxOption__contentWrapper">
Expand Down Expand Up @@ -337,6 +340,7 @@ export class EuiComboBoxOptionsList<T> extends Component<
delimiter,
truncationProps,
listboxAriaLabel,
setListOptions,
...rest
} = this.props;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface EuiFilterSelectItemProps
isFocused?: boolean;
toolTipContent?: EuiComboBoxOptionOption['toolTipContent'];
toolTipProps?: EuiComboBoxOptionOption['toolTipProps'];
forwardRef?: (ref: HTMLButtonElement | null) => void;
}

const resolveIconAndColor = (checked?: FilterChecked) => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -95,6 +101,7 @@ export class EuiFilterSelectItemClass extends Component<
toolTipContent,
toolTipProps,
style,
forwardRef,
...rest
} = this.props;

Expand Down Expand Up @@ -140,7 +147,7 @@ export class EuiFilterSelectItemClass extends Component<

const optionItem = (
<button
ref={(ref) => (this.buttonRef = ref)}
ref={this.setButtonRef}
role="option"
type="button"
aria-selected={checked === 'on'}
Expand Down