Skip to content

Commit

Permalink
use react-virtualized to virtualize EuiComboBox options list (#670)
Browse files Browse the repository at this point in the history
* use react-virtualized to virtualize combo box options list

* use smaller width and height

* include group label in matching options list

* add better text for example description

* dynamically set width and height

* Massage group title padding. Truncate text instead of wrapping it. Add title attribute to options for usability. (#1)

* remove console.log and fix spelling

* fix problems with settig focus on active option

* more keyboard accessiblity work

* Combo box focus state and text overflow (#2)

* Call setState instead of setting activeOptionIndex directly.

* Clear activeOptionIndex when you click the input.

* Prevent a lot of input from overflowing the container.

* Allow disabled options to be focused but not selected.

* add throttle to incrementActiveOptionIndex to avoid keypresses getting UI out of sync

* rowHeight prop

* remove unneeded const

* fix spacing in example text, fix lodash import

* skip disabled options when using keyboard

* Revert "skip disabled options when using keyboard"

This reverts commit 47fa3ef.
  • Loading branch information
nreese authored Apr 18, 2018
1 parent 64c0d7c commit cd12e5b
Show file tree
Hide file tree
Showing 14 changed files with 281 additions and 96 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## [`master`](https://github.com/elastic/eui/tree/master)

- Added `status` prop to `EuiStep` for additional styling ([#673](https://github.com/elastic/eui/pull/673))
- Virtualized `EuiComboBoxOptionsList` ([#670](https://github.com/elastic/eui/pull/670))

**Bug fixes**

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"react-color": "^2.13.8",
"react-datepicker": "v1.4.1",
"react-input-autosize": "^2.2.1",
"react-virtualized": "^9.18.5",
"serve": "^6.3.1",
"tabbable": "^1.1.0",
"uuid": "^3.1.0"
Expand Down
39 changes: 34 additions & 5 deletions src-docs/src/views/combo_box/combo_box_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ import Async from './async';
const asyncSource = require('!!raw-loader!./async');
const asyncHtml = renderToHtml(Async);

import Virtualized from './virtualized';
const virtualizedSource = require('!!raw-loader!./virtualized');
const virtualizedHtml = renderToHtml(Virtualized);

export const ComboBoxExample = {
title: 'Combo Box',
intro: (
Expand Down Expand Up @@ -94,6 +98,23 @@ export const ComboBoxExample = {
}],
props: { EuiComboBox },
demo: <ComboBox />,
}, {
title: 'Virtualized',
source: [{
type: GuideSectionTypes.JS,
code: virtualizedSource,
}, {
type: GuideSectionTypes.HTML,
code: virtualizedHtml,
}],
text: (
<p>
<EuiCode>EuiComboBoxList</EuiCode> uses <Link to="https://github.com/bvaughn/react-virtualized">react-virtualized</Link>{' '}
to only render visible options to be super fast no matter how many options there are.
</p>
),
props: { EuiComboBox },
demo: <Virtualized />,
}, {
title: 'Containers',
source: [{
Expand Down Expand Up @@ -140,11 +161,19 @@ export const ComboBoxExample = {
code: renderOptionHtml,
}],
text: (
<p>
You can provide a <EuiCode>renderOption</EuiCode> prop which will accept <EuiCode>option</EuiCode>
and <EuiCode>searchValue</EuiCode> arguments. Use the <EuiCode>value</EuiCode> prop of the
<EuiCode>option</EuiCode> object to store metadata about the option for use in this callback.
</p>
<Fragment>
<p>
You can provide a <EuiCode>renderOption</EuiCode> prop which will accept <EuiCode>option</EuiCode>{' '}
and <EuiCode>searchValue</EuiCode> arguments. Use the <EuiCode>value</EuiCode> prop of the{' '}
<EuiCode>option</EuiCode> object to store metadata about the option for use in this callback.
</p>

<p>
<strong>Note:</strong> virtualization (above) requires that each option have the same height.
Ensure that you render the options so that wrapping text is truncated instead of causing
the height of the option to change.
</p>
</Fragment>
),
props: { EuiComboBox },
demo: <RenderOption />,
Expand Down
4 changes: 2 additions & 2 deletions src-docs/src/views/combo_box/render_option.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,11 @@ export default class extends Component {
}));
};

renderOption = (option, searchValue) => {
renderOption = (option, searchValue, contentClassName) => {
const { color, label, value } = option;
return (
<EuiHealth color={color}>
<span>
<span className={contentClassName}>
<EuiHighlight search={searchValue}>
{label}
</EuiHighlight>
Expand Down
46 changes: 46 additions & 0 deletions src-docs/src/views/combo_box/virtualized.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React, { Component } from 'react';

import {
EuiComboBox,
} from '../../../../src/components';

export default class extends Component {
constructor(props) {
super(props);

this.options = [];
let groupOptions = [];
for (let i=1; i < 5000; i++) {
groupOptions.push({ label: `option${i}` });
if (i % 25 === 0) {
this.options.push({
label: `Options ${i - (groupOptions.length - 1)} to ${i}`,
options: groupOptions
});
groupOptions = [];
}
}

this.state = {
selectedOptions: [],
};
}

onChange = (selectedOptions) => {
this.setState({
selectedOptions,
});
};

render() {
const { selectedOptions } = this.state;
return (
<EuiComboBox
placeholder="Select or create options"
options={this.options}
selectedOptions={selectedOptions}
onChange={this.onChange}
/>
);
}
}
2 changes: 2 additions & 0 deletions src/components/combo_box/_combo_box.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@
* 2. Force input height to expand tp fill this element.
* 3. Reset appearance on Safari.
* 4. Fix react-input-autosize appearance.
* 5. Prevent a lot of input from causing the react-input-autosize to overflow the container.
*/
.euiComboBox__input {
display: inline-flex !important; /* 1 */
height: 32px; /* 2 */
overflow: hidden; /* 5 */

> input {
appearance: none; /* 3 */
Expand Down
82 changes: 56 additions & 26 deletions src/components/combo_box/combo_box.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* from the tab order with tabindex="-1" so that we can control the keyboard navigation interface.
*/

import { throttle } from 'lodash';
import React, {
Component,
} from 'react';
Expand Down Expand Up @@ -38,6 +39,7 @@ export class EuiComboBox extends Component {
onCreateOption: PropTypes.func,
renderOption: PropTypes.func,
isInvalid: PropTypes.bool,
rowHeight: PropTypes.number,
}

static defaultProps = {
Expand All @@ -50,18 +52,17 @@ export class EuiComboBox extends Component {

const initialSearchValue = '';
const { options, selectedOptions } = props;
const { matchingOptions, optionToGroupMap } = this.getMatchingOptions(options, selectedOptions, initialSearchValue);
const matchingOptions = this.getMatchingOptions(options, selectedOptions, initialSearchValue);

this.state = {
searchValue: initialSearchValue,
isListOpen: false,
listPosition: 'bottom',
activeOptionIndex: undefined,
};

// Cached derived state.
this.matchingOptions = matchingOptions;
this.optionToGroupMap = optionToGroupMap;
this.activeOptionIndex = undefined;
this.listBounds = undefined;

// Refs.
Expand Down Expand Up @@ -122,6 +123,7 @@ export class EuiComboBox extends Component {
this.optionsList.style.width = `${comboBoxBounds.width}px`;

this.setState({
width: comboBoxBounds.width,
listPosition: position,
});
};
Expand Down Expand Up @@ -149,7 +151,7 @@ export class EuiComboBox extends Component {
tabbableItems[comboBoxIndex + amount].focus();
};

incrementActiveOptionIndex = amount => {
incrementActiveOptionIndex = throttle(amount => {
// If there are no options available, reset the focus.
if (!this.matchingOptions.length) {
this.clearActiveOption();
Expand All @@ -161,33 +163,49 @@ export class EuiComboBox extends Component {
if (!this.hasActiveOption()) {
// If this is the beginning of the user's keyboard navigation of the menu, then we'll focus
// either the first or last item.
nextActiveOptionIndex = amount < 0 ? this.options.length - 1 : 0;
nextActiveOptionIndex = amount < 0 ? this.matchingOptions.length - 1 : 0;
} else {
nextActiveOptionIndex = this.activeOptionIndex + amount;
nextActiveOptionIndex = this.state.activeOptionIndex + amount;

if (nextActiveOptionIndex < 0) {
nextActiveOptionIndex = this.options.length - 1;
} else if (nextActiveOptionIndex === this.options.length) {
nextActiveOptionIndex = this.matchingOptions.length - 1;
} else if (nextActiveOptionIndex === this.matchingOptions.length) {
nextActiveOptionIndex = 0;
}
}

this.activeOptionIndex = nextActiveOptionIndex;
this.focusActiveOption();
};
// Group titles are included in option list but are not selectable
// Skip group title options
const direction = amount > 0 ? 1 : -1;
while (this.matchingOptions[nextActiveOptionIndex].isGroupLabelOption) {
nextActiveOptionIndex = nextActiveOptionIndex + direction;

if (nextActiveOptionIndex < 0) {
nextActiveOptionIndex = this.matchingOptions.length - 1;
} else if (nextActiveOptionIndex === this.matchingOptions.length) {
nextActiveOptionIndex = 0;
}
}

this.setState({
activeOptionIndex: nextActiveOptionIndex,
});
}, 200);

hasActiveOption = () => {
return this.activeOptionIndex !== undefined;
return this.state.activeOptionIndex !== undefined;
};

clearActiveOption = () => {
this.activeOptionIndex = undefined;
this.setState({
activeOptionIndex: undefined,
});
};

focusActiveOption = () => {
// If an item is focused, focus it.
if (this.hasActiveOption()) {
this.options[this.activeOptionIndex].focus();
if (this.hasActiveOption() && this.options[this.state.activeOptionIndex]) {
this.options[this.state.activeOptionIndex].focus();
}
};

Expand Down Expand Up @@ -366,6 +384,8 @@ export class EuiComboBox extends Component {
onComboBoxClick = () => {
// When the user clicks anywhere on the box, enter the interaction state.
this.searchInput.focus();
// If the user does this from a state in which an option has focus, then we need to clear it.
this.clearActiveOption();
};

onComboBoxFocus = (e) => {
Expand All @@ -379,7 +399,9 @@ export class EuiComboBox extends Component {
// and we need to update the index.
const optionIndex = this.options.indexOf(e.target);
if (optionIndex !== -1) {
this.activeOptionIndex = optionIndex;
this.setState({
activeOptionIndex: optionIndex,
});
}
};

Expand All @@ -392,6 +414,12 @@ export class EuiComboBox extends Component {

comboBoxRef = node => {
this.comboBox = node;
if (this.comboBox) {
const comboBoxBounds = this.comboBox.getBoundingClientRect();
this.setState({
width: comboBoxBounds.width,
});
}
};

autoSizeInputRef = node => {
Expand All @@ -407,11 +435,7 @@ export class EuiComboBox extends Component {
};

optionRef = (index, node) => {
// Sometimes the node is null.
if (node) {
// Store all options.
this.options[index] = node;
}
this.options[index] = node;
};

componentDidMount() {
Expand All @@ -436,12 +460,14 @@ export class EuiComboBox extends Component {

// Calculate and cache the options which match the searchValue, because we use this information
// in multiple places and it would be expensive to calculate repeatedly.
const { matchingOptions, optionToGroupMap } = this.getMatchingOptions(options, selectedOptions, nextState.searchValue);
const matchingOptions = this.getMatchingOptions(options, selectedOptions, nextState.searchValue);
this.matchingOptions = matchingOptions;
this.optionToGroupMap = optionToGroupMap;

if (!matchingOptions.length) {
this.clearActiveOption();
// Prevent endless setState -> componentWillUpdate -> setState loop.
if (nextState.hasActiveOption) {
this.clearActiveOption();
}
}
}

Expand Down Expand Up @@ -470,10 +496,11 @@ export class EuiComboBox extends Component {
onSearchChange, // eslint-disable-line no-unused-vars
async, // eslint-disable-line no-unused-vars
isInvalid,
rowHeight,
...rest
} = this.props;

const { searchValue, isListOpen, listPosition } = this.state;
const { searchValue, isListOpen, listPosition, width, activeOptionIndex } = this.state;

const classes = classNames('euiComboBox', className, {
'euiComboBox-isOpen': isListOpen,
Expand All @@ -494,7 +521,6 @@ export class EuiComboBox extends Component {
onCreateOption={onCreateOption}
searchValue={searchValue}
matchingOptions={this.matchingOptions}
optionToGroupMap={this.optionToGroupMap}
listRef={this.optionsListRef}
optionRef={this.optionRef}
onOptionClick={this.onOptionClick}
Expand All @@ -504,6 +530,10 @@ export class EuiComboBox extends Component {
updatePosition={this.updateListPosition}
position={listPosition}
renderOption={renderOption}
width={width}
scrollToIndex={activeOptionIndex}
onScroll={this.focusActiveOption}
rowHeight={rowHeight}
/>
</EuiPortal>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.euiComboBoxOption {
font-size: $euiFontSizeS;
padding: $euiSizeXS $euiSizeS;
padding: $euiSizeXS $euiSizeS $euiSizeXS #{$euiSizeM + $euiSizeXS};
width: 100%;
text-align: left;
border: $euiBorderThin;
Expand All @@ -11,16 +11,24 @@
&:hover {
text-decoration: underline;
}

&:focus {
cursor: pointer;
color: $euiColorPrimary;
background-color: $euiFocusBackgroundColor;
}
&:disabled {

&.euiComboBoxOption-isDisabled {
color: $euiColorMediumShade;
cursor: not-allowed;
&:hover {
text-decoration: none;
}
}
}

.euiComboBoxOption__content {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
Loading

0 comments on commit cd12e5b

Please sign in to comment.