Improve Combobox behavior by rendering the selectedItem in the prefix as text

This commit is contained in:
Andrey Okonetchnikov committed 2022-09-20 12:42:48 +02:00
1 parent ebe6adc781
commit 1c19a195cc
2 files changed
+77 -34

No files matched your search

@@ -8,8 +8,12 @@ export default {
};
export const Simple: ComponentStory<typeof Combobox> = () => {
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<typeof items[number] | undefined>();
return (
<Combobox
name="fruit"
@@ -26,7 +30,9 @@ export const CustomInput: ComponentStory<typeof Combobox> = () => {
{ label: "Banana", value: "banana" },
{ label: "Orange", value: "orange" },
];
const [value, setValue] = React.useState(items[0]);
const [value, setValue] = React.useState<typeof items[0] | undefined>(
items[0]
);
return (
<Combobox
@@ -1,7 +1,7 @@
import {
useState,
forwardRef,
useEffect,
useCallback,
type ComponentProps,
type ForwardRefRenderFunction,
} from "react";
@@ -64,7 +64,7 @@ const ListboxItem = styled("li", itemCss, {
type ListProps<Item> = {
containerProps: ComponentProps<typeof Listbox>;
items: Array<Item>;
items: ReadonlyArray<Item>;
getItemProps: (
options: UseComboboxGetItemPropsOptions<Item>
) => ComponentProps<typeof ListboxItem>;
@@ -113,11 +113,11 @@ export const ComboboxPopperContent = PopperContent;
type ComboboxProps<Item> = {
name: string;
label?: string;
items: Array<Item>;
items: ReadonlyArray<Item>;
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<typeof ComboboxTextField>
) => JSX.Element;
@@ -132,9 +132,7 @@ export const Combobox = <Item extends BaseItem>({
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) => <ComboboxTextField {...props} />,
@@ -143,6 +141,39 @@ export const Combobox = <Item extends BaseItem>({
renderPopperContent = (props) => <ComboboxPopperContent {...props} />,
}: ComboboxProps<Item>) => {
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 = <Item extends BaseItem>({
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 = <Item extends BaseItem>({
},
});
useEffect(() => {
if (isOpen === false) {
setFoundItems(items);
}
}, [isOpen, items]);
const inputProps: Record<string, unknown> = getInputProps({ name });
const inputProps: Record<string, unknown> = 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<string, unknown> = getToggleButtonProps();
const comboboxProps: Record<string, unknown> = getComboboxProps();
const menuProps: Record<string, unknown> = getMenuProps();
@@ -195,7 +228,11 @@ export const Combobox = <Item extends BaseItem>({
<Popper>
<Box {...comboboxProps}>
<PopperAnchor>
{renderTextField({ inputProps, toggleProps, highlightedItem })}
{renderTextField({
inputProps,
toggleProps,
highlightedItem,
})}
</PopperAnchor>
{renderPopperContent({
style: { zIndex: 1 },