Skip to content

Commit

Permalink
feat: Add optionMatcher prop to EuiSelectable and EuiComboBox c…
Browse files Browse the repository at this point in the history
…omponents (#7709)
  • Loading branch information
tkajtoch authored Apr 29, 2024
1 parent 9281590 commit 1e83176
Show file tree
Hide file tree
Showing 15 changed files with 522 additions and 51 deletions.
1 change: 1 addition & 0 deletions changelogs/upcoming/7709.md
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions src-docs/src/views/combo_box/combo_box_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,17 @@ const labelledbySnippet = `<EuiComboBox
isClearable={true}
/>`;

import OptionMatcher from './option_matcher';
const optionMatcherSource = require('!!raw-loader!./option_matcher');
const optionMatcherSnippet = `<EuiComboBox
aria-label="Accessible screen reader label"
placeholder="Select or create options"
options={options}
onChange={onChange}
onCreateOption={onCreateOption}
optionMatcher={optionMatcher}
/>`;

export const ComboBoxExample = {
title: 'Combo box',
intro: (
Expand Down Expand Up @@ -677,6 +688,35 @@ export const ComboBoxExample = {
snippet: startingWithSnippet,
demo: <StartingWith />,
},
{
title: 'Custom option matcher',
text: (
<>
<p>
When searching for options, <EuiCode>EuiComboBox</EuiCode> uses a
partial equality string matcher by default, displaying all options
whose labels include the searched string and taking{' '}
<EuiCode>isCaseSensitive</EuiCode> prop value into account.
</p>
<p>
In rare cases, you may want to customize this behavior. You can do
so by passing a custom option matcher function to the{' '}
<EuiCode>optionMatcher</EuiCode> prop. The function must be of type{' '}
<EuiCode>EuiComboBoxOptionMatcher</EuiCode> and return
<EuiCode>true</EuiCode> for options that should be visible for given
search string.
</p>
</>
),
source: [
{
type: GuideSectionTypes.TSX,
code: optionMatcherSource,
},
],
snippet: optionMatcherSnippet,
demo: <OptionMatcher />,
},
{
title: 'Duplicate labels',
source: [
Expand Down
69 changes: 69 additions & 0 deletions src-docs/src/views/combo_box/option_matcher.tsx
Original file line number Diff line number Diff line change
@@ -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<EuiComboBoxOptionOption[]>(
[]
);
const onChange = (selectedOptions: EuiComboBoxOptionOption[]) => {
setSelected(selectedOptions);
};

const startsWithMatcher = useCallback<EuiComboBoxOptionMatcher<unknown>>(
({ option, searchValue }) => {
return option.label.startsWith(searchValue);
},
[]
);

return (
<EuiComboBox
placeholder="Select options"
options={options}
selectedOptions={selectedOptions}
onChange={onChange}
isClearable={true}
optionMatcher={startsWithMatcher}
/>
);
};
37 changes: 37 additions & 0 deletions src-docs/src/views/selectable/selectable_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -526,5 +529,39 @@ export const SelectableExample = {
</EuiSelectable>`,
props,
},
{
title: 'Custom option matcher',
text: (
<>
<p>
When searching for options, <EuiCode>EuiSelectable</EuiCode> uses a
partial equality string matcher by default, displaying all options
whose labels include the searched string.
</p>
<p>
In rare cases, you may want to customize this behavior. You can do
so by passing a custom option matcher function to the{' '}
<EuiCode>optionMatcher</EuiCode> prop. The function must be of type{' '}
<EuiCode>EuiSelectableOptionMatcher</EuiCode> and return
<EuiCode>true</EuiCode> for options that should be visible for given
search string.
</p>
</>
),
source: [
{
type: GuideSectionTypes.TSX,
code: selectableOptionMatcherSource,
},
],
demo: <SelectableOptionMatcher />,
snippet: `<EuiSelectable
options={[]}
onChange={newOptions => setOptions(newOptions)}
optionMatcher={optionMatcher}
>
{list => list}
</EuiSelectable>`,
},
],
};
71 changes: 71 additions & 0 deletions src-docs/src/views/selectable/selectable_option_matcher.tsx
Original file line number Diff line number Diff line change
@@ -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<EuiSelectableOption[]>([
{
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<EuiSelectableOptionMatcher<unknown>>(
({ option, searchValue }) => {
return option.label.startsWith(searchValue);
},
[]
);

return (
<EuiSelectable
aria-label="Searchable example"
searchable
searchProps={{
'data-test-subj': 'selectableSearchHere',
}}
options={options}
onChange={(newOptions) => setOptions(newOptions)}
optionMatcher={startsWithMatcher}
>
{(list, search) => (
<>
{search}
{list}
</>
)}
</EuiSelectable>
);
};
46 changes: 45 additions & 1 deletion src/components/combo_box/combo_box.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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<EuiComboBoxOptionMatcher<unknown>>(
({ option, searchValue }) => {
return option.label.startsWith(searchValue);
},
[]
);

return (
<>
<p>
This matcher example uses <EuiCode>option.label.startsWith()</EuiCode>
. Only options that start exactly like the given search string will be
matched.
</p>
<br />
<EuiComboBox
singleSelection={
// @ts-ignore Specific to Storybook control
singleSelection === 'asPlainText'
? { asPlainText: true }
: Boolean(singleSelection)
}
{...args}
selectedOptions={selectedOptions}
onChange={onChange}
optionMatcher={optionMatcher}
/>
</>
);
},
};
19 changes: 18 additions & 1 deletion src/components/combo_box/combo_box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
getSelectedOptionForSearchValue,
transformForCaseSensitivity,
SortMatchesBy,
createPartialStringEqualityOptionMatcher,
} from './matching_options';
import {
EuiComboBoxInputProps,
Expand All @@ -43,6 +44,7 @@ import {
RefInstance,
EuiComboBoxOptionOption,
EuiComboBoxSingleSelectionShape,
EuiComboBoxOptionMatcher,
} from './types';
import { EuiComboBoxOptionsList } from './combo_box_options_list';

Expand Down Expand Up @@ -125,6 +127,15 @@ export interface _EuiComboBoxProps<T>
* 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<T>;
/**
* 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
Expand Down Expand Up @@ -181,10 +192,11 @@ export interface _EuiComboBoxProps<T>
*/
type DefaultProps<T> = Omit<
(typeof EuiComboBox)['defaultProps'],
'options' | 'selectedOptions'
'options' | 'selectedOptions' | 'optionMatcher'
> & {
options: Array<EuiComboBoxOptionOption<T>>;
selectedOptions: Array<EuiComboBoxOptionOption<T>>;
optionMatcher: EuiComboBoxOptionMatcher<T>;
};
export type EuiComboBoxProps<T> = Omit<
_EuiComboBoxProps<T>,
Expand Down Expand Up @@ -217,6 +229,7 @@ export class EuiComboBox<T> extends Component<
prepend: undefined,
append: undefined,
sortMatchesBy: 'none' as const,
optionMatcher: createPartialStringEqualityOptionMatcher(),
};

state: EuiComboBoxState<T> = {
Expand All @@ -227,6 +240,7 @@ export class EuiComboBox<T> 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),
Expand Down Expand Up @@ -670,6 +684,7 @@ export class EuiComboBox<T> extends Component<
selectedOptions,
singleSelection,
sortMatchesBy,
optionMatcher,
} = nextProps;
const { activeOptionIndex, searchValue } = prevState;

Expand All @@ -683,6 +698,7 @@ export class EuiComboBox<T> extends Component<
isPreFiltered: async,
showPrevSelected: Boolean(singleSelection),
sortMatchesBy,
optionMatcher: optionMatcher!,
});

const stateUpdate: Partial<EuiComboBoxState<T>> = { matchingOptions };
Expand Down Expand Up @@ -727,6 +743,7 @@ export class EuiComboBox<T> extends Component<
autoFocus,
truncationProps,
inputPopoverProps,
optionMatcher,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
...rest
Expand Down
Loading

0 comments on commit 1e83176

Please sign in to comment.