Skip to content

Commit 9c8681c

Browse files
authored
[EuiComboBox] Optional case sensitive option matching (#6268)
* refactor API and introduce isCaseSensitive * refactor utils; add isCaseSensitiveProp * CL * docs * enforce highlight case sensitivity * account for more toLowerCase; new transform util
1 parent d5e5638 commit 9c8681c

8 files changed

Lines changed: 403 additions & 91 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import React, { useState } from 'react';
2+
3+
import { EuiComboBox } from '../../../../src/components';
4+
5+
export default () => {
6+
const [options, updateOptions] = useState([
7+
{
8+
label: 'Titan',
9+
'data-test-subj': 'titanOption',
10+
},
11+
{
12+
label: 'Enceladus is disabled',
13+
disabled: true,
14+
},
15+
{
16+
label: 'Mimas',
17+
},
18+
{
19+
label: 'Dione',
20+
},
21+
{
22+
label: 'Iapetus',
23+
},
24+
{
25+
label: 'Phoebe',
26+
},
27+
{
28+
label: 'Rhea',
29+
},
30+
{
31+
label:
32+
"Pandora is one of Saturn's moons, named for a Titaness of Greek mythology",
33+
},
34+
{
35+
label: 'Tethys',
36+
},
37+
{
38+
label: 'Hyperion',
39+
},
40+
]);
41+
42+
const [selectedOptions, setSelected] = useState([]);
43+
44+
const onChange = (selectedOptions) => {
45+
setSelected(selectedOptions);
46+
};
47+
48+
const onCreateOption = (searchValue, flattenedOptions) => {
49+
const normalizedSearchValue = searchValue.trim().toLowerCase();
50+
51+
if (!normalizedSearchValue) {
52+
return;
53+
}
54+
55+
const newOption = {
56+
label: searchValue,
57+
};
58+
59+
// Create the option if it doesn't exist.
60+
if (
61+
flattenedOptions.findIndex(
62+
(option) => option.label.trim().toLowerCase() === normalizedSearchValue
63+
) === -1
64+
) {
65+
updateOptions([...options, newOption]);
66+
}
67+
68+
// Select the option.
69+
setSelected((prevSelected) => [...prevSelected, newOption]);
70+
};
71+
72+
return (
73+
<EuiComboBox
74+
aria-label="Accessible screen reader label"
75+
placeholder="Select or create options"
76+
options={options}
77+
selectedOptions={selectedOptions}
78+
onChange={onChange}
79+
onCreateOption={onCreateOption}
80+
isClearable={true}
81+
isCaseSensitive
82+
/>
83+
);
84+
};

src-docs/src/views/combo_box/combo_box_example.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ const virtualizedSnippet = `<EuiComboBox
150150
onChange={onChange}
151151
/>`;
152152

153+
import CaseSensitive from './case_sensitive';
154+
const caseSensitiveSource = require('!!raw-loader!./case_sensitive');
155+
const caseSensitiveSnippet = `<EuiComboBox
156+
aria-label="Accessible screen reader label"
157+
placeholder="Select or create options"
158+
options={options}
159+
onChange={onChange}
160+
onCreateOption={onCreateOption}
161+
isCaseSensitive
162+
/>`;
163+
153164
import Disabled from './disabled';
154165
const disabledSource = require('!!raw-loader!./disabled');
155166
const disabledSnippet = `<EuiComboBox
@@ -269,6 +280,24 @@ export const ComboBoxExample = {
269280
snippet: disabledSnippet,
270281
demo: <Disabled />,
271282
},
283+
{
284+
title: 'Case-sensitive matching',
285+
source: [
286+
{
287+
type: GuideSectionTypes.JS,
288+
code: caseSensitiveSource,
289+
},
290+
],
291+
text: (
292+
<p>
293+
Set the prop <EuiCode>isCaseSensitive</EuiCode> to make the combo box
294+
option matching case sensitive.
295+
</p>
296+
),
297+
props: { EuiComboBox, EuiComboBoxOptionOption },
298+
snippet: caseSensitiveSnippet,
299+
demo: <CaseSensitive />,
300+
},
272301
{
273302
title: 'Virtualized',
274303
source: [

src/components/combo_box/combo_box.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,56 @@ describe('behavior', () => {
461461
});
462462
});
463463

464+
describe('isCaseSensitive', () => {
465+
const isCaseSensitiveOptions = [
466+
{
467+
label: 'Case sensitivity',
468+
},
469+
];
470+
471+
test('options "false"', () => {
472+
const component = mount<
473+
EuiComboBox<TitanOption>,
474+
EuiComboBoxProps<TitanOption>,
475+
{ matchingOptions: TitanOption[] }
476+
>(
477+
<EuiComboBox options={isCaseSensitiveOptions} isCaseSensitive={false} />
478+
);
479+
480+
findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
481+
target: { value: 'case' },
482+
});
483+
484+
expect(component.state('matchingOptions')[0].label).toBe(
485+
'Case sensitivity'
486+
);
487+
});
488+
489+
test('options "true"', () => {
490+
const component = mount<
491+
EuiComboBox<TitanOption>,
492+
EuiComboBoxProps<TitanOption>,
493+
{ matchingOptions: TitanOption[] }
494+
>(
495+
<EuiComboBox options={isCaseSensitiveOptions} isCaseSensitive={true} />
496+
);
497+
498+
findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
499+
target: { value: 'case' },
500+
});
501+
502+
expect(component.state('matchingOptions').length).toBe(0);
503+
504+
findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
505+
target: { value: 'Case' },
506+
});
507+
508+
expect(component.state('matchingOptions')[0].label).toBe(
509+
'Case sensitivity'
510+
);
511+
});
512+
});
513+
464514
it('calls the inputRef prop with the input element', () => {
465515
const inputRefCallback = jest.fn();
466516

src/components/combo_box/combo_box.tsx

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
getMatchingOptions,
2929
flattenOptionGroups,
3030
getSelectedOptionForSearchValue,
31+
transformForCaseSensitivity,
32+
SortMatchesBy,
3133
} from './matching_options';
3234
import {
3335
EuiComboBoxInputProps,
@@ -122,7 +124,11 @@ export interface _EuiComboBoxProps<T>
122124
* `startsWith`: moves items that start with search value to top of the list;
123125
* `none`: don't change the sort order of initial object
124126
*/
125-
sortMatchesBy: 'none' | 'startsWith';
127+
sortMatchesBy: SortMatchesBy;
128+
/**
129+
* Whether to match options with case sensitivity.
130+
*/
131+
isCaseSensitive?: boolean;
126132
/**
127133
* Creates an input group with element(s) coming before input. It won't show if `singleSelection` is set to `false`.
128134
* `string` | `ReactElement` or an array of these
@@ -211,14 +217,15 @@ export class EuiComboBox<T> extends Component<
211217
listElement: null,
212218
listPosition: 'bottom',
213219
listZIndex: undefined,
214-
matchingOptions: getMatchingOptions<T>(
215-
this.props.options,
216-
this.props.selectedOptions,
217-
initialSearchValue,
218-
this.props.async,
219-
Boolean(this.props.singleSelection),
220-
this.props.sortMatchesBy
221-
),
220+
matchingOptions: getMatchingOptions<T>({
221+
options: this.props.options,
222+
selectedOptions: this.props.selectedOptions,
223+
searchValue: initialSearchValue,
224+
isCaseSensitive: this.props.isCaseSensitive,
225+
isPreFiltered: this.props.async,
226+
showPrevSelected: Boolean(this.props.singleSelection),
227+
sortMatchesBy: this.props.sortMatchesBy,
228+
}),
222229
searchValue: initialSearchValue,
223230
width: 0,
224231
};
@@ -433,6 +440,7 @@ export class EuiComboBox<T> extends Component<
433440

434441
addCustomOption = (isContainerBlur: boolean, searchValue: string) => {
435442
const {
443+
isCaseSensitive,
436444
onCreateOption,
437445
options,
438446
selectedOptions,
@@ -456,7 +464,13 @@ export class EuiComboBox<T> extends Component<
456464
}
457465

458466
// Don't create the value if it's already been selected.
459-
if (getSelectedOptionForSearchValue(searchValue, selectedOptions)) {
467+
if (
468+
getSelectedOptionForSearchValue({
469+
isCaseSensitive,
470+
searchValue,
471+
selectedOptions,
472+
})
473+
) {
460474
return;
461475
}
462476

@@ -484,26 +498,40 @@ export class EuiComboBox<T> extends Component<
484498
if (this.state.matchingOptions.length !== 1) {
485499
return false;
486500
}
487-
return (
488-
this.state.matchingOptions[0].label.toLowerCase() ===
489-
searchValue.toLowerCase()
501+
const normalizedSearchSubject = transformForCaseSensitivity(
502+
this.state.matchingOptions[0].label,
503+
this.props.isCaseSensitive
490504
);
505+
const normalizedSearchValue = transformForCaseSensitivity(
506+
searchValue,
507+
this.props.isCaseSensitive
508+
);
509+
return normalizedSearchSubject === normalizedSearchValue;
491510
};
492511

493512
areAllOptionsSelected = () => {
494-
const { options, selectedOptions, async } = this.props;
513+
const { options, selectedOptions, async, isCaseSensitive } = this.props;
495514
// Assume if this is async then there could be infinite options.
496515
if (async) {
497516
return false;
498517
}
499518

500519
const flattenOptions = flattenOptionGroups(options).map((option) => {
501-
return { ...option, label: option.label.trim().toLowerCase() };
520+
return {
521+
...option,
522+
label: transformForCaseSensitivity(
523+
option.label.trim(),
524+
isCaseSensitive
525+
),
526+
};
502527
});
503528

504529
let numberOfSelectedOptions = 0;
505530
selectedOptions.forEach(({ label }) => {
506-
const trimmedLabel = label.trim().toLowerCase();
531+
const trimmedLabel = transformForCaseSensitivity(
532+
label.trim(),
533+
isCaseSensitive
534+
);
507535
if (
508536
flattenOptions.findIndex((option) => option.label === trimmedLabel) !==
509537
-1
@@ -788,6 +816,8 @@ export class EuiComboBox<T> extends Component<
788816
prevState: EuiComboBoxState<T>
789817
) {
790818
const {
819+
async,
820+
isCaseSensitive,
791821
options,
792822
selectedOptions,
793823
singleSelection,
@@ -797,14 +827,15 @@ export class EuiComboBox<T> extends Component<
797827

798828
// Calculate and cache the options which match the searchValue, because we use this information
799829
// in multiple places and it would be expensive to calculate repeatedly.
800-
const matchingOptions = getMatchingOptions(
830+
const matchingOptions = getMatchingOptions({
801831
options,
802832
selectedOptions,
803833
searchValue,
804-
nextProps.async,
805-
Boolean(singleSelection),
806-
sortMatchesBy
807-
);
834+
isCaseSensitive,
835+
isPreFiltered: async,
836+
showPrevSelected: Boolean(singleSelection),
837+
sortMatchesBy,
838+
});
808839

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

@@ -873,14 +904,15 @@ export class EuiComboBox<T> extends Component<
873904
// isn't called after a state change, and we track `searchValue` in state
874905
// instead we need to react to a change in searchValue here
875906
this.updateMatchingOptionsIfDifferent(
876-
getMatchingOptions(
907+
getMatchingOptions({
877908
options,
878909
selectedOptions,
879910
searchValue,
880-
this.props.async,
881-
Boolean(singleSelection),
882-
sortMatchesBy
883-
)
911+
isCaseSensitive: this.props.isCaseSensitive,
912+
isPreFiltered: this.props.async,
913+
showPrevSelected: Boolean(singleSelection),
914+
sortMatchesBy,
915+
})
884916
);
885917
}
886918

@@ -898,6 +930,7 @@ export class EuiComboBox<T> extends Component<
898930
fullWidth,
899931
id,
900932
inputRef,
933+
isCaseSensitive,
901934
isClearable,
902935
isDisabled,
903936
isInvalid,
@@ -977,6 +1010,7 @@ export class EuiComboBox<T> extends Component<
9771010
customOptionText={customOptionText}
9781011
data-test-subj={optionsListDataTestSubj}
9791012
fullWidth={fullWidth}
1013+
isCaseSensitive={isCaseSensitive}
9801014
isLoading={isLoading}
9811015
listRef={this.listRefCallback}
9821016
matchingOptions={matchingOptions}

0 commit comments

Comments
 (0)