diff --git a/.changeset/pretty-foxes-pull.md b/.changeset/pretty-foxes-pull.md new file mode 100644 index 000000000..126e54a8c --- /dev/null +++ b/.changeset/pretty-foxes-pull.md @@ -0,0 +1,5 @@ +--- +"@saleor/macaw-ui": minor +--- + +Combobox and DynamicCombobox now accepts new props that allow entering custom values diff --git a/src/components/Combobox/Common/useCombobox.tsx b/src/components/Combobox/Common/useCombobox.tsx index 892ebbc81..5643aa961 100644 --- a/src/components/Combobox/Common/useCombobox.tsx +++ b/src/components/Combobox/Common/useCombobox.tsx @@ -3,7 +3,7 @@ import { useCombobox as useDownshiftCombobox, UseComboboxGetInputPropsOptions, } from "downshift"; -import { FocusEvent, useState } from "react"; +import { FocusEvent, useState, KeyboardEvent } from "react"; import { Option, @@ -11,6 +11,11 @@ import { useHighlightedIndex, } from "~/components/BaseSelect"; +const CUSTOM_VALUE_SENTINEL = "__macaw_custom_value__"; + +export const isCustomValueOption = (item: Option): boolean => + item.value === CUSTOM_VALUE_SENTINEL; + const getItemsFilter = ( inputValue: string | undefined, options: T[] @@ -34,6 +39,8 @@ export const useCombobox = ({ onInputValueChange, onFocus, onBlur, + allowCustomValue, + onCustomValueSubmit, }: { selectedItem: T | null | undefined; options: T[]; @@ -42,12 +49,33 @@ export const useCombobox = ({ onInputValueChange?: (value: string) => void; onFocus?: (e: FocusEvent) => void; onBlur?: (e: FocusEvent) => void; + allowCustomValue?: boolean; + onCustomValueSubmit?: (value: string) => void; }) => { const [inputValue, setInputValue] = useState(""); const [active, setActive] = useState(false); const typed = Boolean(selectedItem || active || inputValue); - const itemsToSelect = getItemsFilter(inputValue, options); + const filteredItems = getItemsFilter(inputValue, options); + const hasFilteredItems = filteredItems.length > 0; + const trimmedInputValue = inputValue.trim(); + + const canSubmitCustomValue = + !!allowCustomValue && + !hasFilteredItems && + trimmedInputValue.length > 0 && + !!onCustomValueSubmit; + + const customValueOption = canSubmitCustomValue + ? ({ label: trimmedInputValue, value: CUSTOM_VALUE_SENTINEL } as T) + : null; + + const itemsToSelect = customValueOption + ? [...filteredItems, customValueOption] + : filteredItems; + + const hasItemsToSelect = itemsToSelect.length > 0; + const { highlightedIndex, onHighlightedIndexChange } = useHighlightedIndex( itemsToSelect, selectedItem @@ -55,6 +83,7 @@ export const useCombobox = ({ const { isOpen, + selectItem, getToggleButtonProps, getLabelProps, getMenuProps, @@ -62,6 +91,7 @@ export const useCombobox = ({ getItemProps, } = useDownshiftCombobox({ items: itemsToSelect, + inputValue, itemToString: (item) => item?.label ?? "", selectedItem, highlightedIndex, @@ -82,19 +112,26 @@ export const useCombobox = ({ } }, onSelectedItemChange: ({ selectedItem }) => { - if (selectedItem) { - const selectedValue = isValuePassedAsString - ? selectedItem.value - : selectedItem; + if (!selectedItem) return; + + if (selectedItem.value === CUSTOM_VALUE_SENTINEL) { + onCustomValueSubmit?.(trimmedInputValue); setInputValue(""); - onChange?.(selectedValue as V); + return; } + + const selectedValue = isValuePassedAsString + ? selectedItem.value + : selectedItem; + setInputValue(""); + onChange?.(selectedValue as V); }, }); return { active, itemsToSelect, + inputValue: trimmedInputValue, typed, isOpen, getToggleButtonProps, @@ -107,6 +144,7 @@ export const useCombobox = ({ _getInputProps<{ onFocus: (e: FocusEvent) => void; onBlur: (e: FocusEvent) => void; + onKeyDown: (e: KeyboardEvent) => void; }>( { onFocus: (e) => { @@ -117,12 +155,22 @@ export const useCombobox = ({ onBlur?.(e); setActive(false); }, + onKeyDown: (e) => { + if ( + e.key === "Enter" && + canSubmitCustomValue && + customValueOption && + highlightedIndex === -1 + ) { + selectItem(customValueOption); + } + }, ...options, }, otherOptions ), highlightedIndex, getItemProps, - hasItemsToSelect: itemsToSelect.length > 0, + hasItemsToSelect, }; }; diff --git a/src/components/Combobox/Dynamic/DynamicCombobox.stories.tsx b/src/components/Combobox/Dynamic/DynamicCombobox.stories.tsx index 26327a7f0..0f30db71c 100644 --- a/src/components/Combobox/Dynamic/DynamicCombobox.stories.tsx +++ b/src/components/Combobox/Dynamic/DynamicCombobox.stories.tsx @@ -1,4 +1,5 @@ import { Meta } from "@storybook/react"; +import { fn } from "@storybook/test"; import { useState } from "react"; import { DynamicCombobox } from ".."; @@ -177,3 +178,46 @@ export const NoOptions = () => { ); }; + +export const AllowCustomValue = () => { + const [options, setOptions] = useState([]); + const [value, setValue] = useState