diff --git a/CHANGELOG.md b/CHANGELOG.md index d059ac22ed0..d30b4addcac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [`master`](https://github.com/elastic/eui/tree/master) +- Added `sortMatchesBy` prop for `EuiComboBox` ([#3089](https://github.com/elastic/eui/pull/3089)) - Added `prepend` and `append` ability to `EuiFieldPassword` ([#3122](https://github.com/elastic/eui/pull/3122)) - Added `Enter` key press functionality to `EuiSuperDatePicker` ([#3048](https://github.com/elastic/eui/pull/3048)) - Added `title` to headers of `EuiTable` in case of truncation ([#3094](https://github.com/elastic/eui/pull/3094)) 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 a3479d6ff26..0f70d299c8b 100644 --- a/src-docs/src/views/combo_box/combo_box_example.js +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -59,6 +59,10 @@ import Disabled from './disabled'; const disabledSource = require('!!raw-loader!./disabled'); const disabledHtml = renderToHtml(Disabled); +import StartingWith from './startingWith'; +const startingWithSource = require('!!raw-loader!./startingWith'); +const startingWithHtml = renderToHtml(StartingWith); + export const ComboBoxExample = { title: 'Combo Box', intro: ( @@ -347,5 +351,29 @@ export const ComboBoxExample = { props: { EuiComboBox }, demo: , }, + { + title: 'Sorting matches', + source: [ + { + type: GuideSectionTypes.JS, + code: startingWithSource, + }, + { + type: GuideSectionTypes.HTML, + code: startingWithHtml, + }, + ], + text: ( +

+ By default, the matched options will keep their original sort order. + If you would like to prioritize those options that{' '} + start with the searched string, pass{' '} + sortMatchesBy="startsWith" + to display those options at the top of the list. +

+ ), + props: { EuiComboBox }, + demo: , + }, ], }; diff --git a/src-docs/src/views/combo_box/startingWith.js b/src-docs/src/views/combo_box/startingWith.js new file mode 100644 index 00000000000..95de94a4738 --- /dev/null +++ b/src-docs/src/views/combo_box/startingWith.js @@ -0,0 +1,97 @@ +import React, { Component } from 'react'; + +import { EuiComboBox } from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + }, + { + label: 'Enceladus is disabled', + disabled: true, + }, + { + label: 'Mimas', + }, + { + label: 'Dione', + }, + { + label: 'Iapetus', + }, + { + label: 'Phoebe', + }, + { + label: 'Rhea', + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + }, + { + label: 'Tethys', + }, + { + label: 'Hyperion', + }, + ]; + + this.state = { + selectedOptions: [this.options[2], this.options[4]], + }; + } + + onChange = selectedOptions => { + this.setState({ + selectedOptions, + }); + }; + + onCreateOption = (searchValue, flattenedOptions) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + label: searchValue, + }; + + // Create the option if it doesn't exist. + if ( + flattenedOptions.findIndex( + option => option.label.trim().toLowerCase() === normalizedSearchValue + ) === -1 + ) { + this.options.push(newOption); + } + + // Select the option. + this.setState(prevState => ({ + selectedOptions: prevState.selectedOptions.concat(newOption), + })); + }; + + render() { + const { selectedOptions } = this.state; + return ( + + ); + } +} diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx index dadb0e77c85..6856b23c2bc 100644 --- a/src/components/combo_box/combo_box.test.tsx +++ b/src/components/combo_box/combo_box.test.tsx @@ -7,7 +7,7 @@ import { } from '../../test'; import { comboBoxKeyCodes } from '../../services'; -import { EuiComboBox } from './combo_box'; +import { EuiComboBox, EuiComboBoxProps } from './combo_box'; jest.mock('../portal', () => ({ EuiPortal: ({ children }: { children: ReactNode }) => children, @@ -325,4 +325,20 @@ describe('behavior', () => { ).toBe(document.activeElement); }); }); + + describe('sortMatchesBy', () => { + test('options startsWith', () => { + const component = mount< + EuiComboBox, + EuiComboBoxProps, + { matchingOptions: TitanOption[] } + >(); + + findTestSubject(component, 'comboBoxSearchInput').simulate('change', { + target: { value: 'e' }, + }); + + expect(component.state('matchingOptions')[0].label).toBe('Enceladus'); + }); + }); }); diff --git a/src/components/combo_box/combo_box.tsx b/src/components/combo_box/combo_box.tsx index 740fff697f0..f84770a8322 100644 --- a/src/components/combo_box/combo_box.tsx +++ b/src/components/combo_box/combo_box.tsx @@ -110,6 +110,12 @@ interface _EuiComboBoxProps * When `true` only allows the user to select a single option. Set to `{ asPlainText: true }` to not render input selection as pills */ singleSelection: boolean | EuiComboBoxSingleSelectionShape; + /** + * Display matching options by: + * `startsWith`: moves items that start with search value to top of the list; + * `none`: don't change the sort order of initial object + */ + sortMatchesBy?: 'none' | 'startsWith'; /** * Creates an input group with element(s) coming before input. It won't show if `singleSelection` is set to `false`. * `string` | `ReactElement` or an array of these @@ -170,6 +176,7 @@ export class EuiComboBox extends Component< singleSelection: false, prepend: null, append: null, + sortMatchesBy: 'none', }; state: EuiComboBoxState = { @@ -779,6 +786,7 @@ export class EuiComboBox extends Component< ); } } + this.setState({ matchingOptions: newMatchingOptions, activeOptionIndex: nextActiveOptionIndex, @@ -841,6 +849,7 @@ export class EuiComboBox extends Component< singleSelection, prepend, append, + sortMatchesBy, ...rest } = this.props; const { @@ -850,8 +859,30 @@ export class EuiComboBox extends Component< listPosition, searchValue, width, + matchingOptions, } = this.state; + let newMatchingOptions = matchingOptions; + + if (sortMatchesBy === 'startsWith') { + const refObj: { + startWith: Array>; + others: Array>; + } = { startWith: [], others: [] }; + + newMatchingOptions.forEach(object => { + if ( + object.label + .toLowerCase() + .startsWith(searchValue.trim().toLowerCase()) + ) { + refObj.startWith.push(object); + } else { + refObj.others.push(object); + } + }); + newMatchingOptions = [...refObj.startWith, ...refObj.others]; + } // Visually indicate the combobox is in an invalid state if it has lost focus but there is text entered in the input. // When custom options are disabled and the user leaves the combo box after entering text that does not match any // options, this tells the user that they've entered invalid input. @@ -887,7 +918,7 @@ export class EuiComboBox extends Component< fullWidth={fullWidth} isLoading={isLoading} listRef={this.listRefCallback} - matchingOptions={this.state.matchingOptions} + matchingOptions={newMatchingOptions} onCloseList={this.closeList} onCreateOption={onCreateOption} onOptionClick={this.onOptionClick}