Skip to content

Commit 384dca0

Browse files
feat(Combobox): allow custom value (#14935)
* feat(Combobox): add allowCustomValue prop * test(Combobox): add tests to cover new functionality * docs(Combobox): add some docs around the new prop * docs(Combobox): add test story * test(snapshot): update snapshots * chore(storybook): remove commented out code --------- Co-authored-by: Taylor Jones <tay1orjones@users.noreply.github.com>
1 parent e30238b commit 384dca0

File tree

5 files changed

+93
-2
lines changed

5 files changed

+93
-2
lines changed

packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,9 @@ Map {
10541054
"ComboBox" => Object {
10551055
"$$typeof": Symbol(react.forward_ref),
10561056
"propTypes": Object {
1057+
"allowCustomValue": Object {
1058+
"type": "bool",
1059+
},
10571060
"aria-label": Object {
10581061
"type": "string",
10591062
},

packages/react/src/components/ComboBox/ComboBox-test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,21 @@ describe('ComboBox', () => {
101101
});
102102
});
103103

104+
it('should retain value if custom value is entered and `allowCustomValue` is set', async () => {
105+
render(<ComboBox {...mockProps} allowCustomValue />);
106+
107+
expect(findInputNode()).toHaveDisplayValue('');
108+
109+
await userEvent.type(findInputNode(), 'Apple');
110+
// Should close menu and keep value in input, even though it is not in the item list
111+
await userEvent.keyboard('[Enter]');
112+
assertMenuClosed();
113+
expect(findInputNode()).toHaveDisplayValue('Apple');
114+
// Should retain value on blur
115+
await userEvent.keyboard('[Tab]');
116+
expect(findInputNode()).toHaveDisplayValue('Apple');
117+
});
118+
104119
describe('should display initially selected item found in `initialSelectedItem`', () => {
105120
it('using an object type for the `initialSelectedItem` prop', () => {
106121
render(

packages/react/src/components/ComboBox/ComboBox.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import ComboBox from '../ComboBox';
2121
- [itemToElement](#itemtoelement)
2222
- [itemToString](#itemtostring)
2323
- [shouldFilterItem](#shouldfilteritem)
24+
- [allowCustomValue](#allowcustomvalue)
2425
- [With Layer](#with-layer)
2526
- [Feedback](#feedback)
2627

@@ -147,6 +148,19 @@ const filterItems = (menu) => {
147148
/>;
148149
```
149150
151+
## `allowCustomValue`
152+
153+
By default, if text is entered into the `Combobox` and it does not match an
154+
item, it will be cleared on blur. However, you can change this behavior by
155+
passing in the `allowCustomValue` prop. This will allow a user to close the menu
156+
and accept a custom value by pressing `Enter` as well as retain the value on
157+
blur. The `inputValue` is provided as a second argument to the `onChange`
158+
callback.
159+
160+
```js
161+
{selectedItem: undefined, inputValue: 'Apple'}
162+
```
163+
150164
## With Layer
151165
152166
<Canvas>

packages/react/src/components/ComboBox/ComboBox.stories.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,32 @@ export const Default = () => (
7979
</div>
8080
);
8181

82+
export const AllowCustomValue = () => {
83+
const filterItems = (menu) => {
84+
return menu?.item?.toLowerCase().includes(menu?.inputValue?.toLowerCase());
85+
};
86+
return (
87+
<div style={{ width: 300 }}>
88+
<ComboBox
89+
allowCustomValue
90+
shouldFilterItem={filterItems}
91+
onChange={(e) => {
92+
console.log(e);
93+
}}
94+
id="carbon-combobox"
95+
items={['Apple', 'Orange', 'Banana', 'Pineapple', 'Raspberry', 'Lime']}
96+
downshiftProps={{
97+
onStateChange: () => {
98+
console.log('the state has changed');
99+
},
100+
}}
101+
titleText="ComboBox title"
102+
helperText="Combobox helper text"
103+
/>
104+
</div>
105+
);
106+
};
107+
82108
export const _WithLayer = () => (
83109
<WithLayer>
84110
{(layer) => (

packages/react/src/components/ComboBox/ComboBox.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const {
5555
clickButton,
5656
blurButton,
5757
changeInput,
58+
blurInput,
5859
} = Downshift.stateChangeTypes;
5960

6061
const defaultItemToString = <ItemType,>(item: ItemType | null) => {
@@ -127,13 +128,20 @@ const getInstanceId = setupGetInstanceId();
127128
type ExcludedAttributes = 'id' | 'onChange' | 'onClick' | 'type' | 'size';
128129

129130
interface OnChangeData<ItemType> {
130-
selectedItem: ItemType | null;
131+
selectedItem: ItemType | null | undefined;
132+
inputValue?: string | null;
131133
}
132134

133135
type ItemToStringHandler<ItemType> = (item: ItemType | null) => string;
134136

135137
export interface ComboBoxProps<ItemType>
136138
extends Omit<InputHTMLAttributes<HTMLInputElement>, ExcludedAttributes> {
139+
/**
140+
* Specify whether or not the ComboBox should allow a value that is
141+
* not in the list to be entered in the input
142+
*/
143+
allowCustomValue?: boolean;
144+
137145
/**
138146
* Specify a label to be read by screen readers on the container node
139147
* 'aria-label' of the ListBox component.
@@ -329,6 +337,7 @@ const ComboBox = forwardRef(
329337
translateWithId,
330338
warn,
331339
warnText,
340+
allowCustomValue = false,
332341
...rest
333342
} = props;
334343
const prefix = usePrefix();
@@ -447,6 +456,14 @@ const ComboBox = forwardRef(
447456
case changeInput:
448457
updateHighlightedIndex(getHighlightedIndex(changes));
449458
break;
459+
case blurInput:
460+
if (allowCustomValue) {
461+
setInputValue(inputValue);
462+
if (onChange) {
463+
onChange({ selectedItem, inputValue });
464+
}
465+
}
466+
break;
450467
}
451468
};
452469

@@ -571,8 +588,18 @@ const ComboBox = forwardRef(
571588
event.stopPropagation();
572589
}
573590

574-
if (match(event, keys.Enter) && !inputValue) {
591+
if (
592+
match(event, keys.Enter) &&
593+
(!inputValue || allowCustomValue)
594+
) {
575595
toggleMenu();
596+
597+
// Since `onChange` does not normally fire when the menu is closed, we should
598+
// manually fire it when `allowCustomValue` is provided, the menu is closing,
599+
// and there is a value.
600+
if (allowCustomValue && isOpen && inputValue) {
601+
onChange({ selectedItem, inputValue });
602+
}
576603
}
577604

578605
if (match(event, keys.Escape) && inputValue) {
@@ -744,6 +771,12 @@ const ComboBox = forwardRef(
744771

745772
ComboBox.displayName = 'ComboBox';
746773
ComboBox.propTypes = {
774+
/**
775+
* Specify whether or not the ComboBox should allow a value that is
776+
* not in the list to be entered in the input
777+
*/
778+
allowCustomValue: PropTypes.bool,
779+
747780
/**
748781
* 'aria-label' of the ListBox component.
749782
* Specify a label to be read by screen readers on the container node

0 commit comments

Comments
 (0)