diff --git a/CHANGELOG.md b/CHANGELOG.md index 629a51652a9..9d4214cba56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** diff --git a/package.json b/package.json index c0c82328f0e..4b1eba1e606 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src-docs/src/views/combo_box/combo_box_example.js b/src-docs/src/views/combo_box/combo_box_example.js index f13e55ba154..e3d69e48c60 100644 --- a/src-docs/src/views/combo_box/combo_box_example.js +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -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: ( @@ -94,6 +98,23 @@ export const ComboBoxExample = { }], props: { EuiComboBox }, demo: , + }, { + title: 'Virtualized', + source: [{ + type: GuideSectionTypes.JS, + code: virtualizedSource, + }, { + type: GuideSectionTypes.HTML, + code: virtualizedHtml, + }], + text: ( +

+ EuiComboBoxList uses react-virtualized{' '} + to only render visible options to be super fast no matter how many options there are. +

+ ), + props: { EuiComboBox }, + demo: , }, { title: 'Containers', source: [{ @@ -140,11 +161,19 @@ export const ComboBoxExample = { code: renderOptionHtml, }], text: ( -

- You can provide a renderOption prop which will accept option - and searchValue arguments. Use the value prop of the - option object to store metadata about the option for use in this callback. -

+ +

+ You can provide a renderOption prop which will accept option{' '} + and searchValue arguments. Use the value prop of the{' '} + option object to store metadata about the option for use in this callback. +

+ +

+ Note: 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. +

+
), props: { EuiComboBox }, demo: , diff --git a/src-docs/src/views/combo_box/render_option.js b/src-docs/src/views/combo_box/render_option.js index 2d39f806f25..611a96eddf8 100644 --- a/src-docs/src/views/combo_box/render_option.js +++ b/src-docs/src/views/combo_box/render_option.js @@ -110,11 +110,11 @@ export default class extends Component { })); }; - renderOption = (option, searchValue) => { + renderOption = (option, searchValue, contentClassName) => { const { color, label, value } = option; return ( - + {label} diff --git a/src-docs/src/views/combo_box/virtualized.js b/src-docs/src/views/combo_box/virtualized.js new file mode 100644 index 00000000000..3edb40f0f2b --- /dev/null +++ b/src-docs/src/views/combo_box/virtualized.js @@ -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 ( + + ); + } +} diff --git a/src/components/combo_box/_combo_box.scss b/src/components/combo_box/_combo_box.scss index e42df2a9601..47934f3d51b 100644 --- a/src/components/combo_box/_combo_box.scss +++ b/src/components/combo_box/_combo_box.scss @@ -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 */ diff --git a/src/components/combo_box/combo_box.js b/src/components/combo_box/combo_box.js index 34be9f85828..d2ee1b3ded5 100644 --- a/src/components/combo_box/combo_box.js +++ b/src/components/combo_box/combo_box.js @@ -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'; @@ -38,6 +39,7 @@ export class EuiComboBox extends Component { onCreateOption: PropTypes.func, renderOption: PropTypes.func, isInvalid: PropTypes.bool, + rowHeight: PropTypes.number, } static defaultProps = { @@ -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. @@ -122,6 +123,7 @@ export class EuiComboBox extends Component { this.optionsList.style.width = `${comboBoxBounds.width}px`; this.setState({ + width: comboBoxBounds.width, listPosition: position, }); }; @@ -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(); @@ -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(); } }; @@ -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) => { @@ -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, + }); } }; @@ -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 => { @@ -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() { @@ -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(); + } } } @@ -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, @@ -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} @@ -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} /> ); diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss index f38453417b4..87adfb7093a 100644 --- a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss +++ b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss @@ -1,6 +1,6 @@ .euiComboBoxOption { font-size: $euiFontSizeS; - padding: $euiSizeXS $euiSizeS; + padding: $euiSizeXS $euiSizeS $euiSizeXS #{$euiSizeM + $euiSizeXS}; width: 100%; text-align: left; border: $euiBorderThin; @@ -11,12 +11,14 @@ &:hover { text-decoration: underline; } + &:focus { cursor: pointer; color: $euiColorPrimary; background-color: $euiFocusBackgroundColor; } - &:disabled { + + &.euiComboBoxOption-isDisabled { color: $euiColorMediumShade; cursor: not-allowed; &:hover { @@ -24,3 +26,9 @@ } } } + + .euiComboBoxOption__content { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_options_list.scss b/src/components/combo_box/combo_box_options_list/_combo_box_options_list.scss index 2f3fce45414..8bdef3516fb 100644 --- a/src/components/combo_box/combo_box_options_list/_combo_box_options_list.scss +++ b/src/components/combo_box/combo_box_options_list/_combo_box_options_list.scss @@ -11,6 +11,10 @@ z-index: $euiZComboBox; position: absolute; /* 2 */ top: 0; /* 2 */ + + .ReactVirtualized__List { + @include euiScrollBar; + } } .euiComboBoxOptionsList--bottom { @@ -23,16 +27,18 @@ box-shadow: none !important; } + /** + * 1. Prevent really long input from overflowing the container. + */ .euiComboBoxOptionsList__empty { padding: $euiSizeS; text-align: center; color: $euiColorDarkShade; + word-wrap: break-word; /* 1 */ } .euiComboBoxOptionsList__rowWrap { - @include euiScrollBar; - - padding: $euiSizeS; + padding: 0; max-height: 200px; - overflow-y: auto; + overflow: hidden; } diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_title.scss b/src/components/combo_box/combo_box_options_list/_combo_box_title.scss index 0a800293790..253c5e0ed62 100644 --- a/src/components/combo_box/combo_box_options_list/_combo_box_title.scss +++ b/src/components/combo_box/combo_box_options_list/_combo_box_title.scss @@ -1,11 +1,11 @@ +/** + * 1. Force each title to be the same height as an option, so that the virtualized scroll logic + * works. + */ .euiComboBoxTitle { font-size: $euiFontSizeXS; - padding: $euiSizeXS $euiSizeS $euiSizeXS 0; + padding: ($euiSizeXS + $euiSizeS - 1px) $euiSizeS $euiSizeXS; /* 1 */ width: 100%; font-weight: $euiFontWeightBold; color: $euiColorFullShade; - - .euiComboBoxOption + & { - margin-top: $euiSizeS; - } } diff --git a/src/components/combo_box/combo_box_options_list/combo_box_option.js b/src/components/combo_box/combo_box_options_list/combo_box_option.js index 184712686a9..f56eaded9c9 100644 --- a/src/components/combo_box/combo_box_options_list/combo_box_option.js +++ b/src/components/combo_box/combo_box_options_list/combo_box_option.js @@ -18,7 +18,12 @@ export class EuiComboBoxOption extends Component { } onClick = () => { - const { onClick, option } = this.props; + const { onClick, option, disabled } = this.props; + + if (disabled) { + return; + } + onClick(option); }; @@ -26,7 +31,12 @@ export class EuiComboBoxOption extends Component { if (e.keyCode === ENTER || e.keyCode === SPACE) { e.preventDefault(); e.stopPropagation(); - const { onEnterKey, option } = this.props; + const { onEnterKey, option, disabled } = this.props; + + if (disabled) { + return; + } + onEnterKey(option); } }; @@ -36,7 +46,7 @@ export class EuiComboBoxOption extends Component { children, className, optionRef, - option, // eslint-disable-line no-unused-vars + option, onClick, // eslint-disable-line no-unused-vars onEnterKey, // eslint-disable-line no-unused-vars disabled, @@ -45,18 +55,27 @@ export class EuiComboBoxOption extends Component { const classes = classNames( 'euiComboBoxOption', - className + className, + { + 'euiComboBoxOption-isDisabled': disabled, + }, ); + const { + label, + } = option; + return (