diff --git a/CHANGELOG.md b/CHANGELOG.md index 137692d0f44..1b64808729b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [`main`](https://github.com/elastic/eui/tree/main) -No public interface changes since `48.0.0`. +- Improved `EuiSelectable` keypress scenarios ([#5613](https://github.com/elastic/eui/pull/5613)) ## [`48.0.0`](https://github.com/elastic/eui/tree/v48.0.0) diff --git a/src/components/form/field_search/field_search.tsx b/src/components/form/field_search/field_search.tsx index 1ace03460e3..6ef40609824 100644 --- a/src/components/form/field_search/field_search.tsx +++ b/src/components/form/field_search/field_search.tsx @@ -247,7 +247,7 @@ export class EuiFieldSearch extends Component< isLoading={isLoading} clear={ isClearable && value && !rest.readOnly && !rest.disabled - ? { onClick: this.onClear } + ? { onClick: this.onClear, 'data-test-subj': 'clearSearchButton' } : undefined } compressed={compressed} diff --git a/src/components/selectable/selectable.spec.tsx b/src/components/selectable/selectable.spec.tsx new file mode 100644 index 00000000000..011190a2e9f --- /dev/null +++ b/src/components/selectable/selectable.spec.tsx @@ -0,0 +1,127 @@ +/* + * 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. + */ + +import React from 'react'; + +import { EuiSelectable, EuiSelectableProps } from './selectable'; + +const options: EuiSelectableProps['options'] = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + }, + { + label: 'Enceladus', + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + }, +]; + +describe('EuiSelectable', () => { + describe('with a `searchable` configuration', () => { + it('filters the list with search', () => { + cy.realMount( + + {(list, search) => ( + <> + {search} + {list} + + )} + + ); + + // Focus the second option + cy.get('input') + .realClick() + .realPress('{downarrow}') + .realPress('{downarrow}') + .then(() => { + cy.get('li[role=option]') + .eq(1) + .should('have.attr', 'aria-selected', 'true'); + }); + + // Focus remains on the second option + cy.get('input') + .realClick() + .realPress('Alt') + .realPress('Control') + .realPress('Meta') + .realPress('Shift') + .then(() => { + cy.get('li[role=option]') + .eq(1) + .should('have.attr', 'aria-selected', 'true'); + }); + + // Filter the list + cy.get('input') + .realClick() + .realType('enc') + .then(() => { + cy.get('li[role=option]') + .first() + .should('have.attr', 'title', 'Enceladus'); + }); + }); + + it('can clear the input', () => { + cy.realMount( + + {(list, search) => ( + <> + {search} + {list} + + )} + + ); + + cy.get('input') + .realClick() + .realType('enc') + .then(() => { + cy.get('li[role=option]') + .first() + .should('have.attr', 'title', 'Enceladus'); + }); + + // Using ENTER + cy.get('[data-test-subj="clearSearchButton"]') + .focus() + .realPress('{enter}') + .then(() => { + cy.get('li[role=option]') + .first() + .should('have.attr', 'title', 'Titan'); + }); + + cy.get('input') + .realClick() + .realType('enc') + .then(() => { + cy.get('li[role=option]') + .first() + .should('have.attr', 'title', 'Enceladus'); + }); + + // Using SPACE + cy.get('[data-test-subj="clearSearchButton"]') + .focus() + .realPress('Space') + .then(() => { + cy.get('li[role=option]') + .first() + .should('have.attr', 'title', 'Titan'); + }); + }); + }); +}); diff --git a/src/components/selectable/selectable.tsx b/src/components/selectable/selectable.tsx index b875d573a38..a5b62b01cd5 100644 --- a/src/components/selectable/selectable.tsx +++ b/src/components/selectable/selectable.tsx @@ -180,6 +180,7 @@ export class EuiSelectable extends Component< searchable: false, isPreFiltered: false, }; + private inputRef: HTMLInputElement | null = null; private containerRef = createRef(); private optionsListRef = createRef>(); private preventOnFocus = false; @@ -312,6 +313,12 @@ export class EuiSelectable extends Component< // via the input box, and as such only ENTER will toggle selection. return; } + if (event.target !== this.inputRef) { + // The captured event is not derived from the searchbox. + // The user is attempting to interact with an internal button, + // such as the clear button, and the event should not be altered. + return; + } event.preventDefault(); event.stopPropagation(); if (this.state.activeOptionIndex != null && optionsList) { @@ -321,6 +328,12 @@ export class EuiSelectable extends Component< } break; + case keys.ALT: + case keys.SHIFT: + case keys.CTRL: + case keys.META: + break; + default: this.setState({ activeOptionIndex: undefined }, this.onFocus); break; @@ -631,6 +644,7 @@ export class EuiSelectable extends Component< aria-activedescendant={this.makeOptionId(activeOptionIndex)} // the current faux-focused option placeholder={placeholderName} isPreFiltered={isPreFiltered ?? false} + inputRef={(node) => (this.inputRef = node)} {...(searchHasAccessibleName ? searchAccessibleName : { 'aria-label': placeholderName })} diff --git a/src/components/selectable/selectable_search/__snapshots__/selectable_search.test.tsx.snap b/src/components/selectable/selectable_search/__snapshots__/selectable_search.test.tsx.snap index 1ed7e795479..b645bac31ab 100644 --- a/src/components/selectable/selectable_search/__snapshots__/selectable_search.test.tsx.snap +++ b/src/components/selectable/selectable_search/__snapshots__/selectable_search.test.tsx.snap @@ -76,6 +76,7 @@ exports[`EuiSelectableSearch props defaultValue 1`] = `