diff --git a/changelogs/upcoming/7709.md b/changelogs/upcoming/7709.md
new file mode 100644
index 000000000000..a1bf329af065
--- /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
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 87b50fb01048..69c6c90e2f52 100644
--- a/src-docs/src/views/combo_box/combo_box_example.js
+++ b/src-docs/src/views/combo_box/combo_box_example.js
@@ -278,6 +278,17 @@ const labelledbySnippet = ``;
+import OptionMatcher from './option_matcher';
+const optionMatcherSource = require('!!raw-loader!./option_matcher');
+const optionMatcherSnippet = ``;
+
export const ComboBoxExample = {
title: 'Combo box',
intro: (
@@ -716,6 +727,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 000000000000..9aef76ae99c3
--- /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 (
+
+ );
+};
diff --git a/src-docs/src/views/selectable/selectable_example.js b/src-docs/src/views/selectable/selectable_example.js
index 9f6dae7c1b11..301af9592450 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 000000000000..1e7387496f2e
--- /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}
+ >
+ )}
+
+ );
+};
diff --git a/src/components/combo_box/combo_box.stories.tsx b/src/components/combo_box/combo_box.stories.tsx
index 07c23965447c..dba7d675efe5 100644
--- a/src/components/combo_box/combo_box.stories.tsx
+++ b/src/components/combo_box/combo_box.stories.tsx
@@ -6,12 +6,14 @@
* 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 { ToolTipPositions } from '../tool_tip';
import { EuiComboBox, EuiComboBoxProps } from './combo_box';
+import { EuiComboBoxOptionMatcher } from './types';
+import { EuiCode } from '../code';
const toolTipProps = {
toolTipContent: 'This is a tooltip!',
@@ -121,3 +123,45 @@ const StatefulComboBox = ({
/>
);
};
+
+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 54608db7d9d8..ee8a578e2d7a 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 };
@@ -727,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/index.ts b/src/components/combo_box/index.ts
index d85ab3bf70fb..916be6a65c54 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.test.ts b/src/components/combo_box/matching_options.test.ts
index 419b5892dc7a..68b6b952b362 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,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
{ label: 'Mimas' },
],
sortMatchesBy: 'none',
+ optionMatcher: defaultOptionMatcher,
},
{
options,
@@ -159,6 +166,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
showPrevSelected: true,
expected: [{ 'data-test-subj': 'saturnOption', label: 'Saturn' }],
sortMatchesBy: 'none',
+ optionMatcher: defaultOptionMatcher,
},
{
options,
@@ -178,6 +186,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
{ label: 'Mimas' },
],
sortMatchesBy: 'none',
+ optionMatcher: defaultOptionMatcher,
},
{
options: [{ label: 'Titan' }, { label: 'Titan' }],
@@ -194,6 +203,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
// Duplicate options without an key will be treated as the same option
],
sortMatchesBy: 'none',
+ optionMatcher: defaultOptionMatcher,
},
{
options: [
@@ -215,6 +225,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
{ label: 'Titan', key: 'titan1' },
],
sortMatchesBy: 'none',
+ optionMatcher: defaultOptionMatcher,
},
// Case sensitivity
{
@@ -231,6 +242,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
},
],
sortMatchesBy: 'none',
+ optionMatcher: defaultOptionMatcher,
},
{
options,
@@ -241,6 +253,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
showPrevSelected: false,
expected: [],
sortMatchesBy: 'none',
+ optionMatcher: defaultOptionMatcher,
},
{
options,
@@ -256,6 +269,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/combo_box/matching_options.ts b/src/components/combo_box/matching_options.ts
index 2ef3095994aa..74344f6e1d9f 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 5ea792c8842d..ecd524811ca4 100644
--- a/src/components/combo_box/types.ts
+++ b/src/components/combo_box/types.ts
@@ -42,3 +42,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;
diff --git a/src/components/selectable/matching_options.ts b/src/components/selectable/matching_options.ts
index dc4e7b04d654..870004117146 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 819e265570e3..6591783ab0cc 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.test.tsx b/src/components/selectable/selectable_search/selectable_search.test.tsx
index 1964471d86ef..d1cbf4388481 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());
diff --git a/src/components/selectable/selectable_search/selectable_search.tsx b/src/components/selectable/selectable_search/selectable_search.tsx
index 947f5c54fbf5..7434bce690a9 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;
+ /**
+ * 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);