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`] = `