Skip to content

Commit

Permalink
[EuiComboBox] Optional case sensitive option matching (#6268)
Browse files Browse the repository at this point in the history
* refactor API and introduce isCaseSensitive

* refactor utils; add isCaseSensitiveProp

* CL

* docs

* enforce highlight case sensitivity

* account for more toLowerCase; new transform util
  • Loading branch information
thompsongl authored Oct 3, 2022
1 parent d5e5638 commit 9c8681c
Show file tree
Hide file tree
Showing 8 changed files with 403 additions and 91 deletions.
84 changes: 84 additions & 0 deletions src-docs/src/views/combo_box/case_sensitive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { useState } from 'react';

import { EuiComboBox } from '../../../../src/components';

export default () => {
const [options, updateOptions] = useState([
{
label: 'Titan',
'data-test-subj': 'titanOption',
},
{
label: 'Enceladus is disabled',
disabled: true,
},
{
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',
},
]);

const [selectedOptions, setSelected] = useState([]);

const onChange = (selectedOptions) => {
setSelected(selectedOptions);
};

const onCreateOption = (searchValue, flattenedOptions) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();

if (!normalizedSearchValue) {
return;
}

const newOption = {
label: searchValue,
};

// Create the option if it doesn't exist.
if (
flattenedOptions.findIndex(
(option) => option.label.trim().toLowerCase() === normalizedSearchValue
) === -1
) {
updateOptions([...options, newOption]);
}

// Select the option.
setSelected((prevSelected) => [...prevSelected, newOption]);
};

return (
<EuiComboBox
aria-label="Accessible screen reader label"
placeholder="Select or create options"
options={options}
selectedOptions={selectedOptions}
onChange={onChange}
onCreateOption={onCreateOption}
isClearable={true}
isCaseSensitive
/>
);
};
29 changes: 29 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 @@ -150,6 +150,17 @@ const virtualizedSnippet = `<EuiComboBox
onChange={onChange}
/>`;

import CaseSensitive from './case_sensitive';
const caseSensitiveSource = require('!!raw-loader!./case_sensitive');
const caseSensitiveSnippet = `<EuiComboBox
aria-label="Accessible screen reader label"
placeholder="Select or create options"
options={options}
onChange={onChange}
onCreateOption={onCreateOption}
isCaseSensitive
/>`;

import Disabled from './disabled';
const disabledSource = require('!!raw-loader!./disabled');
const disabledSnippet = `<EuiComboBox
Expand Down Expand Up @@ -269,6 +280,24 @@ export const ComboBoxExample = {
snippet: disabledSnippet,
demo: <Disabled />,
},
{
title: 'Case-sensitive matching',
source: [
{
type: GuideSectionTypes.JS,
code: caseSensitiveSource,
},
],
text: (
<p>
Set the prop <EuiCode>isCaseSensitive</EuiCode> to make the combo box
option matching case sensitive.
</p>
),
props: { EuiComboBox, EuiComboBoxOptionOption },
snippet: caseSensitiveSnippet,
demo: <CaseSensitive />,
},
{
title: 'Virtualized',
source: [
Expand Down
50 changes: 50 additions & 0 deletions src/components/combo_box/combo_box.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,56 @@ describe('behavior', () => {
});
});

describe('isCaseSensitive', () => {
const isCaseSensitiveOptions = [
{
label: 'Case sensitivity',
},
];

test('options "false"', () => {
const component = mount<
EuiComboBox<TitanOption>,
EuiComboBoxProps<TitanOption>,
{ matchingOptions: TitanOption[] }
>(
<EuiComboBox options={isCaseSensitiveOptions} isCaseSensitive={false} />
);

findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
target: { value: 'case' },
});

expect(component.state('matchingOptions')[0].label).toBe(
'Case sensitivity'
);
});

test('options "true"', () => {
const component = mount<
EuiComboBox<TitanOption>,
EuiComboBoxProps<TitanOption>,
{ matchingOptions: TitanOption[] }
>(
<EuiComboBox options={isCaseSensitiveOptions} isCaseSensitive={true} />
);

findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
target: { value: 'case' },
});

expect(component.state('matchingOptions').length).toBe(0);

findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
target: { value: 'Case' },
});

expect(component.state('matchingOptions')[0].label).toBe(
'Case sensitivity'
);
});
});

it('calls the inputRef prop with the input element', () => {
const inputRefCallback = jest.fn();

Expand Down
86 changes: 60 additions & 26 deletions src/components/combo_box/combo_box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
getMatchingOptions,
flattenOptionGroups,
getSelectedOptionForSearchValue,
transformForCaseSensitivity,
SortMatchesBy,
} from './matching_options';
import {
EuiComboBoxInputProps,
Expand Down Expand Up @@ -122,7 +124,11 @@ export interface _EuiComboBoxProps<T>
* `startsWith`: moves items that start with search value to top of the list;
* `none`: don't change the sort order of initial object
*/
sortMatchesBy: 'none' | 'startsWith';
sortMatchesBy: SortMatchesBy;
/**
* Whether to match options with case sensitivity.
*/
isCaseSensitive?: boolean;
/**
* 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 @@ -211,14 +217,15 @@ export class EuiComboBox<T> extends Component<
listElement: null,
listPosition: 'bottom',
listZIndex: undefined,
matchingOptions: getMatchingOptions<T>(
this.props.options,
this.props.selectedOptions,
initialSearchValue,
this.props.async,
Boolean(this.props.singleSelection),
this.props.sortMatchesBy
),
matchingOptions: getMatchingOptions<T>({
options: this.props.options,
selectedOptions: this.props.selectedOptions,
searchValue: initialSearchValue,
isCaseSensitive: this.props.isCaseSensitive,
isPreFiltered: this.props.async,
showPrevSelected: Boolean(this.props.singleSelection),
sortMatchesBy: this.props.sortMatchesBy,
}),
searchValue: initialSearchValue,
width: 0,
};
Expand Down Expand Up @@ -433,6 +440,7 @@ export class EuiComboBox<T> extends Component<

addCustomOption = (isContainerBlur: boolean, searchValue: string) => {
const {
isCaseSensitive,
onCreateOption,
options,
selectedOptions,
Expand All @@ -456,7 +464,13 @@ export class EuiComboBox<T> extends Component<
}

// Don't create the value if it's already been selected.
if (getSelectedOptionForSearchValue(searchValue, selectedOptions)) {
if (
getSelectedOptionForSearchValue({
isCaseSensitive,
searchValue,
selectedOptions,
})
) {
return;
}

Expand Down Expand Up @@ -484,26 +498,40 @@ export class EuiComboBox<T> extends Component<
if (this.state.matchingOptions.length !== 1) {
return false;
}
return (
this.state.matchingOptions[0].label.toLowerCase() ===
searchValue.toLowerCase()
const normalizedSearchSubject = transformForCaseSensitivity(
this.state.matchingOptions[0].label,
this.props.isCaseSensitive
);
const normalizedSearchValue = transformForCaseSensitivity(
searchValue,
this.props.isCaseSensitive
);
return normalizedSearchSubject === normalizedSearchValue;
};

areAllOptionsSelected = () => {
const { options, selectedOptions, async } = this.props;
const { options, selectedOptions, async, isCaseSensitive } = this.props;
// Assume if this is async then there could be infinite options.
if (async) {
return false;
}

const flattenOptions = flattenOptionGroups(options).map((option) => {
return { ...option, label: option.label.trim().toLowerCase() };
return {
...option,
label: transformForCaseSensitivity(
option.label.trim(),
isCaseSensitive
),
};
});

let numberOfSelectedOptions = 0;
selectedOptions.forEach(({ label }) => {
const trimmedLabel = label.trim().toLowerCase();
const trimmedLabel = transformForCaseSensitivity(
label.trim(),
isCaseSensitive
);
if (
flattenOptions.findIndex((option) => option.label === trimmedLabel) !==
-1
Expand Down Expand Up @@ -788,6 +816,8 @@ export class EuiComboBox<T> extends Component<
prevState: EuiComboBoxState<T>
) {
const {
async,
isCaseSensitive,
options,
selectedOptions,
singleSelection,
Expand All @@ -797,14 +827,15 @@ export class EuiComboBox<T> extends Component<

// Calculate and cache the options which match the searchValue, because we use this information
// in multiple places and it would be expensive to calculate repeatedly.
const matchingOptions = getMatchingOptions(
const matchingOptions = getMatchingOptions({
options,
selectedOptions,
searchValue,
nextProps.async,
Boolean(singleSelection),
sortMatchesBy
);
isCaseSensitive,
isPreFiltered: async,
showPrevSelected: Boolean(singleSelection),
sortMatchesBy,
});

const stateUpdate: Partial<EuiComboBoxState<T>> = { matchingOptions };

Expand Down Expand Up @@ -873,14 +904,15 @@ export class EuiComboBox<T> extends Component<
// isn't called after a state change, and we track `searchValue` in state
// instead we need to react to a change in searchValue here
this.updateMatchingOptionsIfDifferent(
getMatchingOptions(
getMatchingOptions({
options,
selectedOptions,
searchValue,
this.props.async,
Boolean(singleSelection),
sortMatchesBy
)
isCaseSensitive: this.props.isCaseSensitive,
isPreFiltered: this.props.async,
showPrevSelected: Boolean(singleSelection),
sortMatchesBy,
})
);
}

Expand All @@ -898,6 +930,7 @@ export class EuiComboBox<T> extends Component<
fullWidth,
id,
inputRef,
isCaseSensitive,
isClearable,
isDisabled,
isInvalid,
Expand Down Expand Up @@ -977,6 +1010,7 @@ export class EuiComboBox<T> extends Component<
customOptionText={customOptionText}
data-test-subj={optionsListDataTestSubj}
fullWidth={fullWidth}
isCaseSensitive={isCaseSensitive}
isLoading={isLoading}
listRef={this.listRefCallback}
matchingOptions={matchingOptions}
Expand Down
Loading

0 comments on commit 9c8681c

Please sign in to comment.