From e28fcf9b14112ab3024a655ce177bf6a3d03a552 Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Tue, 23 Apr 2024 14:36:17 +0200 Subject: [PATCH 1/8] feat(EuiComboBox): Add `optionMatcher` prop to allow passing custom options matcher functions --- .../combo_box/combo_box.stories.tsx | 46 ++++++++++++++++- src/components/combo_box/combo_box.tsx | 18 ++++++- src/components/combo_box/index.ts | 3 ++ src/components/combo_box/matching_options.ts | 49 ++++++++++++++++--- src/components/combo_box/types.ts | 32 ++++++++++++ 5 files changed, 139 insertions(+), 9 deletions(-) diff --git a/src/components/combo_box/combo_box.stories.tsx b/src/components/combo_box/combo_box.stories.tsx index da31f4a5ffa..c283edaf250 100644 --- a/src/components/combo_box/combo_box.stories.tsx +++ b/src/components/combo_box/combo_box.stories.tsx @@ -6,11 +6,13 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { EuiComboBox, EuiComboBoxProps } from './combo_box'; +import { EuiComboBoxOptionMatcher } from './types'; +import { EuiCode } from '../code'; const options = [ { label: 'Item 1' }, @@ -98,3 +100,45 @@ export const Playground: Story = { ); }, }; + +export const CustomMatcher: Story = { + render: function Render({ singleSelection, onCreateOption, ...args }) { + const [selectedOptions, setSelectedOptions] = useState( + args.selectedOptions + ); + const onChange: EuiComboBoxProps<{}>['onChange'] = (options, ...args) => { + setSelectedOptions(options); + action('onChange')(options, ...args); + }; + + const optionMatcher = useCallback>( + ({ option, searchValue }) => { + return option.label.startsWith(searchValue); + }, + [] + ); + + return ( + <> +

+ This matcher example uses option.label.startsWith() + . Only options that start exactly like the given search string will be + matched. +

+
+ + + ); + }, +}; diff --git a/src/components/combo_box/combo_box.tsx b/src/components/combo_box/combo_box.tsx index 54608db7d9d..f265bab1f98 100644 --- a/src/components/combo_box/combo_box.tsx +++ b/src/components/combo_box/combo_box.tsx @@ -32,6 +32,7 @@ import { getSelectedOptionForSearchValue, transformForCaseSensitivity, SortMatchesBy, + createPartialStringEqualityOptionMatcher, } from './matching_options'; import { EuiComboBoxInputProps, @@ -43,6 +44,7 @@ import { RefInstance, EuiComboBoxOptionOption, EuiComboBoxSingleSelectionShape, + EuiComboBoxOptionMatcher, } from './types'; import { EuiComboBoxOptionsList } from './combo_box_options_list'; @@ -125,6 +127,15 @@ export interface _EuiComboBoxProps * Whether to match options with case sensitivity. */ isCaseSensitive?: boolean; + /** + * Optional custom option matcher function + * + * @example + * const exactEqualityMatcher: EuiComboBoxOptionMatcher = ({ option, searchValue }) => { + * return option.label === searchValue; + * } + */ + optionMatcher?: EuiComboBoxOptionMatcher; /** * 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 @@ -181,10 +192,11 @@ export interface _EuiComboBoxProps */ type DefaultProps = Omit< (typeof EuiComboBox)['defaultProps'], - 'options' | 'selectedOptions' + 'options' | 'selectedOptions' | 'optionMatcher' > & { options: Array>; selectedOptions: Array>; + optionMatcher: EuiComboBoxOptionMatcher; }; export type EuiComboBoxProps = Omit< _EuiComboBoxProps, @@ -217,6 +229,7 @@ export class EuiComboBox extends Component< prepend: undefined, append: undefined, sortMatchesBy: 'none' as const, + optionMatcher: createPartialStringEqualityOptionMatcher(), }; state: EuiComboBoxState = { @@ -227,6 +240,7 @@ export class EuiComboBox extends Component< options: this.props.options, selectedOptions: this.props.selectedOptions, searchValue: initialSearchValue, + optionMatcher: this.props.optionMatcher!, isCaseSensitive: this.props.isCaseSensitive, isPreFiltered: this.props.async, showPrevSelected: Boolean(this.props.singleSelection), @@ -670,6 +684,7 @@ export class EuiComboBox extends Component< selectedOptions, singleSelection, sortMatchesBy, + optionMatcher, } = nextProps; const { activeOptionIndex, searchValue } = prevState; @@ -683,6 +698,7 @@ export class EuiComboBox extends Component< isPreFiltered: async, showPrevSelected: Boolean(singleSelection), sortMatchesBy, + optionMatcher: optionMatcher!, }); const stateUpdate: Partial> = { matchingOptions }; diff --git a/src/components/combo_box/index.ts b/src/components/combo_box/index.ts index d85ab3bf70f..916be6a65c5 100644 --- a/src/components/combo_box/index.ts +++ b/src/components/combo_box/index.ts @@ -13,4 +13,7 @@ export * from './combo_box_options_list'; export type { EuiComboBoxOptionOption, EuiComboBoxSingleSelectionShape, + EuiComboBoxOptionMatcher, + EuiComboBoxOptionMatcherArgs, } from './types'; +export { createPartialStringEqualityOptionMatcher } from './matching_options'; diff --git a/src/components/combo_box/matching_options.ts b/src/components/combo_box/matching_options.ts index 2ef3095994a..74344f6e1d9 100644 --- a/src/components/combo_box/matching_options.ts +++ b/src/components/combo_box/matching_options.ts @@ -6,13 +6,14 @@ * Side Public License, v 1. */ -import { EuiComboBoxOptionOption } from './types'; +import { EuiComboBoxOptionOption, EuiComboBoxOptionMatcher } from './types'; export type SortMatchesBy = 'none' | 'startsWith'; interface GetMatchingOptions { options: Array>; selectedOptions: Array>; searchValue: string; + optionMatcher: EuiComboBoxOptionMatcher; isCaseSensitive?: boolean; isPreFiltered?: boolean; showPrevSelected?: boolean; @@ -21,7 +22,11 @@ interface GetMatchingOptions { interface CollectMatchingOption extends Pick< GetMatchingOptions, - 'isCaseSensitive' | 'isPreFiltered' | 'showPrevSelected' + | 'isCaseSensitive' + | 'isPreFiltered' + | 'showPrevSelected' + | 'optionMatcher' + | 'searchValue' > { accumulator: Array>; option: EuiComboBoxOptionOption; @@ -86,10 +91,12 @@ const collectMatchingOption = ({ accumulator, option, selectedOptions, + searchValue, normalizedSearchValue, isCaseSensitive, isPreFiltered, showPrevSelected, + optionMatcher, }: CollectMatchingOption) => { // Only show options which haven't yet been selected unless requested. const selectedOption = getSelectedOptionForSearchValue({ @@ -113,11 +120,13 @@ const collectMatchingOption = ({ return; } - const normalizedOption = transformForCaseSensitivity( - option.label.trim(), - isCaseSensitive - ); - if (normalizedOption.includes(normalizedSearchValue)) { + const isMatching = optionMatcher({ + option, + searchValue, + normalizedSearchValue, + isCaseSensitive: isCaseSensitive ?? true, + }); + if (isMatching) { accumulator.push(option); } }; @@ -126,6 +135,7 @@ export const getMatchingOptions = ({ options, selectedOptions, searchValue, + optionMatcher, isCaseSensitive = false, isPreFiltered = false, showPrevSelected = false, @@ -145,10 +155,12 @@ export const getMatchingOptions = ({ accumulator: matchingOptionsForGroup, option: groupOption, selectedOptions, + searchValue, normalizedSearchValue, isCaseSensitive, isPreFiltered, showPrevSelected, + optionMatcher, }); }); if (matchingOptionsForGroup.length > 0) { @@ -167,10 +179,12 @@ export const getMatchingOptions = ({ accumulator: matchingOptions, option, selectedOptions, + searchValue, normalizedSearchValue, isCaseSensitive, isPreFiltered, showPrevSelected, + optionMatcher, }); } }); @@ -197,3 +211,24 @@ export const getMatchingOptions = ({ return matchingOptions; }; + +/** + * Partial string equality option matcher for EuiComboBox. + * It matches all options with labels including the searched string. + */ +export const createPartialStringEqualityOptionMatcher = < + TOption +>(): EuiComboBoxOptionMatcher => { + return ({ option, isCaseSensitive, normalizedSearchValue }) => { + if (!normalizedSearchValue) { + return true; + } + + const normalizedOption = transformForCaseSensitivity( + option.label.trim(), + isCaseSensitive + ); + + return normalizedOption.includes(normalizedSearchValue); + }; +}; diff --git a/src/components/combo_box/types.ts b/src/components/combo_box/types.ts index 45cf3838975..df687fc95ae 100644 --- a/src/components/combo_box/types.ts +++ b/src/components/combo_box/types.ts @@ -33,3 +33,35 @@ export type RefInstance = T | null; export interface EuiComboBoxSingleSelectionShape { asPlainText?: boolean; } + +export interface EuiComboBoxOptionMatcherArgs { + /** + * Option being currently processed + */ + option: EuiComboBoxOptionOption; + /** + * Raw search input value + */ + searchValue: string; + /** + * Search input value normalized for case-sensitivity + * and with leading and trailing whitespace characters trimmed + */ + normalizedSearchValue: string; + /** + * Whether to match the option with case-sensitivity + */ + isCaseSensitive: boolean; +} + +/** + * Option matcher function for EuiComboBox component. + * + * @example + * const equalityMatcher: EuiComboBoxOptionMatcher = ({ option, searchValue }) => { + * return option.label === searchValue; + * } + */ +export type EuiComboBoxOptionMatcher = ( + args: EuiComboBoxOptionMatcherArgs +) => boolean; From e29346124dd28962b172f7d826b7a28439bb0412 Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Tue, 23 Apr 2024 16:44:46 +0200 Subject: [PATCH 2/8] feat(EuiSelectable): Add `optionMatcher` prop to allow passing custom options matcher functions --- src/components/selectable/matching_options.ts | 89 ++++++++++++++----- src/components/selectable/selectable.tsx | 62 +++++++++---- .../selectable_search/selectable_search.tsx | 15 +++- 3 files changed, 125 insertions(+), 41 deletions(-) diff --git a/src/components/selectable/matching_options.ts b/src/components/selectable/matching_options.ts index dc4e7b04d65..87000411714 100644 --- a/src/components/selectable/matching_options.ts +++ b/src/components/selectable/matching_options.ts @@ -7,6 +7,7 @@ */ import { EuiSelectableOption } from './selectable_option'; +import { EuiSelectableOptionMatcher } from './selectable'; const getSearchableLabel = ( option: EuiSelectableOption, @@ -26,18 +27,30 @@ const getSelectedOptionForSearchValue = ( ); }; -const collectMatchingOption = ( - accumulator: Array>, - option: EuiSelectableOption, - normalizedSearchValue: string, - isPreFiltered?: boolean, - selectedOptions?: Array> -) => { +interface CollectMatchingOptionArgs { + accumulator: Array>; + option: EuiSelectableOption; + searchValue: string; + normalizedSearchValue: string; + isPreFiltered?: boolean; + selectedOptions?: Array>; + optionMatcher: EuiSelectableOptionMatcher; +} + +const collectMatchingOption = ({ + selectedOptions, + isPreFiltered, + option, + accumulator, + searchValue, + normalizedSearchValue, + optionMatcher, +}: CollectMatchingOptionArgs) => { // Don't show options that have already been requested if // the selectedOptions list exists if (selectedOptions) { - const selectedOption = getSelectedOptionForSearchValue( - getSearchableLabel(option, false), + const selectedOption = getSelectedOptionForSearchValue( + getSearchableLabel(option, false), selectedOptions ); if (selectedOption) { @@ -57,44 +70,76 @@ const collectMatchingOption = ( return; } - const normalizedOption = getSearchableLabel(option); - if (normalizedOption.includes(normalizedSearchValue)) { + const isMatching = optionMatcher({ + option, + searchValue, + normalizedSearchValue, + }); + if (isMatching) { accumulator.push(option); } }; type SelectableOptions = Array>; -export const getMatchingOptions = ( +interface GetMatchingOptionsArgs { /** * All available options to match against */ - options: SelectableOptions, + options: SelectableOptions; /** * String to match option.label || option.searchableLabel against */ - searchValue: string, + searchValue: string; /** * Async? */ - isPreFiltered?: boolean, + isPreFiltered: boolean; /** * To exclude selected options from the search list, * pass the array of selected options */ - selectedOptions?: SelectableOptions -) => { + selectedOptions?: SelectableOptions; + /** + * Option matcher function passed to EuiSelectable or the default matcher + */ + optionMatcher: EuiSelectableOptionMatcher; +} + +export const getMatchingOptions = ({ + searchValue, + options, + isPreFiltered, + selectedOptions = [], + optionMatcher, +}: GetMatchingOptionsArgs) => { const normalizedSearchValue = searchValue.toLowerCase(); - const matchingOptions: SelectableOptions = []; + const matchingOptions: SelectableOptions = []; options.forEach((option) => { - collectMatchingOption( - matchingOptions, + collectMatchingOption({ + accumulator: matchingOptions, option, + searchValue, normalizedSearchValue, isPreFiltered, - selectedOptions - ); + selectedOptions, + optionMatcher, + }); }); return matchingOptions; }; + +/** + * Partial string equality option matcher for EuiSelectable + * It matches all options with labels including the searched string. + */ +export const createPartialStringEqualityOptionMatcher = < + TOption +>(): EuiSelectableOptionMatcher => { + return ({ option, normalizedSearchValue }) => { + const normalizedOption = getSearchableLabel(option); + + return normalizedOption.includes(normalizedSearchValue); + }; +}; diff --git a/src/components/selectable/selectable.tsx b/src/components/selectable/selectable.tsx index 819e265570e..6591783ab0c 100644 --- a/src/components/selectable/selectable.tsx +++ b/src/components/selectable/selectable.tsx @@ -26,7 +26,10 @@ import { } from './selectable_list'; import { EuiLoadingSpinner } from '../loading'; import { EuiSpacer } from '../spacer'; -import { getMatchingOptions } from './matching_options'; +import { + createPartialStringEqualityOptionMatcher, + getMatchingOptions, +} from './matching_options'; import { keys, htmlIdGenerator } from '../../services'; import { EuiScreenReaderLive, EuiScreenReaderOnly } from '../accessibility'; import { EuiI18n } from '../i18n'; @@ -49,6 +52,16 @@ type EuiSelectableOptionsListPropsWithDefaults = RequiredEuiSelectableOptionsListProps & Partial; +export interface EuiSelectableOptionMatcherArgs { + option: EuiSelectableOption; + searchValue: string; + normalizedSearchValue: string; +} + +export type EuiSelectableOptionMatcher = ( + args: EuiSelectableOptionMatcherArgs +) => boolean; + // The `searchable` prop has significant implications for a11y. // When present, we effectively change from adhering // to the ARIA `listbox` spec (https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox) @@ -185,6 +198,15 @@ export type EuiSelectableProps = CommonProps & * interacting with a selectable are read out. */ selectableScreenReaderText?: string; + /** + * Optional custom option matcher function + * + * @example + * const exactEqualityMatcher: EuiSelectableOptionMatcher = ({ option, searchValue }) => { + * return option.label === searchValue; + * } + */ + optionMatcher?: EuiSelectableOptionMatcher; }; export interface EuiSelectableState { @@ -203,6 +225,7 @@ export class EuiSelectable extends Component< singleSelection: false, searchable: false, isPreFiltered: false, + optionMatcher: createPartialStringEqualityOptionMatcher(), }; private inputRef: HTMLInputElement | null = null; private containerRef = createRef(); @@ -226,11 +249,14 @@ export class EuiSelectable extends Component< const initialSearchValue = searchProps?.value || String(searchProps?.defaultValue || ''); - const visibleOptions = getMatchingOptions( + const visibleOptions = getMatchingOptions({ options, - initialSearchValue, - !!isPreFiltered - ); + searchValue: initialSearchValue, + isPreFiltered: !!isPreFiltered, + selectedOptions: [], + optionMatcher: props.optionMatcher!, + }); + searchProps?.onChange?.(initialSearchValue, visibleOptions); // ensure that the currently selected single option is active if it is in the visibleOptions @@ -254,7 +280,7 @@ export class EuiSelectable extends Component< nextProps: EuiSelectableProps, prevState: EuiSelectableState ) { - const { options, isPreFiltered, searchProps } = nextProps; + const { options, isPreFiltered, searchProps, optionMatcher } = nextProps; const { activeOptionIndex, searchValue } = prevState; const stateUpdate: Partial> = { @@ -266,11 +292,13 @@ export class EuiSelectable extends Component< stateUpdate.searchValue = searchProps.value; } - stateUpdate.visibleOptions = getMatchingOptions( + stateUpdate.visibleOptions = getMatchingOptions({ options, - stateUpdate.searchValue ?? '', - !!isPreFiltered - ); + searchValue: stateUpdate.searchValue ?? '', + isPreFiltered: !!isPreFiltered, + selectedOptions: [], + optionMatcher: optionMatcher!, + }); if ( activeOptionIndex != null && @@ -484,13 +512,15 @@ export class EuiSelectable extends Component< event: EuiSelectableOnChangeEvent, clickedOption: EuiSelectableOption ) => { - const { isPreFiltered, onChange } = this.props; + const { isPreFiltered, onChange, optionMatcher } = this.props; const { searchValue } = this.state; - const visibleOptions = getMatchingOptions( + const visibleOptions = getMatchingOptions({ options, - searchValue, - !!isPreFiltered - ); + searchValue: searchValue ?? '', + isPreFiltered: !!isPreFiltered, + selectedOptions: [], + optionMatcher: optionMatcher!, + }); this.setState({ visibleOptions }); @@ -529,6 +559,7 @@ export class EuiSelectable extends Component< errorMessage, selectableScreenReaderText, isPreFiltered, + optionMatcher, ...rest } = this.props; @@ -720,6 +751,7 @@ export class EuiSelectable extends Component< aria-activedescendant={this.makeOptionId(activeOptionIndex)} // the current faux-focused option placeholder={placeholderName} isPreFiltered={!!isPreFiltered} + optionMatcher={optionMatcher!} inputRef={(node) => { this.inputRef = node; searchProps?.inputRef?.(node); diff --git a/src/components/selectable/selectable_search/selectable_search.tsx b/src/components/selectable/selectable_search/selectable_search.tsx index 947f5c54fbf..87aaf826ad0 100644 --- a/src/components/selectable/selectable_search/selectable_search.tsx +++ b/src/components/selectable/selectable_search/selectable_search.tsx @@ -12,6 +12,7 @@ import { CommonProps } from '../../common'; import { EuiFieldSearch, EuiFieldSearchProps } from '../../form'; import { getMatchingOptions } from '../matching_options'; import { EuiSelectableOption } from '../selectable_option'; +import type { EuiSelectableOptionMatcher } from '../selectable'; export type EuiSelectableSearchProps = CommonProps & Omit< @@ -40,6 +41,10 @@ type _EuiSelectableSearchProps = EuiSelectableSearchProps & { */ listId?: string; isPreFiltered: boolean; + /** + * Optional custom option matcher function + */ + optionMatcher: EuiSelectableOptionMatcher; }; export const EuiSelectableSearch = ({ @@ -50,19 +55,21 @@ export const EuiSelectableSearch = ({ isPreFiltered, listId, className, + optionMatcher, ...rest }: _EuiSelectableSearchProps) => { const onChange = useCallback( (e: ChangeEvent) => { const searchValue = e.target.value; - const matchingOptions = getMatchingOptions( + const matchingOptions = getMatchingOptions({ options, searchValue, - isPreFiltered - ); + isPreFiltered, + optionMatcher, + }); onChangeCallback(searchValue, matchingOptions); }, - [options, isPreFiltered, onChangeCallback] + [options, isPreFiltered, onChangeCallback, optionMatcher] ); const classes = classNames('euiSelectableSearch', className); From 00729ae72a53107aec63364a99e4dec98b02b497 Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Tue, 23 Apr 2024 16:57:30 +0200 Subject: [PATCH 3/8] test: fix failing tests --- src/components/combo_box/combo_box.tsx | 1 + .../combo_box/matching_options.test.ts | 40 ++++++++++++++++++- .../selectable_search.test.tsx | 2 + 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/components/combo_box/combo_box.tsx b/src/components/combo_box/combo_box.tsx index f265bab1f98..ee8a578e2d7 100644 --- a/src/components/combo_box/combo_box.tsx +++ b/src/components/combo_box/combo_box.tsx @@ -743,6 +743,7 @@ export class EuiComboBox extends Component< autoFocus, truncationProps, inputPopoverProps, + optionMatcher, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...rest diff --git a/src/components/combo_box/matching_options.test.ts b/src/components/combo_box/matching_options.test.ts index 419b5892dc7..6aa89f0c477 100644 --- a/src/components/combo_box/matching_options.test.ts +++ b/src/components/combo_box/matching_options.test.ts @@ -6,12 +6,13 @@ * Side Public License, v 1. */ -import { EuiComboBoxOptionOption } from './types'; +import { EuiComboBoxOptionOption, EuiComboBoxOptionMatcher } from './types'; import { SortMatchesBy, flattenOptionGroups, getMatchingOptions, getSelectedOptionForSearchValue, + createPartialStringEqualityOptionMatcher, } from './matching_options'; const options = [ @@ -109,8 +110,12 @@ interface GetMatchingOptionsTestCase { selectedOptions: EuiComboBoxOptionOption[]; showPrevSelected: boolean; sortMatchesBy: SortMatchesBy; + optionMatcher: EuiComboBoxOptionMatcher; } +const defaultOptionMatcher = + createPartialStringEqualityOptionMatcher(); + const testCases: GetMatchingOptionsTestCase[] = [ { options, @@ -126,6 +131,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ showPrevSelected: false, expected: [], sortMatchesBy: 'none', + optionMatcher: defaultOptionMatcher, }, { options, @@ -144,6 +150,8 @@ const testCases: GetMatchingOptionsTestCase[] = [ { label: 'Mimas' }, ], sortMatchesBy: 'none', + optionMatcher: defaultOptionMatcher, + }, { options, @@ -159,6 +167,8 @@ const testCases: GetMatchingOptionsTestCase[] = [ showPrevSelected: true, expected: [{ 'data-test-subj': 'saturnOption', label: 'Saturn' }], sortMatchesBy: 'none', + optionMatcher: defaultOptionMatcher, + }, { options, @@ -178,6 +188,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ { label: 'Mimas' }, ], sortMatchesBy: 'none', + optionMatcher: defaultOptionMatcher, }, { options: [{ label: 'Titan' }, { label: 'Titan' }], @@ -194,6 +205,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ // Duplicate options without an key will be treated as the same option ], sortMatchesBy: 'none', + optionMatcher: defaultOptionMatcher, }, { options: [ @@ -215,6 +227,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ { label: 'Titan', key: 'titan1' }, ], sortMatchesBy: 'none', + optionMatcher: defaultOptionMatcher, }, // Case sensitivity { @@ -231,6 +244,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ }, ], sortMatchesBy: 'none', + optionMatcher: defaultOptionMatcher, }, { options, @@ -241,6 +255,7 @@ const testCases: GetMatchingOptionsTestCase[] = [ showPrevSelected: false, expected: [], sortMatchesBy: 'none', + optionMatcher: defaultOptionMatcher, }, { options, @@ -256,6 +271,29 @@ const testCases: GetMatchingOptionsTestCase[] = [ }, ], sortMatchesBy: 'none', + optionMatcher: defaultOptionMatcher, + }, + { + options, + selectedOptions: [], + searchValue: 'Titan', + isCaseSensitive: false, + isPreFiltered: false, + showPrevSelected: false, + expected: options, + sortMatchesBy: 'none', + optionMatcher: () => true, + }, + { + options, + selectedOptions: [], + searchValue: 'Titan', + isCaseSensitive: false, + isPreFiltered: false, + showPrevSelected: false, + expected: [], + sortMatchesBy: 'none', + optionMatcher: () => false, }, ]; diff --git a/src/components/selectable/selectable_search/selectable_search.test.tsx b/src/components/selectable/selectable_search/selectable_search.test.tsx index 1964471d86e..d1cbf438848 100644 --- a/src/components/selectable/selectable_search/selectable_search.test.tsx +++ b/src/components/selectable/selectable_search/selectable_search.test.tsx @@ -12,6 +12,7 @@ import { render } from '../../../test/rtl'; import { requiredProps } from '../../../test/required_props'; import { EuiSelectableSearch } from './selectable_search'; +import { createPartialStringEqualityOptionMatcher } from '../matching_options'; describe('EuiSelectableSearch', () => { const onChange = jest.fn(); @@ -21,6 +22,7 @@ describe('EuiSelectableSearch', () => { options: [{ label: 'hello' }, { label: 'world' }], value: '', isPreFiltered: false, + optionMatcher: createPartialStringEqualityOptionMatcher(), }; beforeEach(() => jest.clearAllMocks()); From 0f08ac1603b6ce76afa59b4bfdc62eb09cd33cfa Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Wed, 24 Apr 2024 15:33:59 +0200 Subject: [PATCH 4/8] style: fix matching_options.test.ts formatting --- src/components/combo_box/matching_options.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/combo_box/matching_options.test.ts b/src/components/combo_box/matching_options.test.ts index 6aa89f0c477..68b6b952b36 100644 --- a/src/components/combo_box/matching_options.test.ts +++ b/src/components/combo_box/matching_options.test.ts @@ -151,7 +151,6 @@ const testCases: GetMatchingOptionsTestCase[] = [ ], sortMatchesBy: 'none', optionMatcher: defaultOptionMatcher, - }, { options, @@ -168,7 +167,6 @@ const testCases: GetMatchingOptionsTestCase[] = [ expected: [{ 'data-test-subj': 'saturnOption', label: 'Saturn' }], sortMatchesBy: 'none', optionMatcher: defaultOptionMatcher, - }, { options, From 835bac39a663d70161a3484e5efc7a9ea36d2d06 Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Wed, 24 Apr 2024 16:46:40 +0200 Subject: [PATCH 5/8] chore: add changelog --- changelogs/upcoming/7709.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelogs/upcoming/7709.md diff --git a/changelogs/upcoming/7709.md b/changelogs/upcoming/7709.md new file mode 100644 index 00000000000..a1bf329af06 --- /dev/null +++ b/changelogs/upcoming/7709.md @@ -0,0 +1 @@ +- Added a new, optional `optionMatcher` prop to `EuiSelectable` and `EuiComboBox` allowing passing a custom option matcher function to these components and controlling option filtering for given search string From b0b9b1928b27baf85e04a87841ff42b3b461e785 Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Wed, 24 Apr 2024 17:35:07 +0200 Subject: [PATCH 6/8] docs(EuiComboBox): add `optionMatcher` section and example --- .../src/views/combo_box/combo_box_example.js | 40 +++++++++++ .../src/views/combo_box/option_matcher.tsx | 69 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src-docs/src/views/combo_box/option_matcher.tsx 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 8944d40e0b4..010edf189ca 100644 --- a/src-docs/src/views/combo_box/combo_box_example.js +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -258,6 +258,17 @@ const labelledbySnippet = ``; +import OptionMatcher from './option_matcher'; +const optionMatcherSource = require('!!raw-loader!./option_matcher'); +const optionMatcherSnippet = ``; + export const ComboBoxExample = { title: 'Combo box', intro: ( @@ -677,6 +688,35 @@ export const ComboBoxExample = { snippet: startingWithSnippet, demo: , }, + { + title: 'Custom option matcher', + text: ( + <> +

+ When searching for options, EuiComboBox uses a + partial equality string matcher by default, displaying all options + whose labels include the searched string and taking{' '} + isCaseSensitive prop value into account. +

+

+ In rare cases, you may want to customize this behavior. You can do + so by passing a custom option matcher function to the{' '} + optionMatcher prop. The function must be of type{' '} + EuiComboBoxOptionMatcher and return + true for options that should be visible for given + search string. +

+ + ), + source: [ + { + type: GuideSectionTypes.TSX, + code: optionMatcherSource, + }, + ], + snippet: optionMatcherSnippet, + demo: , + }, { title: 'Duplicate labels', source: [ diff --git a/src-docs/src/views/combo_box/option_matcher.tsx b/src-docs/src/views/combo_box/option_matcher.tsx new file mode 100644 index 00000000000..9aef76ae99c --- /dev/null +++ b/src-docs/src/views/combo_box/option_matcher.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useState } from 'react'; + +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiComboBoxOptionMatcher, +} from '../../../../src'; + +const options: EuiComboBoxOptionOption[] = [ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + }, + { + label: 'Enceladus', + }, + { + 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', + }, +]; + +export default () => { + const [selectedOptions, setSelected] = useState( + [] + ); + const onChange = (selectedOptions: EuiComboBoxOptionOption[]) => { + setSelected(selectedOptions); + }; + + const startsWithMatcher = useCallback>( + ({ option, searchValue }) => { + return option.label.startsWith(searchValue); + }, + [] + ); + + return ( + + ); +}; From 6cc9f8a9d45debf8a2334fa983616c8587703f9d Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Wed, 24 Apr 2024 17:43:37 +0200 Subject: [PATCH 7/8] docs(EuiSelectable): add `optionMatcher` section and example --- .../views/selectable/selectable_example.js | 37 ++++++++++ .../selectable/selectable_option_matcher.tsx | 71 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src-docs/src/views/selectable/selectable_option_matcher.tsx diff --git a/src-docs/src/views/selectable/selectable_example.js b/src-docs/src/views/selectable/selectable_example.js index 9f6dae7c1b1..301af959245 100644 --- a/src-docs/src/views/selectable/selectable_example.js +++ b/src-docs/src/views/selectable/selectable_example.js @@ -45,6 +45,9 @@ const truncationSource = require('!!raw-loader!./selectable_truncation'); import SelectableCustomRender from './selectable_custom_render'; const selectableCustomRenderSource = require('!!raw-loader!./selectable_custom_render'); +import SelectableOptionMatcher from './selectable_option_matcher'; +const selectableOptionMatcherSource = require('!!raw-loader!./selectable_option_matcher'); + const props = { EuiSelectable, EuiSelectableOptionProps, @@ -526,5 +529,39 @@ export const SelectableExample = { `, props, }, + { + title: 'Custom option matcher', + text: ( + <> +

+ When searching for options, EuiSelectable uses a + partial equality string matcher by default, displaying all options + whose labels include the searched string. +

+

+ In rare cases, you may want to customize this behavior. You can do + so by passing a custom option matcher function to the{' '} + optionMatcher prop. The function must be of type{' '} + EuiSelectableOptionMatcher and return + true for options that should be visible for given + search string. +

+ + ), + source: [ + { + type: GuideSectionTypes.TSX, + code: selectableOptionMatcherSource, + }, + ], + demo: , + snippet: ` setOptions(newOptions)} + optionMatcher={optionMatcher} +> + {list => list} +`, + }, ], }; diff --git a/src-docs/src/views/selectable/selectable_option_matcher.tsx b/src-docs/src/views/selectable/selectable_option_matcher.tsx new file mode 100644 index 00000000000..1e7387496f2 --- /dev/null +++ b/src-docs/src/views/selectable/selectable_option_matcher.tsx @@ -0,0 +1,71 @@ +import React, { useCallback, useState } from 'react'; + +import { EuiSelectable, EuiSelectableOption } from '../../../../src'; +import { EuiSelectableOptionMatcher } from '../../../../src/components/selectable/selectable'; + +export default () => { + const [options, setOptions] = useState([ + { + label: 'Titan', + 'data-test-subj': 'titanOption', + }, + { + label: 'Enceladus is disabled', + disabled: true, + }, + { + label: 'Mimas', + checked: 'on', + }, + { + label: 'Dione', + }, + { + label: 'Iapetus', + checked: 'on', + }, + { + label: 'Phoebe', + }, + { + label: 'Rhea', + }, + { + label: + "Pandora is one of Saturn's moons, named for a Titaness of Greek mythology", + }, + { + label: 'Tethys', + }, + { + label: 'Hyperion', + }, + ]); + + const startsWithMatcher = useCallback>( + ({ option, searchValue }) => { + return option.label.startsWith(searchValue); + }, + [] + ); + + return ( + setOptions(newOptions)} + optionMatcher={startsWithMatcher} + > + {(list, search) => ( + <> + {search} + {list} + + )} + + ); +}; From d718bf78936aba3d3758f9b4a2480d2ddc971a8c Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Mon, 29 Apr 2024 15:54:37 +0200 Subject: [PATCH 8/8] fix(EuiSelectableSearchProps): fix `optionMatcher` jsdoc --- .../selectable/selectable_search/selectable_search.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/selectable/selectable_search/selectable_search.tsx b/src/components/selectable/selectable_search/selectable_search.tsx index 87aaf826ad0..7434bce690a 100644 --- a/src/components/selectable/selectable_search/selectable_search.tsx +++ b/src/components/selectable/selectable_search/selectable_search.tsx @@ -42,7 +42,7 @@ type _EuiSelectableSearchProps = EuiSelectableSearchProps & { listId?: string; isPreFiltered: boolean; /** - * Optional custom option matcher function + * Option matcher function */ optionMatcher: EuiSelectableOptionMatcher; };