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
89 changes: 67 additions & 22 deletions src/components/selectable/matching_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { EuiSelectableOption } from './selectable_option';
import { EuiSelectableOptionMatcher } from './selectable';

const getSearchableLabel = <T>(
option: EuiSelectableOption<T>,
Expand All @@ -26,18 +27,30 @@ const getSelectedOptionForSearchValue = <T>(
);
};

const collectMatchingOption = <T>(
accumulator: Array<EuiSelectableOption<T>>,
option: EuiSelectableOption<T>,
normalizedSearchValue: string,
isPreFiltered?: boolean,
selectedOptions?: Array<EuiSelectableOption<T>>
) => {
interface CollectMatchingOptionArgs<TOption> {
accumulator: Array<EuiSelectableOption<TOption>>;
option: EuiSelectableOption<TOption>;
searchValue: string;
normalizedSearchValue: string;
isPreFiltered?: boolean;
selectedOptions?: Array<EuiSelectableOption<TOption>>;
optionMatcher: EuiSelectableOptionMatcher<TOption>;
}

const collectMatchingOption = <TOption>({
selectedOptions,
isPreFiltered,
option,
accumulator,
searchValue,
normalizedSearchValue,
optionMatcher,
}: CollectMatchingOptionArgs<TOption>) => {
// Don't show options that have already been requested if
// the selectedOptions list exists
if (selectedOptions) {
const selectedOption = getSelectedOptionForSearchValue<T>(
getSearchableLabel<T>(option, false),
const selectedOption = getSelectedOptionForSearchValue<TOption>(
getSearchableLabel<TOption>(option, false),
selectedOptions
);
if (selectedOption) {
Expand All @@ -57,44 +70,76 @@ const collectMatchingOption = <T>(
return;
}

const normalizedOption = getSearchableLabel<T>(option);
if (normalizedOption.includes(normalizedSearchValue)) {
const isMatching = optionMatcher({
option,
searchValue,
normalizedSearchValue,
});
if (isMatching) {
accumulator.push(option);
}
};

type SelectableOptions<T> = Array<EuiSelectableOption<T>>;

export const getMatchingOptions = <T>(
interface GetMatchingOptionsArgs<TOption> {
/**
* All available options to match against
*/
options: SelectableOptions<T>,
options: SelectableOptions<TOption>;
/**
* 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<T>
) => {
selectedOptions?: SelectableOptions<TOption>;
/**
* Option matcher function passed to EuiSelectable or the default matcher
*/
optionMatcher: EuiSelectableOptionMatcher<TOption>;
}

export const getMatchingOptions = <TOption>({
searchValue,
options,
isPreFiltered,
selectedOptions = [],
optionMatcher,
}: GetMatchingOptionsArgs<TOption>) => {
const normalizedSearchValue = searchValue.toLowerCase();
const matchingOptions: SelectableOptions<T> = [];
const matchingOptions: SelectableOptions<TOption> = [];

options.forEach((option) => {
collectMatchingOption<T>(
matchingOptions,
collectMatchingOption<TOption>({
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<TOption> => {
return ({ option, normalizedSearchValue }) => {
const normalizedOption = getSearchableLabel(option);

return normalizedOption.includes(normalizedSearchValue);
};
};
62 changes: 47 additions & 15 deletions src/components/selectable/selectable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -49,6 +52,16 @@ type EuiSelectableOptionsListPropsWithDefaults =
RequiredEuiSelectableOptionsListProps &
Partial<OptionalEuiSelectableOptionsListProps>;

export interface EuiSelectableOptionMatcherArgs<TOption> {
option: EuiSelectableOption<TOption>;
searchValue: string;
normalizedSearchValue: string;
}

export type EuiSelectableOptionMatcher<T> = (
args: EuiSelectableOptionMatcherArgs<T>
) => 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)
Expand Down Expand Up @@ -185,6 +198,15 @@ export type EuiSelectableProps<T = {}> = 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<T>;
};

export interface EuiSelectableState<T> {
Expand All @@ -203,6 +225,7 @@ export class EuiSelectable<T = {}> extends Component<
singleSelection: false,
searchable: false,
isPreFiltered: false,
optionMatcher: createPartialStringEqualityOptionMatcher(),
};
private inputRef: HTMLInputElement | null = null;
private containerRef = createRef<HTMLDivElement>();
Expand All @@ -226,11 +249,14 @@ export class EuiSelectable<T = {}> extends Component<
const initialSearchValue =
searchProps?.value || String(searchProps?.defaultValue || '');

const visibleOptions = getMatchingOptions<T>(
const visibleOptions = getMatchingOptions<T>({
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
Expand All @@ -254,7 +280,7 @@ export class EuiSelectable<T = {}> extends Component<
nextProps: EuiSelectableProps<T>,
prevState: EuiSelectableState<T>
) {
const { options, isPreFiltered, searchProps } = nextProps;
const { options, isPreFiltered, searchProps, optionMatcher } = nextProps;
const { activeOptionIndex, searchValue } = prevState;

const stateUpdate: Partial<EuiSelectableState<T>> = {
Expand All @@ -266,11 +292,13 @@ export class EuiSelectable<T = {}> extends Component<
stateUpdate.searchValue = searchProps.value;
}

stateUpdate.visibleOptions = getMatchingOptions<T>(
stateUpdate.visibleOptions = getMatchingOptions<T>({
options,
stateUpdate.searchValue ?? '',
!!isPreFiltered
);
searchValue: stateUpdate.searchValue ?? '',
isPreFiltered: !!isPreFiltered,
selectedOptions: [],
optionMatcher: optionMatcher!,
});

if (
activeOptionIndex != null &&
Expand Down Expand Up @@ -484,13 +512,15 @@ export class EuiSelectable<T = {}> extends Component<
event: EuiSelectableOnChangeEvent,
clickedOption: EuiSelectableOption<T>
) => {
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 });

Expand Down Expand Up @@ -529,6 +559,7 @@ export class EuiSelectable<T = {}> extends Component<
errorMessage,
selectableScreenReaderText,
isPreFiltered,
optionMatcher,
...rest
} = this.props;

Expand Down Expand Up @@ -720,6 +751,7 @@ export class EuiSelectable<T = {}> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = CommonProps &
Omit<
Expand Down Expand Up @@ -40,6 +41,10 @@ type _EuiSelectableSearchProps<T> = EuiSelectableSearchProps<T> & {
*/
listId?: string;
isPreFiltered: boolean;
/**
* Optional custom option matcher function
*/
optionMatcher: EuiSelectableOptionMatcher<T>;
tkajtoch marked this conversation as resolved.
Show resolved Hide resolved
};

export const EuiSelectableSearch = <T,>({
Expand All @@ -50,19 +55,21 @@ export const EuiSelectableSearch = <T,>({
isPreFiltered,
listId,
className,
optionMatcher,
...rest
}: _EuiSelectableSearchProps<T>) => {
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const searchValue = e.target.value;
const matchingOptions = getMatchingOptions<T>(
const matchingOptions = getMatchingOptions<T>({
options,
searchValue,
isPreFiltered
);
isPreFiltered,
optionMatcher,
});
onChangeCallback(searchValue, matchingOptions);
},
[options, isPreFiltered, onChangeCallback]
[options, isPreFiltered, onChangeCallback, optionMatcher]
);

const classes = classNames('euiSelectableSearch', className);
Expand Down