Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix EuiComboBox focus trap #866

Merged
merged 11 commits into from
May 25, 2018
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

- `EuiSearchBar` no longer has an `onParse` callback, and now passes an object to `onChange` with the shape `{ query, queryText, error }` ([#863](https://github.com/elastic/eui/pull/863))
- `EuiInMemoryTable`'s `search.onChange` callback now passes an object with `{ query, queryText, error }` instead of only the query ([#863](https://github.com/elastic/eui/pull/863))
- `EuiFormControlLayout` no longer has `onClear`, `iconSide`, or `onIconClick` props. Instead of `onClear` it now accepts a `clear` object of the shape `{ onClick }`. Instead of the icon props, it now accepts a single `icon` prop which be either a string or an object of the shape `{ type, side, onClick }`. ([#866](https://github.com/elastic/eui/pull/866))

**Bug fixes**

- `EuiComboBox` is no longer a focus trap, the clear button is now keyboard-accessible, and the virtualized list no longer interferes with the tab order ([#866](https://github.com/elastic/eui/pull/866))
- `EuiButton`, `EuiButtonEmpty`, and `EuiButtonIcon` now look and behave disabled when `isDisabled={true}` ([#862](https://github.com/elastic/eui/pull/862))
- `EuiGlobalToastList` no longer triggers `Uncaught TypeError: _this.callback is not a function` ([#865](https://github.com/elastic/eui/pull/865))
- `EuiGlobalToastList` checks to see if it has dismissed a toast before re-dismissing it ([#868](https://github.com/elastic/eui/pull/868))
Expand Down
28 changes: 14 additions & 14 deletions src/components/combo_box/__snapshots__/combo_box.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ exports[`EuiComboBox is rendered 1`] = `
>
<EuiComboBoxInput
autoSizeInputRef={[Function]}
clearButtonRef={[Function]}
hasSelectedOptions={false}
inputRef={[Function]}
isListOpen={false}
onChange={[Function]}
onClear={[Function]}
onClick={[Function]}
onClose={[Function]}
onFocus={[Function]}
onOpen={[Function]}
onOpenListClick={[Function]}
onRemoveOption={[Function]}
searchValue=""
selectedOptions={Array []}
Expand All @@ -39,14 +39,14 @@ exports[`props isClearable is false with selectedOptions 1`] = `
>
<EuiComboBoxInput
autoSizeInputRef={[Function]}
clearButtonRef={[Function]}
hasSelectedOptions={true}
inputRef={[Function]}
isListOpen={false}
onChange={[Function]}
onClick={[Function]}
onClose={[Function]}
onFocus={[Function]}
onOpen={[Function]}
onOpenListClick={[Function]}
onRemoveOption={[Function]}
searchValue=""
selectedOptions={
Expand Down Expand Up @@ -76,14 +76,14 @@ exports[`props isClearable is false without selectedOptions 1`] = `
>
<EuiComboBoxInput
autoSizeInputRef={[Function]}
clearButtonRef={[Function]}
hasSelectedOptions={false}
inputRef={[Function]}
isListOpen={false}
onChange={[Function]}
onClick={[Function]}
onClose={[Function]}
onFocus={[Function]}
onOpen={[Function]}
onOpenListClick={[Function]}
onRemoveOption={[Function]}
searchValue=""
selectedOptions={Array []}
Expand All @@ -104,15 +104,15 @@ exports[`props isDisabled 1`] = `
>
<EuiComboBoxInput
autoSizeInputRef={[Function]}
clearButtonRef={[Function]}
hasSelectedOptions={true}
inputRef={[Function]}
isDisabled={true}
isListOpen={false}
onChange={[Function]}
onClick={[Function]}
onClose={[Function]}
onFocus={[Function]}
onOpen={[Function]}
onOpenListClick={[Function]}
onRemoveOption={[Function]}
searchValue=""
selectedOptions={
Expand All @@ -139,15 +139,15 @@ exports[`props options 1`] = `
>
<EuiComboBoxInput
autoSizeInputRef={[Function]}
clearButtonRef={[Function]}
hasSelectedOptions={false}
inputRef={[Function]}
isListOpen={false}
onChange={[Function]}
onClear={[Function]}
onClick={[Function]}
onClose={[Function]}
onFocus={[Function]}
onOpen={[Function]}
onOpenListClick={[Function]}
onRemoveOption={[Function]}
searchValue=""
selectedOptions={Array []}
Expand All @@ -168,15 +168,15 @@ exports[`props selectedOptions 1`] = `
>
<EuiComboBoxInput
autoSizeInputRef={[Function]}
clearButtonRef={[Function]}
hasSelectedOptions={true}
inputRef={[Function]}
isListOpen={false}
onChange={[Function]}
onClear={[Function]}
onClick={[Function]}
onClose={[Function]}
onFocus={[Function]}
onOpen={[Function]}
onOpenListClick={[Function]}
onRemoveOption={[Function]}
searchValue=""
selectedOptions={
Expand Down Expand Up @@ -206,15 +206,15 @@ exports[`props singleSelection 1`] = `
>
<EuiComboBoxInput
autoSizeInputRef={[Function]}
clearButtonRef={[Function]}
hasSelectedOptions={true}
inputRef={[Function]}
isListOpen={false}
onChange={[Function]}
onClear={[Function]}
onClick={[Function]}
onClose={[Function]}
onFocus={[Function]}
onOpen={[Function]}
onOpenListClick={[Function]}
onRemoveOption={[Function]}
searchValue=""
selectedOptions={
Expand Down
61 changes: 42 additions & 19 deletions src/components/combo_box/combo_box.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,26 +137,42 @@ export class EuiComboBox extends Component {
};

tabAway = amount => {
if (![-1, 1].includes(amount)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IE doesn't support includes. What do you think of if (Math.abs(amount) !== 1) ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting! We're using includes in other places in EUI. Maybe we should just add documentation stating that we expect consumers to polyfill ES2015 features, e.g. with babel-polyfill. I think that's fair, since EUI is intended for usage within Elastic and I'd be surprised if we couldn't meet that requirements.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like the right thing since the methods are already being used

throw new Error(`tabAway expects amount to be -1 or 1, but received ${amount}`);
}

const tabbableItems = tabbable(document);
const comboBoxIndex = tabbableItems.indexOf(this.searchInput);

// Wrap to last tabbable if tabbing backwards.
if (amount < 0) {
if (comboBoxIndex === 0) {
tabbableItems[tabbableItems.length - 1].focus();
return;
if (document.activeElement === this.searchInput) {
const searchInputIndex = tabbableItems.indexOf(this.searchInput);

// Wrap to last tabbable if tabbing backwards.
if (amount === -1) {
if (searchInputIndex === 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

amount may be less that -1, test if searchInputIndex + amount < 0

tabbableItems[tabbableItems.length - 1].focus();
return;
}
}

// Otherwise tab to the next adjacent item.
tabbableItems[searchInputIndex + amount].focus();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test if this exceeds tabbableItems.length?

return;
}

// Wrap to first tabbable if tabbing forwards.
if (amount > 0) {
if (comboBoxIndex === tabbableItems.length - 1) {
tabbableItems[0].focus();
return;
if (document.activeElement === this.clearButton) {
const clearButtonIndex = tabbableItems.indexOf(this.clearButton);

// Wrap to first tabbable if tabbing forwards.
if (amount === 1) {
if (clearButtonIndex === tabbableItems.length - 1) {
tabbableItems[0].focus();
return;
}
}
}

tabbableItems[comboBoxIndex + amount].focus();
// Otherwise tab to the next adjacent item.
tabbableItems[clearButtonIndex + amount].focus();
}
};

incrementActiveOptionIndex = throttle(amount => {
Expand Down Expand Up @@ -290,14 +306,10 @@ export class EuiComboBox extends Component {
};

onFocus = () => {
document.addEventListener('click', this.onDocumentFocusChange);
document.addEventListener('focusin', this.onDocumentFocusChange);
this.openList();
}

onBlur = () => {
document.removeEventListener('click', this.onDocumentFocusChange);
document.removeEventListener('focusin', this.onDocumentFocusChange);
this.closeList();
}

Expand All @@ -309,6 +321,7 @@ export class EuiComboBox extends Component {
|| this.optionsList === event.target
|| this.optionsList && this.optionsList.contains(event.target)
) {
this.onFocus();
return;
}

Expand Down Expand Up @@ -417,6 +430,10 @@ export class EuiComboBox extends Component {
}
};

onOpenListClick = () => {
this.searchInput.focus();
};

onSearchChange = (searchValue) => {
if (this.props.onSearchChange) {
this.props.onSearchChange(searchValue);
Expand Down Expand Up @@ -450,8 +467,14 @@ export class EuiComboBox extends Component {
this.options[index] = node;
};

clearButtonRef = node => {
this.clearButton = node;
};

componentDidMount() {
this._isMounted = true;
document.addEventListener('click', this.onDocumentFocusChange);
document.addEventListener('focusin', this.onDocumentFocusChange);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By leaving these event listeners active while the component is mounted, we can open the list when the user tabs backwards to give the clear button focus.


// TODO: This will need to be called once the actual stylesheet loads.
setTimeout(() => {
Expand Down Expand Up @@ -586,10 +609,10 @@ export class EuiComboBox extends Component {
onClear={isClearable && !isDisabled ? this.clearSelectedOptions : undefined}
hasSelectedOptions={selectedOptions.length > 0}
isListOpen={isListOpen}
onOpen={this.openList}
onClose={this.closeList}
onOpenListClick={this.onOpenListClick}
singleSelection={singleSelection}
isDisabled={isDisabled}
clearButtonRef={this.clearButtonRef}
/>

{optionsList}
Expand Down
26 changes: 18 additions & 8 deletions src/components/combo_box/combo_box_input/combo_box_input.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ export class EuiComboBoxInput extends Component {
onClear: PropTypes.func,
hasSelectedOptions: PropTypes.bool.isRequired,
isListOpen: PropTypes.bool.isRequired,
onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
onOpenListClick: PropTypes.func.isRequired,
singleSelection: PropTypes.bool,
isDisabled: PropTypes.bool,
clearButtonRef: PropTypes.func,
}

constructor(props) {
Expand Down Expand Up @@ -86,10 +86,10 @@ export class EuiComboBoxInput extends Component {
onClear,
hasSelectedOptions,
isListOpen,
onOpen,
onClose,
onOpenListClick,
singleSelection,
isDisabled,
clearButtonRef,
} = this.props;

const pills = selectedOptions.map((option) => {
Expand Down Expand Up @@ -149,14 +149,24 @@ export class EuiComboBoxInput extends Component {
const clickProps = {};

if (!isDisabled) {
clickProps.onClear = hasSelectedOptions ? onClear : undefined;
clickProps.onIconClick = isListOpen ? onClose : onOpen;
clickProps.clear = {
onClick: hasSelectedOptions ? onClear : undefined,
ref: clearButtonRef,
};
}

const icon = {
type: 'arrowDown',
side: 'right',
onClick: isListOpen && !isDisabled ? undefined : onOpenListClick,
// We want to remove this from the tab order because you can open the combo box by tabbing
// to it already.
tabIndex: '-1',
};

return (
<EuiFormControlLayout
icon="arrowDown"
iconSide="right"
icon={icon}
{...clickProps}
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export class EuiComboBoxOptionsList extends Component {

const optionsList = (
<List
tabIndex={-1}
width={width}
height={height}
rowCount={matchingOptions.length}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ exports[`EuiDatePicker is rendered 1`] = `
compressed={false}
fullWidth={false}
icon="calendar"
iconSide="left"
isLoading={false}
>
<EuiValidatableControl>
Expand Down
10 changes: 9 additions & 1 deletion src/components/form/field_number/field_number.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';

import {
ICON_SIDES,
EuiFormControlLayout,
} from '../form_control_layout';

Expand Down Expand Up @@ -81,7 +82,14 @@ EuiFieldNumber.propTypes = {
max: PropTypes.number,
step: PropTypes.number,
value: numberOrEmptyString,
icon: PropTypes.string,
icon: PropTypes.oneOfType([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these components purposefully limited prop declarations. Is there a reason to add iconSide and onClick to the number field? I kind of like giving people less options here which is why I only exposed the props in some of the compoents. Generally, when we've given people the power to use things, they start using them. I'm just a little worried about seeing random action icons on the right side of inputs that lack context. Down arrow, clear? Makes sense. Random logstash icon with some hidden meaning and an onClick action has me worried 😄

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. Looks like using them will actually break the layouts because of the assumptions the previous api made.

I think if these just get reverted to the strings only you're prolly ok? Leave the mapping stuff to the control only?

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh great catch! This was an oversight on my part.

PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
side: PropTypes.oneOf(ICON_SIDES),
onClick: PropTypes.func,
}),
]),
isInvalid: PropTypes.bool,
fullWidth: PropTypes.bool,
isLoading: PropTypes.bool,
Expand Down
8 changes: 7 additions & 1 deletion src/components/form/field_number/field_number.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { requiredProps } from '../../../test/required_props';

import { EuiFieldNumber } from './field_number';

jest.mock('../form_control_layout', () => ({ EuiFormControlLayout: 'eui-form-control-layout' }));
jest.mock('../form_control_layout', () => {
const formControlLayout = require.requireActual('../form_control_layout')
return {
...formControlLayout,
EuiFormControlLayout: 'eui-form-control-layout',
}
});
jest.mock('../validatable_control', () => ({ EuiValidatableControl: 'eui-validatable-control' }));

describe('EuiFieldNumber', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/components/form/field_text/field_text.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';

import {
ICON_SIDES,
EuiFormControlLayout,
} from '../form_control_layout';

Expand Down Expand Up @@ -61,7 +62,14 @@ EuiFieldText.propTypes = {
id: PropTypes.string,
placeholder: PropTypes.string,
value: PropTypes.string,
icon: PropTypes.string,
icon: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
side: PropTypes.oneOf(ICON_SIDES),
onClick: PropTypes.func,
}),
]),
isInvalid: PropTypes.bool,
inputRef: PropTypes.func,
fullWidth: PropTypes.bool,
Expand Down
Loading