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

use react-virtualized to virtualize EuiComboBox options list #670

Merged
merged 16 commits into from
Apr 18, 2018
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
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>
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we tweak this to fix some spacing and a typo:

      <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>

<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>
Copy link
Contributor

Choose a reason for hiding this comment

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

This will fix some more spacing issues:

        <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>

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);
Copy link
Contributor

Choose a reason for hiding this comment

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

200 feels a bit sluggish to me -- 100 feels snappier, can we use that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

with 100 I was still getting the keydown to be faster than the rendering and I could hold the key down and the focus option would not keep up


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