Skip to content

Commit

Permalink
[EuiSuperSelect] Fix various focus and accessibility issues: The Sequ…
Browse files Browse the repository at this point in the history
…el, Part Two (#7650)
  • Loading branch information
cee-chen authored Apr 5, 2024
1 parent 5016291 commit d08f49c
Show file tree
Hide file tree
Showing 7 changed files with 273 additions and 31 deletions.
6 changes: 6 additions & 0 deletions changelogs/upcoming/7650.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**Accessibility**

- `EuiSuperSelect` now correctly reads out parent `EuiFormRow` labels to screen readers
- `EuiSuperSelect` now more closely mimics native `<select>` behavior in its keyboard behavior and navigation
- `EuiSuperSelect` no longer strands keyboard focus on close
- `EuiSuperSelect` now correctly allows keyboard navigating past disabled options in the middle of the options list
21 changes: 15 additions & 6 deletions src-docs/src/views/super_select/super_select_basic.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React, { useState } from 'react';

import { EuiSuperSelect, EuiHealth } from '../../../../src/components';
import {
EuiSuperSelect,
EuiHealth,
EuiFormRow,
} from '../../../../src/components';

export default () => {
const options = [
Expand Down Expand Up @@ -40,10 +44,15 @@ export default () => {
};

return (
<EuiSuperSelect
options={options}
valueOfSelected={value}
onChange={(value) => onChange(value)}
/>
<EuiFormRow
label="Status"
helpText="This super select is inside a form row."
>
<EuiSuperSelect
options={options}
valueOfSelected={value}
onChange={(value) => onChange(value)}
/>
</EuiFormRow>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,10 @@ exports[`EuiSuperSelect renders 1`] = `
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
tabindex="-1"
/>
<div
data-focus-lock-disabled="false"
data-focus-lock-disabled="disabled"
>
<p
class="emotion-euiScreenReaderOnly"
Expand Down Expand Up @@ -243,7 +243,7 @@ exports[`EuiSuperSelect renders 1`] = `
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
tabindex="-1"
/>
</div>
</div>
Expand Down
164 changes: 164 additions & 0 deletions src/components/form/super_select/super_select.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

/// <reference types="cypress" />
/// <reference types="cypress-real-events" />
/// <reference types="../../../../cypress/support" />

import React, { useState } from 'react';
import { EuiFormRow } from '../form_row';
import { EuiSuperSelect, EuiSuperSelectProps } from './super_select';

const options = [
{ value: '1', inputDisplay: <strong>Option #1</strong> },
{ value: '2', inputDisplay: 'Option #2' },
];

const ControlledSuperSelect = (props: Partial<EuiSuperSelectProps>) => {
const [selectedValue, setSelectedValue] = useState<string | undefined>();
const onChange = (value: string) => {
setSelectedValue(value);
};
return (
<EuiSuperSelect
options={options}
valueOfSelected={selectedValue}
onChange={onChange}
{...props}
/>
);
};

describe('EuiSuperSelect', () => {
const dropdownIsOpen = () =>
cy.get('.euiSuperSelect__listbox').should('be.visible');
const dropdownIsClosed = () =>
cy.get('.euiSuperSelect__listbox').should('not.exist');

it('opens the popover on specific key presses', () => {
cy.realMount(
<>
<EuiSuperSelect options={options} />
<button>other focusable element</button>
</>
);

cy.realPress('Tab');
cy.focused().should('have.class', 'euiSuperSelectControl');

const keys = ['ArrowUp', 'ArrowDown', 'Space', 'Enter'] as const; // Enter technically isn't supported by native `<select>` elements but `<buttons>` naturally support it, so we can test it anyway
keys.forEach((key) => {
cy.realPress(key);
dropdownIsOpen();
cy.realPress('Escape');
dropdownIsClosed();
});
cy.focused().should('have.class', 'euiSuperSelectControl');

// Should allow tabbing away from the super select when the dropdown is closed
cy.realPress('Tab');
cy.focused().should('not.have.class', 'euiSuperSelectControl');
});

it('closes the popover and selects the currently focused item on Enter or Tab keypress', () => {
cy.realMount(
<ControlledSuperSelect options={options} placeholder="Placeholder" />
);

cy.realPress('Tab');
cy.focused().should('have.text', 'Placeholder');

cy.realPress('Enter');
dropdownIsOpen();
cy.realPress('ArrowDown');
cy.realPress('Enter');
dropdownIsClosed();
cy.focused().should('have.text', 'Option #2');

cy.realPress('Enter');
dropdownIsOpen();
cy.realPress('ArrowUp');
cy.realPress('Tab');
dropdownIsClosed();
cy.focused().should('have.text', 'Option #1');
});

it('closes the popover without selecting the current item on Escape key', () => {
cy.realMount(
<ControlledSuperSelect options={options} valueOfSelected="1" />
);

cy.realPress('Tab');
cy.focused().should('have.text', 'Option #1');

cy.realPress('Enter');
dropdownIsOpen();
cy.realPress('ArrowDown');
cy.realPress('Escape');
dropdownIsClosed();
cy.focused().should('have.text', 'Option #1');
});

it('does not allow keyboard navigating past first or last options', () => {
cy.realMount(<EuiSuperSelect options={options} />);

cy.realPress('Tab');
cy.realPress('Enter');
dropdownIsOpen();

cy.focused().should('have.text', 'Option #1');
cy.realPress('ArrowUp');
cy.focused().should('have.text', 'Option #1');
cy.realPress('ArrowDown');
cy.focused().should('have.text', 'Option #2');
cy.realPress('ArrowDown');
cy.focused().should('have.text', 'Option #2');
});

it('navigates past disabled options', () => {
cy.realMount(
<EuiSuperSelect
options={[
{ value: 'disabled1', inputDisplay: 'disabled', disabled: true },
{ value: 'enabled1', inputDisplay: 'enabled 1' },
{ value: 'disabled2', inputDisplay: 'disabled 2', disabled: true },
{ value: 'disabled2', inputDisplay: 'disabled 3', disabled: true },
{ value: 'enabled2', inputDisplay: 'enabled 2' },
{ value: 'disabled3', inputDisplay: 'disabled 4', disabled: true },
]}
/>
);

cy.realPress('Tab');
cy.realPress('Enter');
dropdownIsOpen();

cy.focused().should('have.text', 'enabled 1');
cy.realPress('ArrowDown');
cy.focused().should('have.text', 'enabled 2');
cy.realPress('ArrowDown');
cy.focused().should('have.text', 'enabled 2');
cy.realPress('ArrowUp');
cy.focused().should('have.text', 'enabled 1');
});

it('retains form row focus state on dropdown navigation', () => {
cy.realMount(
<EuiFormRow label="test">
<EuiSuperSelect options={options} />
</EuiFormRow>
);

cy.realPress('Tab');
cy.get('.euiFormLabel').should('have.class', 'euiFormLabel-isFocused');
cy.realPress('Enter');
dropdownIsOpen();
cy.realPress('ArrowDown');
cy.get('.euiFormLabel').should('have.class', 'euiFormLabel-isFocused');
});
});
18 changes: 18 additions & 0 deletions src/components/form/super_select/super_select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { render, screen, waitForEuiPopoverOpen } from '../../../test/rtl';
import { shouldRenderCustomStyles } from '../../../test/internal';
import { requiredProps } from '../../../test';

import { EuiFormRow } from '../form_row';

import { EuiSuperSelect } from './super_select';

const options = [
Expand Down Expand Up @@ -59,6 +61,22 @@ describe('EuiSuperSelect', () => {
restoreErrors();
});

it('applies the correct label IDs when wrapped in an EuiFormRow', () => {
const { getByTestSubject } = render(
<EuiFormRow label="Label" helpText="Description" id="test">
<EuiSuperSelect options={options} data-test-subj="controlButton" />
</EuiFormRow>
);
const control = getByTestSubject('controlButton');

expect(control).toHaveAttribute('id', 'test-button');
expect(control).toHaveAttribute(
'aria-labelledby',
'test-button test-label'
);
expect(control).toHaveAttribute('aria-describedby', 'test-help-0');
});

describe('props', () => {
test('fullWidth', () => {
const { container } = render(
Expand Down
54 changes: 32 additions & 22 deletions src/components/form/super_select/super_select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Side Public License, v 1.
*/

import React, { Component, FocusEvent, ReactNode } from 'react';
import React, { Component, FocusEvent, ReactNode, createRef } from 'react';
import classNames from 'classnames';

import { CommonProps } from '../../common';
Expand Down Expand Up @@ -104,6 +104,7 @@ export class EuiSuperSelect<T = string> extends Component<

private itemNodes: Array<HTMLButtonElement | null> = [];
private _isMounted: boolean = false;
private controlButtonRef = createRef<HTMLButtonElement>();

describedById = htmlIdGenerator('euiSuperSelect_')('_screenreaderDescribeId');

Expand Down Expand Up @@ -146,17 +147,10 @@ export class EuiSuperSelect<T = string> extends Component<
return;
}

if (this.props.valueOfSelected != null) {
if (indexOfSelected != null) {
this.focusItemAt(indexOfSelected);
} else {
focusSelected();
}
if (this.props.valueOfSelected != null && indexOfSelected != null) {
this.focusItemAt(indexOfSelected);
} else {
const firstFocusableOption = this.props.options.findIndex(
({ disabled }) => disabled !== true
);
this.focusItemAt(firstFocusableOption);
this.focusItemAt(0);
}

if (this.props.onFocus) {
Expand All @@ -173,6 +167,11 @@ export class EuiSuperSelect<T = string> extends Component<
isPopoverOpen: false,
});

// Refocus back to the toggling control button on popover close
requestAnimationFrame(() => {
this.controlButtonRef.current?.focus();
});

if (this.props.onBlur) {
this.props.onBlur();
}
Expand All @@ -187,7 +186,12 @@ export class EuiSuperSelect<T = string> extends Component<
};

onSelectKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === keys.ARROW_UP || event.key === keys.ARROW_DOWN) {
// Mimic the ways native `<select>`s can be opened via keypress
if (
event.key === keys.ARROW_UP ||
event.key === keys.ARROW_DOWN ||
event.key === keys.SPACE
) {
event.preventDefault();
event.stopPropagation();
this.openPopover();
Expand All @@ -204,9 +208,10 @@ export class EuiSuperSelect<T = string> extends Component<
break;

case keys.TAB:
// no-op
// Mimic native `<select>` behavior, which selects an item on tab press
event.preventDefault();
event.stopPropagation();
(event.target as HTMLButtonElement).click();
break;

case keys.ARROW_UP:
Expand All @@ -223,11 +228,14 @@ export class EuiSuperSelect<T = string> extends Component<
}
};

focusItemAt(index: number) {
const targetElement = this.itemNodes[index];
if (targetElement != null) {
targetElement.focus();
focusItemAt(index: number, direction?: ShiftDirection) {
let targetElement = this.itemNodes[index];
// If the current index is disabled, find the next non-disabled element
while (targetElement && targetElement.disabled) {
direction === ShiftDirection.BACK ? index-- : index++;
targetElement = this.itemNodes[index];
}
targetElement?.focus();
}

shiftFocus(direction: ShiftDirection) {
Expand All @@ -240,16 +248,16 @@ export class EuiSuperSelect<T = string> extends Component<
// somehow the select options has lost focus
targetElementIndex = 0;
} else {
// Note: this component purposely does not cycle arrow key navigation
// to match native <select> elements
if (direction === ShiftDirection.BACK) {
targetElementIndex =
currentIndex === 0 ? this.itemNodes.length - 1 : currentIndex - 1;
targetElementIndex = currentIndex - 1;
} else {
targetElementIndex =
currentIndex === this.itemNodes.length - 1 ? 0 : currentIndex + 1;
targetElementIndex = currentIndex + 1;
}
}

this.focusItemAt(targetElementIndex);
this.focusItemAt(targetElementIndex, direction);
}

render() {
Expand Down Expand Up @@ -304,6 +312,7 @@ export class EuiSuperSelect<T = string> extends Component<
isInvalid={isInvalid}
compressed={compressed}
{...rest}
buttonRef={this.controlButtonRef}
/>
);

Expand Down Expand Up @@ -339,6 +348,7 @@ export class EuiSuperSelect<T = string> extends Component<
isOpen={isOpen || this.state.isPopoverOpen}
input={button}
fullWidth={fullWidth}
disableFocusTrap // This component handles its own focus manually
>
<EuiScreenReaderOnly>
<p id={this.describedById}>
Expand Down
Loading

0 comments on commit d08f49c

Please sign in to comment.