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