Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
## [`main`](https://github.com/elastic/eui/tree/main)

No public interface changes since `50.0.0`.
- Enhanced `EuiSuggest` to fire the `onItemClick` callback on Enter key press as well as clicks ([#5693](https://github.com/elastic/eui/pull/5693))

**Bug fixes**

- Fixed non-searchable `EuiSelectable`s not selecting items with the Enter & Space keys ([#5693](https://github.com/elastic/eui/pull/5693))

## [`50.0.0`](https://github.com/elastic/eui/tree/v50.0.0)

Expand Down
132 changes: 132 additions & 0 deletions src/components/selectable/selectable.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,137 @@ describe('EuiSelectable', () => {
.should('have.attr', 'title', 'Titan');
});
});

it('allows pressing the Enter key to select an item', () => {
const onChange = cy.stub();
cy.realMount(
<EuiSelectable searchable options={options} onChange={onChange}>
{(list, search) => (
<>
{search}
{list}
</>
)}
</EuiSelectable>
);

cy.realPress('Tab');
cy.realPress('Enter').then(() => {
expect(onChange).to.have.been.calledWith([
{ ...options[0], checked: 'on' },
options[1],
options[2],
]);
});
});

it('does not allow pressing the Space key to select an item while in the searchbox (should filter instead)', () => {
const onItemChange = cy.stub();
const onSearchChange = cy.stub();
cy.realMount(
<EuiSelectable
searchable
options={options}
onChange={onItemChange}
searchProps={{ onChange: onSearchChange }}
>
{(list, search) => (
<>
{search}
{list}
</>
)}
</EuiSelectable>
);

cy.realPress('Tab');
cy.realPress('Space').then(() => {
expect(onItemChange).not.have.been.called;
expect(onSearchChange).to.have.been.calledWith(' ');
});
});

// mouse+keyboard combo users
it('allows users to click into an list item and still press Enter or Space to toggle list items', () => {
const onChange = cy.stub();
cy.realMount(
<EuiSelectable searchable options={options} onChange={onChange}>
{(list, search) => (
<>
{search}
{list}
</>
)}
</EuiSelectable>
);

cy.get('li[role=option]')
.first()
.click()
.then(() => {
expect(onChange).to.have.been.calledWith([
{ ...options[0], checked: 'on' },
options[1],
options[2],
]);
});
cy.realPress('ArrowDown')
.realPress('Enter')
.then(() => {
expect(onChange).to.have.been.calledWith([
options[0], // FYI: doesn't remain `on` because `options` is not controlled to remember state
{ ...options[1], checked: 'on' },
options[2],
]);
});
cy.realPress('ArrowDown')
.realPress('Space')
.then(() => {
expect(onChange).to.have.been.calledWith([
options[0],
options[1],
{ ...options[2], checked: 'on' },
]);
});
});
});

describe('without a `searchable` configuration', () => {
it('allows pressing the Enter key to select an item', () => {
const onChange = cy.stub();
cy.realMount(
<EuiSelectable options={options} onChange={onChange}>
{(list) => <>{list}</>}
</EuiSelectable>
);

cy.realPress('Tab');
cy.realPress('ArrowDown');
cy.realPress('Enter').then(() => {
expect(onChange).to.have.been.calledWith([
options[0],
{ ...options[1], checked: 'on' },
options[2],
]);
});
});

it('allows pressing the Space key to select an item', () => {
const onChange = cy.stub();
cy.realMount(
<EuiSelectable options={options} onChange={onChange}>
{(list) => <>{list}</>}
</EuiSelectable>
);
cy.realPress('Tab');
cy.repeatRealPress('ArrowDown', 2);
cy.realPress('Space').then(() => {
expect(onChange).to.have.been.calledWith([
options[0],
options[1],
{ ...options[2], checked: 'on' },
]);
});
});
});
});
32 changes: 20 additions & 12 deletions src/components/selectable/selectable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,22 +305,30 @@ export class EuiSelectable<T = {}> extends Component<
this.incrementActiveOptionIndex(1);
break;

// For non-searchable instances, SPACE interaction should align with
// the user expectation of selection toggling (e.g., input[type=checkbox]).
// ENTER is also a valid selection mechanism in this case.
case keys.ENTER:
case keys.SPACE:
if (event.key === keys.SPACE && this.props.searchable) {
// For non-searchable instances, SPACE interaction should align with
// the user expectation of selection toggling (e.g., input[type=checkbox]).
// ENTER is also a valid selection mechanism in this case.
//
if (this.props.searchable) {
// For searchable instances, SPACE is reserved as a character for filtering
// 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;
if (event.target === this.inputRef && event.key === keys.SPACE) {
return;
}
// Check if the user is interacting with something other than the
// searchbox or selection list. If not, the user is attempting to
// interact with an internal button such as the clear button,
// and the event should not be altered.
if (
!(
event.target === this.inputRef ||
event.target ===
this.optionsListRef.current?.listBoxRef?.parentElement
)
) {
return;
}
}
event.preventDefault();
event.stopPropagation();
Expand Down
2 changes: 2 additions & 0 deletions src/components/suggest/__snapshots__/suggest.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ exports[`EuiSuggest props isVirtualized 1`] = `
"textWrap": "truncate",
}
}
onChange={[Function]}
options={
Array [
Object {
Expand Down Expand Up @@ -489,6 +490,7 @@ exports[`EuiSuggest props maxHeight 1`] = `
"textWrap": "wrap",
}
}
onChange={[Function]}
options={
Array [
Object {
Expand Down
23 changes: 22 additions & 1 deletion src/components/suggest/suggest.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,30 @@ describe('EuiSuggest', () => {
.first()
.click()
.then(() => {
expect(handler).to.be.called;
expect(handler).to.be.calledWith(sampleItems[0]);
});
});

it('is called when an option is pressed via the Enter key', () => {
const handler = cy.stub();
cy.mount(
<EuiSuggest
aria-label="onItemClick"
suggestions={sampleItems}
onItemClick={handler}
/>
);

cy.get('input')
.click()
.then(() => {
expect(cy.get('ul')).to.exist;
});
cy.realPress('ArrowDown');
cy.realPress('Enter').then(() => {
expect(handler).to.be.calledWith(sampleItems[0]);
});
});
});

describe('onInput', () => {
Expand Down
22 changes: 21 additions & 1 deletion src/components/suggest/suggest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
*/

import React from 'react';
import { render, mount } from 'enzyme';
import { render, mount, shallow } from 'enzyme';
import { requiredProps } from '../../test/required_props';

import { EuiSelectable } from '../selectable';
import { EuiSuggest, EuiSuggestionProps } from './suggest';
import { ALL_STATUSES } from './types';

Expand Down Expand Up @@ -138,6 +139,25 @@ describe('EuiSuggest', () => {
});
});

describe('onItemClick', () => {
it('passes an onChange callback to the underlying EuiSelectable, which will fire on list item clicks and enter keypresses', () => {
const onItemClick = jest.fn();
const component = shallow(
<EuiSuggest
{...requiredProps}
suggestions={sampleItems}
onItemClick={onItemClick}
/>
);

const options = component.find(EuiSelectable).prop('options');
options[1].checked = 'on';
component.find(EuiSelectable).simulate('change', options);

expect(onItemClick).toHaveBeenCalledWith(sampleItems[1]);
});
});

test('remaining EuiFieldSearch props are spread to the search input', () => {
const component = render(
<EuiSuggest
Expand Down
40 changes: 23 additions & 17 deletions src/components/suggest/suggest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import React, {
FormEvent,
FunctionComponent,
useState,
useCallback,
} from 'react';
import classNames from 'classnames';
import { CommonProps, ExclusiveUnion } from '../common';
Expand All @@ -22,7 +23,7 @@ import { useEuiI18n } from '../i18n';
import { EuiInputPopover } from '../popover';
import {
EuiSelectable,
EuiSelectableListItemProps,
EuiSelectableOption,
EuiSelectableSearchableSearchProps,
} from '../selectable';
import { EuiToolTip } from '../tool_tip';
Expand Down Expand Up @@ -56,11 +57,7 @@ const suggestItemPropsKeys = [
'descriptionDisplay',
];

export interface EuiSuggestionProps
extends CommonProps,
_EuiSuggestItemPropsBase {
onClick?: EuiSelectableListItemProps['onClick'];
}
export type EuiSuggestionProps = CommonProps & _EuiSuggestItemPropsBase;

type _EuiSuggestProps = CommonProps &
Omit<
Expand Down Expand Up @@ -241,12 +238,7 @@ export const EuiSuggest: FunctionComponent<EuiSuggestProps> = ({
/**
* Options list
*/
const suggestionList = suggestions.map((item: EuiSuggestionProps) => {
const { className, ...props } = item;
if (onItemClick) {
props.onClick = () => onItemClick(item);
}

const suggestionList = suggestions.map((props: EuiSuggestionProps) => {
// Omit props destined for the EuiSuggestItem so that they don't
// cause warnings or render in the DOM of the EuiSelectableItem
const data = {};
Expand All @@ -263,17 +255,30 @@ export const EuiSuggest: FunctionComponent<EuiSuggestProps> = ({
return {
...(liProps as typeof props),
data,
className: classNames(className, 'euiSuggestItemOption'),
className: classNames(props.className, 'euiSuggestItemOption'),
// Force truncation if `isVirtualized` is true
truncate: isVirtualized ? true : props.truncate,
};
});

const renderOption = (option: EuiSuggestionProps) => {
// `onClick` handled by EuiSelectable
const { onClick, ...props } = option;
const renderOption = useCallback((props: EuiSuggestionProps) => {
return <EuiSuggestItem {...props} />;
};
}, []);

const onItemSelect = useCallback(
(options: EuiSelectableOption[]) => {
if (onItemClick) {
const selectedIndex = options.findIndex(
(option) => option.checked === 'on'
);
if (selectedIndex >= 0) {
const selectedSuggestion = suggestions[selectedIndex];
onItemClick(selectedSuggestion);
}
}
},
[onItemClick, suggestions]
);

const classes = classNames('euiInputPopover', {
'euiInputPopover--fullWidth': fullWidth,
Expand All @@ -286,6 +291,7 @@ export const EuiSuggest: FunctionComponent<EuiSuggestProps> = ({
height={isVirtualized ? undefined : 'full'}
options={suggestionList}
renderOption={renderOption}
onChange={onItemSelect}
listProps={{
bordered: false,
showIcons: false,
Expand Down