From 1c19a195cc83e4a4e9b16555d8170f421ef0c6ab Mon Sep 17 00:00:00 2001 From: Andrey Okonetchnikov Date: Tue, 20 Sep 2022 12:42:48 +0200 Subject: [PATCH] Improve Combobox behavior by rendering the selectedItem in the prefix as text --- .../src/components/combobox.stories.tsx | 12 ++- .../design-system/src/components/combobox.tsx | 99 +++++++++++++------ 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/packages/design-system/src/components/combobox.stories.tsx b/packages/design-system/src/components/combobox.stories.tsx index b55a30a339..5ae0eff1c1 100644 --- a/packages/design-system/src/components/combobox.stories.tsx +++ b/packages/design-system/src/components/combobox.stories.tsx @@ -8,8 +8,12 @@ export default { }; export const Simple: ComponentStory = () => { - const items = [{ label: "Apple" }, { label: "Banana" }, { label: "Orange" }]; - const [value, setValue] = React.useState(items[0]); + const items = [ + { label: "Apple" }, + { label: "Banana" }, + { label: "Orange" }, + ] as const; + const [value, setValue] = React.useState(); return ( = () => { { label: "Banana", value: "banana" }, { label: "Orange", value: "orange" }, ]; - const [value, setValue] = React.useState(items[0]); + const [value, setValue] = React.useState( + items[0] + ); return ( = { containerProps: ComponentProps; - items: Array; + items: ReadonlyArray; getItemProps: ( options: UseComboboxGetItemPropsOptions ) => ComponentProps; @@ -113,11 +113,11 @@ export const ComboboxPopperContent = PopperContent; type ComboboxProps = { name: string; label?: string; - items: Array; + items: ReadonlyArray; value?: Item; - onItemSelect?: (value: Item) => void; + onItemSelect?: (value?: Item) => void; onItemHighlight?: (value?: Item) => void; - itemToString?: (item: Item | null) => string; + itemToString?: (item?: Item | null) => string; renderTextField?: ( props: ComponentProps ) => JSX.Element; @@ -132,9 +132,7 @@ export const Combobox = ({ value, name, itemToString = (item) => - typeof item === "object" && item !== null && "label" in item - ? item.label - : item ?? "", + item != null && "label" in item ? item.label : item ?? "", onItemSelect, onItemHighlight, renderTextField = (props) => , @@ -143,6 +141,39 @@ export const Combobox = ({ renderPopperContent = (props) => , }: ComboboxProps) => { const [foundItems, setFoundItems] = useState(items); + const stateReducer = useCallback((state, actionAndChanges) => { + const { type, changes } = actionAndChanges; + switch (type) { + // on item selection. + case useCombobox.stateChangeTypes.ItemClick: + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.InputBlur: + case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem: + return { + ...changes, + // if we had an item selected. + ...(changes.selectedItem && { + // we will set the input value to be empty since we display it using prefix + inputValue: "", + }), + }; + // Clear input value on escape + case useCombobox.stateChangeTypes.InputKeyDownEscape: + return { + ...changes, + inputValue: "", + selectedItem: null, + }; + // Reset selectedItem when there is selection and we type something + case useCombobox.stateChangeTypes.InputChange: + return { + ...changes, + selectedItem: null, + }; + default: + return changes; // otherwise business as usual. + } + }, []); const { isOpen, getToggleButtonProps, @@ -154,23 +185,20 @@ export const Combobox = ({ getItemProps, selectedItem, } = useCombobox({ - onInputValueChange({ inputValue }) { - if (inputValue) { - const options = - typeof items[0] === "object" && "label" in items[0] - ? { keys: ["label"] } - : undefined; - const foundItems = matchSorter(items, inputValue, options); - setFoundItems(foundItems); - } - }, - items: foundItems, - selectedItem: value, + items: foundItems as Item[], + selectedItem: value ?? null, // Avoid downshift warning about switching controlled mode + stateReducer, itemToString, + onInputValueChange({ inputValue }) { + const options = + typeof items[0] === "object" && "label" in items[0] + ? { keys: ["label"] } + : undefined; + const foundItems = matchSorter(items, inputValue ?? "", options); + setFoundItems(foundItems); + }, onSelectedItemChange({ selectedItem }) { - if (selectedItem) { - onItemSelect?.(selectedItem); - } + onItemSelect?.(selectedItem ?? undefined); }, onHighlightedIndexChange({ highlightedIndex }) { if (highlightedIndex !== undefined) { @@ -179,13 +207,18 @@ export const Combobox = ({ }, }); - useEffect(() => { - if (isOpen === false) { - setFoundItems(items); - } - }, [isOpen, items]); - - const inputProps: Record = getInputProps({ name }); + const inputProps: Record = getInputProps({ + name, + onKeyDown: (event) => { + // When we press Backspace and the input is empty, + // we should to clear the selection + // @todo: it would be much better to handle this in reducer but downshift doesn't handle this event + if (event.key === "Backspace" && selectedItem !== null) { + onItemSelect?.(undefined); + } + }, + prefix: itemToString?.(selectedItem ?? undefined), + }); const toggleProps: Record = getToggleButtonProps(); const comboboxProps: Record = getComboboxProps(); const menuProps: Record = getMenuProps(); @@ -195,7 +228,11 @@ export const Combobox = ({ - {renderTextField({ inputProps, toggleProps, highlightedItem })} + {renderTextField({ + inputProps, + toggleProps, + highlightedItem, + })} {renderPopperContent({ style: { zIndex: 1 },