diff --git a/src-docs/src/views/combo_box/case_sensitive.js b/src-docs/src/views/combo_box/case_sensitive.js
new file mode 100644
index 00000000000..06d48a5a338
--- /dev/null
+++ b/src-docs/src/views/combo_box/case_sensitive.js
@@ -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 (
+
+ );
+};
diff --git a/src-docs/src/views/combo_box/combo_box_example.js b/src-docs/src/views/combo_box/combo_box_example.js
index 9bf03d5f9ff..71b72e196f0 100644
--- a/src-docs/src/views/combo_box/combo_box_example.js
+++ b/src-docs/src/views/combo_box/combo_box_example.js
@@ -150,6 +150,17 @@ const virtualizedSnippet = ``;
+import CaseSensitive from './case_sensitive';
+const caseSensitiveSource = require('!!raw-loader!./case_sensitive');
+const caseSensitiveSnippet = ``;
+
import Disabled from './disabled';
const disabledSource = require('!!raw-loader!./disabled');
const disabledSnippet = `,
},
+ {
+ title: 'Case-sensitive matching',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: caseSensitiveSource,
+ },
+ ],
+ text: (
+
+ Set the prop isCaseSensitive to make the combo box
+ option matching case sensitive.
+
+ ),
+ props: { EuiComboBox, EuiComboBoxOptionOption },
+ snippet: caseSensitiveSnippet,
+ demo: ,
+ },
{
title: 'Virtualized',
source: [
diff --git a/src/components/combo_box/combo_box.test.tsx b/src/components/combo_box/combo_box.test.tsx
index c0c30df8bb4..3c702beb65c 100644
--- a/src/components/combo_box/combo_box.test.tsx
+++ b/src/components/combo_box/combo_box.test.tsx
@@ -461,6 +461,56 @@ describe('behavior', () => {
});
});
+ describe('isCaseSensitive', () => {
+ const isCaseSensitiveOptions = [
+ {
+ label: 'Case sensitivity',
+ },
+ ];
+
+ test('options "false"', () => {
+ const component = mount<
+ EuiComboBox,
+ EuiComboBoxProps,
+ { matchingOptions: TitanOption[] }
+ >(
+
+ );
+
+ findTestSubject(component, 'comboBoxSearchInput').simulate('change', {
+ target: { value: 'case' },
+ });
+
+ expect(component.state('matchingOptions')[0].label).toBe(
+ 'Case sensitivity'
+ );
+ });
+
+ test('options "true"', () => {
+ const component = mount<
+ EuiComboBox,
+ EuiComboBoxProps,
+ { matchingOptions: TitanOption[] }
+ >(
+
+ );
+
+ 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();
diff --git a/src/components/combo_box/combo_box.tsx b/src/components/combo_box/combo_box.tsx
index 99d13d10855..4b6803b7321 100644
--- a/src/components/combo_box/combo_box.tsx
+++ b/src/components/combo_box/combo_box.tsx
@@ -28,6 +28,8 @@ import {
getMatchingOptions,
flattenOptionGroups,
getSelectedOptionForSearchValue,
+ transformForCaseSensitivity,
+ SortMatchesBy,
} from './matching_options';
import {
EuiComboBoxInputProps,
@@ -122,7 +124,11 @@ export interface _EuiComboBoxProps
* `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
@@ -211,14 +217,15 @@ export class EuiComboBox extends Component<
listElement: null,
listPosition: 'bottom',
listZIndex: undefined,
- matchingOptions: getMatchingOptions(
- this.props.options,
- this.props.selectedOptions,
- initialSearchValue,
- this.props.async,
- Boolean(this.props.singleSelection),
- this.props.sortMatchesBy
- ),
+ matchingOptions: getMatchingOptions({
+ 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,
};
@@ -433,6 +440,7 @@ export class EuiComboBox extends Component<
addCustomOption = (isContainerBlur: boolean, searchValue: string) => {
const {
+ isCaseSensitive,
onCreateOption,
options,
selectedOptions,
@@ -456,7 +464,13 @@ export class EuiComboBox extends Component<
}
// Don't create the value if it's already been selected.
- if (getSelectedOptionForSearchValue(searchValue, selectedOptions)) {
+ if (
+ getSelectedOptionForSearchValue({
+ isCaseSensitive,
+ searchValue,
+ selectedOptions,
+ })
+ ) {
return;
}
@@ -484,26 +498,40 @@ export class EuiComboBox 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
@@ -788,6 +816,8 @@ export class EuiComboBox extends Component<
prevState: EuiComboBoxState
) {
const {
+ async,
+ isCaseSensitive,
options,
selectedOptions,
singleSelection,
@@ -797,14 +827,15 @@ export class EuiComboBox 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> = { matchingOptions };
@@ -873,14 +904,15 @@ export class EuiComboBox 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,
+ })
);
}
@@ -898,6 +930,7 @@ export class EuiComboBox extends Component<
fullWidth,
id,
inputRef,
+ isCaseSensitive,
isClearable,
isDisabled,
isInvalid,
@@ -977,6 +1010,7 @@ export class EuiComboBox extends Component<
customOptionText={customOptionText}
data-test-subj={optionsListDataTestSubj}
fullWidth={fullWidth}
+ isCaseSensitive={isCaseSensitive}
isLoading={isLoading}
listRef={this.listRefCallback}
matchingOptions={matchingOptions}
diff --git a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx
index 50e4c6f960d..d187538d50d 100644
--- a/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx
+++ b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx
@@ -56,10 +56,12 @@ export type EuiComboBoxOptionsListProps = CommonProps &
*/
customOptionText?: string;
fullWidth?: boolean;
- getSelectedOptionForSearchValue?: (
- searchValue: string,
- selectedOptions: any[]
- ) => EuiComboBoxOptionOption | undefined;
+ getSelectedOptionForSearchValue?: (params: {
+ isCaseSensitive?: boolean;
+ searchValue: string;
+ selectedOptions: any[];
+ }) => EuiComboBoxOptionOption | undefined;
+ isCaseSensitive?: boolean;
isLoading?: boolean;
listRef: RefCallback;
matchingOptions: Array>;
@@ -113,6 +115,7 @@ export class EuiComboBoxOptionsList extends Component<
static defaultProps = {
'data-test-subj': '',
rowHeight: 29, // row height of default option renderer
+ isCaseSensitive: false,
};
updatePosition = () => {
@@ -218,7 +221,7 @@ export class EuiComboBoxOptionsList extends Component<
if (isGroupLabelOption) {
return (
-
+
{label}
);
@@ -241,7 +244,7 @@ export class EuiComboBoxOptionsList
extends Component<
return (
{
if (onOptionClick) {
onOptionClick(option);
@@ -267,6 +270,7 @@ export class EuiComboBoxOptionsList extends Component<
) : (
{label}
@@ -286,6 +290,7 @@ export class EuiComboBoxOptionsList extends Component<
customOptionText,
fullWidth,
getSelectedOptionForSearchValue,
+ isCaseSensitive,
isLoading,
listRef,
matchingOptions,
@@ -345,10 +350,11 @@ export class EuiComboBoxOptionsList extends Component<
);
} else {
- const selectedOptionForValue = getSelectedOptionForSearchValue(
+ const selectedOptionForValue = getSelectedOptionForSearchValue({
+ isCaseSensitive,
searchValue,
- selectedOptions
- );
+ selectedOptions,
+ });
if (selectedOptionForValue) {
// Disallow duplicate custom options.
emptyStateContent = (
diff --git a/src/components/combo_box/matching_options.test.ts b/src/components/combo_box/matching_options.test.ts
index 77f077792b0..29d2dad2367 100644
--- a/src/components/combo_box/matching_options.test.ts
+++ b/src/components/combo_box/matching_options.test.ts
@@ -8,6 +8,7 @@
import { EuiComboBoxOptionOption } from './types';
import {
+ SortMatchesBy,
flattenOptionGroups,
getMatchingOptions,
getSelectedOptionForSearchValue,
@@ -64,7 +65,10 @@ describe('getSelectedOptionForSearchValue', () => {
'data-test-subj': 'saturnOption',
};
// Act
- const got = getSelectedOptionForSearchValue('saturn', options);
+ const got = getSelectedOptionForSearchValue({
+ searchValue: 'saturn',
+ selectedOptions: options,
+ });
// Assert
expect(got).toMatchObject(expected);
});
@@ -73,7 +77,10 @@ describe('getSelectedOptionForSearchValue', () => {
describe('getSelectedOptionForSearchValue', () => {
test('returns undefined when no matching option found for search value', () => {
// Act
- const got = getSelectedOptionForSearchValue('Pluto', options);
+ const got = getSelectedOptionForSearchValue({
+ searchValue: 'Pluto',
+ selectedOptions: options,
+ });
// Assert
expect(got).toBeUndefined();
});
@@ -84,7 +91,10 @@ describe('getSelectedOptionForSearchValue', () => {
'data-test-subj': 'saturnOption',
};
// Act
- const got = getSelectedOptionForSearchValue('saturn', options);
+ const got = getSelectedOptionForSearchValue({
+ searchValue: 'saturn',
+ selectedOptions: options,
+ });
// Assert
expect(got).toMatchObject(expected);
});
@@ -92,12 +102,13 @@ describe('getSelectedOptionForSearchValue', () => {
interface GetMatchingOptionsTestCase {
expected: EuiComboBoxOptionOption[];
+ isCaseSensitive: boolean;
isPreFiltered: boolean;
options: EuiComboBoxOptionOption[];
searchValue: string;
selectedOptions: EuiComboBoxOptionOption[];
showPrevSelected: boolean;
- sortMatchesBy: string;
+ sortMatchesBy: SortMatchesBy;
}
const testCases: GetMatchingOptionsTestCase[] = [
@@ -110,6 +121,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
},
],
searchValue: 'saturn',
+ isCaseSensitive: false,
isPreFiltered: false,
showPrevSelected: false,
expected: [],
@@ -124,6 +136,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
},
],
searchValue: 'saturn',
+ isCaseSensitive: false,
isPreFiltered: true,
showPrevSelected: false,
expected: [
@@ -141,6 +154,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
},
],
searchValue: 'saturn',
+ isCaseSensitive: false,
isPreFiltered: false,
showPrevSelected: true,
expected: [{ 'data-test-subj': 'saturnOption', label: 'Saturn' }],
@@ -155,6 +169,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
},
],
searchValue: 'saturn',
+ isCaseSensitive: false,
isPreFiltered: true,
showPrevSelected: true,
expected: [
@@ -172,6 +187,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
},
],
searchValue: 'titan',
+ isCaseSensitive: false,
isPreFiltered: true,
showPrevSelected: false,
expected: [
@@ -191,6 +207,7 @@ const testCases: GetMatchingOptionsTestCase[] = [
},
],
searchValue: 'titan',
+ isCaseSensitive: false,
isPreFiltered: true,
showPrevSelected: false,
expected: [
@@ -199,22 +216,55 @@ const testCases: GetMatchingOptionsTestCase[] = [
],
sortMatchesBy: 'none',
},
+ // Case sensitivity
+ {
+ options,
+ selectedOptions: [],
+ searchValue: 'saturn',
+ isCaseSensitive: false,
+ isPreFiltered: false,
+ showPrevSelected: false,
+ expected: [
+ {
+ label: 'Saturn',
+ 'data-test-subj': 'saturnOption',
+ },
+ ],
+ sortMatchesBy: 'none',
+ },
+ {
+ options,
+ selectedOptions: [],
+ searchValue: 'saturn',
+ isCaseSensitive: true,
+ isPreFiltered: false,
+ showPrevSelected: false,
+ expected: [],
+ sortMatchesBy: 'none',
+ },
+ {
+ options,
+ selectedOptions: [],
+ searchValue: 'Saturn',
+ isCaseSensitive: true,
+ isPreFiltered: false,
+ showPrevSelected: false,
+ expected: [
+ {
+ label: 'Saturn',
+ 'data-test-subj': 'saturnOption',
+ },
+ ],
+ sortMatchesBy: 'none',
+ },
];
describe('getMatchingOptions', () => {
test.each(testCases)(
'.getMatchingOptions(%o)',
(testCase: typeof testCases[number]) => {
- expect(
- getMatchingOptions(
- testCase.options,
- testCase.selectedOptions,
- testCase.searchValue,
- testCase.isPreFiltered,
- testCase.showPrevSelected,
- testCase.sortMatchesBy
- )
- ).toMatchObject(testCase.expected);
+ const { expected, ...rest } = testCase;
+ expect(getMatchingOptions(rest)).toMatchObject(expected);
}
);
});
diff --git a/src/components/combo_box/matching_options.ts b/src/components/combo_box/matching_options.ts
index 38c690f7d7e..2ef3095994a 100644
--- a/src/components/combo_box/matching_options.ts
+++ b/src/components/combo_box/matching_options.ts
@@ -8,6 +8,39 @@
import { EuiComboBoxOptionOption } from './types';
+export type SortMatchesBy = 'none' | 'startsWith';
+interface GetMatchingOptions {
+ options: Array>;
+ selectedOptions: Array>;
+ searchValue: string;
+ isCaseSensitive?: boolean;
+ isPreFiltered?: boolean;
+ showPrevSelected?: boolean;
+ sortMatchesBy?: SortMatchesBy;
+}
+interface CollectMatchingOption
+ extends Pick<
+ GetMatchingOptions,
+ 'isCaseSensitive' | 'isPreFiltered' | 'showPrevSelected'
+ > {
+ accumulator: Array>;
+ option: EuiComboBoxOptionOption;
+ selectedOptions: Array>;
+ normalizedSearchValue: string;
+}
+interface GetSelectedOptionForSearchValue
+ extends Pick<
+ GetMatchingOptions,
+ 'isCaseSensitive' | 'searchValue' | 'selectedOptions'
+ > {
+ optionKey?: string;
+}
+
+export const transformForCaseSensitivity = (
+ string: string,
+ isCaseSensitive?: boolean
+) => (isCaseSensitive ? string : string.toLowerCase());
+
export const flattenOptionGroups = (
optionsOrGroups: Array>
) => {
@@ -27,33 +60,44 @@ export const flattenOptionGroups = (
);
};
-export const getSelectedOptionForSearchValue = (
- searchValue: string,
- selectedOptions: Array>,
- optionKey?: string
-) => {
- const normalizedSearchValue = searchValue.toLowerCase();
- return selectedOptions.find(
- (option) =>
- option.label.toLowerCase() === normalizedSearchValue &&
- (!optionKey || option.key === optionKey)
+export const getSelectedOptionForSearchValue = ({
+ isCaseSensitive,
+ searchValue,
+ selectedOptions,
+ optionKey,
+}: GetSelectedOptionForSearchValue) => {
+ const normalizedSearchValue = transformForCaseSensitivity(
+ searchValue,
+ isCaseSensitive
);
+ return selectedOptions.find((option) => {
+ const normalizedOption = transformForCaseSensitivity(
+ option.label,
+ isCaseSensitive
+ );
+ return (
+ normalizedOption === normalizedSearchValue &&
+ (!optionKey || option.key === optionKey)
+ );
+ });
};
-const collectMatchingOption = (
- accumulator: Array>,
- option: EuiComboBoxOptionOption,
- selectedOptions: Array>,
- normalizedSearchValue: string,
- isPreFiltered: boolean,
- showPrevSelected: boolean
-) => {
+const collectMatchingOption = ({
+ accumulator,
+ option,
+ selectedOptions,
+ normalizedSearchValue,
+ isCaseSensitive,
+ isPreFiltered,
+ showPrevSelected,
+}: CollectMatchingOption) => {
// Only show options which haven't yet been selected unless requested.
- const selectedOption = getSelectedOptionForSearchValue(
- option.label,
+ const selectedOption = getSelectedOptionForSearchValue({
+ isCaseSensitive,
+ searchValue: option.label,
selectedOptions,
- option.key
- );
+ optionKey: option.key,
+ });
if (selectedOption && !showPrevSelected) {
return false;
}
@@ -69,35 +113,43 @@ const collectMatchingOption = (
return;
}
- const normalizedOption = option.label.trim().toLowerCase();
+ const normalizedOption = transformForCaseSensitivity(
+ option.label.trim(),
+ isCaseSensitive
+ );
if (normalizedOption.includes(normalizedSearchValue)) {
accumulator.push(option);
}
};
-export const getMatchingOptions = (
- options: Array>,
- selectedOptions: Array>,
- searchValue: string,
- isPreFiltered: boolean,
- showPrevSelected: boolean,
- sortMatchesBy: string
-) => {
- const normalizedSearchValue = searchValue.trim().toLowerCase();
+export const getMatchingOptions = ({
+ options,
+ selectedOptions,
+ searchValue,
+ isCaseSensitive = false,
+ isPreFiltered = false,
+ showPrevSelected = false,
+ sortMatchesBy = 'none',
+}: GetMatchingOptions) => {
+ const normalizedSearchValue = transformForCaseSensitivity(
+ searchValue.trim(),
+ isCaseSensitive
+ );
let matchingOptions: Array> = [];
options.forEach((option) => {
if (option.options) {
const matchingOptionsForGroup: Array> = [];
option.options.forEach((groupOption: EuiComboBoxOptionOption) => {
- collectMatchingOption(
- matchingOptionsForGroup,
- groupOption,
+ collectMatchingOption({
+ accumulator: matchingOptionsForGroup,
+ option: groupOption,
selectedOptions,
normalizedSearchValue,
+ isCaseSensitive,
isPreFiltered,
- showPrevSelected
- );
+ showPrevSelected,
+ });
});
if (matchingOptionsForGroup.length > 0) {
// Add option for group label
@@ -111,14 +163,15 @@ export const getMatchingOptions = (
matchingOptions = matchingOptions.concat(matchingOptionsForGroup);
}
} else {
- collectMatchingOption(
- matchingOptions,
+ collectMatchingOption({
+ accumulator: matchingOptions,
option,
selectedOptions,
normalizedSearchValue,
+ isCaseSensitive,
isPreFiltered,
- showPrevSelected
- );
+ showPrevSelected,
+ });
}
});
@@ -129,7 +182,11 @@ export const getMatchingOptions = (
} = { startWith: [], others: [] };
matchingOptions.forEach((object) => {
- if (object.label.toLowerCase().startsWith(normalizedSearchValue)) {
+ const normalizedLabel = transformForCaseSensitivity(
+ object.label,
+ isCaseSensitive
+ );
+ if (normalizedLabel.startsWith(normalizedSearchValue)) {
refObj.startWith.push(object);
} else {
refObj.others.push(object);
diff --git a/upcoming_changelogs/6268.md b/upcoming_changelogs/6268.md
new file mode 100644
index 00000000000..a3041b03747
--- /dev/null
+++ b/upcoming_changelogs/6268.md
@@ -0,0 +1,2 @@
+- Added optional case sensitive option matching to `EuiComboBox` with the `isCaseSensitive` prop
+