Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add optionMatcher prop to EuiSelectable and EuiComboBox components #7709

Merged
merged 8 commits into from
Apr 29, 2024
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 }) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗒️ A general note, mainly for myself here:
Whenever we align on the code-snippet API in this PR and merge it, this story needs to be updated as it's a case of "additional composition wrapper" (it will likely need the parameters.codeSnippet.resolveChildren: true as otherwise this story snippet would be <Render anyStoryArgsHere />

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the case for many of our stories, though, isn't it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some for sure. There are different cases I've seen so far. It depends on what should be output per story.
E.g. for cases like stateful wrappers that return just the actual story component it's different than a wrapper that returns the story component as nested child with other composition elements.

For stateful wrappers or related wrappers (Where a parent-subcomponent structure is required and can be determined based on naming it's already done automatically)
For anything else it might need adjustments.
I'm currently checking the newly added stories and there are some new cases as well that I did not consider yet (render functions 🙈)

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
Loading