diff --git a/package-lock.json b/package-lock.json index cf31a4d65..7a67c8b63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2399,6 +2399,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/packages/components/combobox/src/Combobox.doc.mdx b/packages/components/combobox/src/Combobox.doc.mdx index c3f22f306..481a6e2b2 100644 --- a/packages/components/combobox/src/Combobox.doc.mdx +++ b/packages/components/combobox/src/Combobox.doc.mdx @@ -99,33 +99,31 @@ AutoSuggest is used as the default behaviour. The user can type anything in the -### Disabled +### Custom filtering -Use `disabled` on the root component to disable the combobox entirely. +Disable `autoFilter` to implement your own logic to filter out items depending on the inputValue or some external logic. - - -### Disabled Item +This example showcases case-sensitive filtering. -Use `disabled` on individual `Combobox.Item` to disable them. + - +### Custom value entry -### Filtering - AutoFilter +Combo Box can be configured to allow entering custom values that aren’t included in the list of options. -Use `autoFilter` to filter out items that does not match the input value. This behaviour is not case-sensitive. + -For more custom filtering, logic should be done on call-site to render only desired items. +### Disabled - +Use `disabled` on the root component to disable the combobox entirely. -### Filtering - Manual + -Use your own logic to filter out items depending on the inputValue or some external logic. +### Disabled Item -This example showcases case-sensitive filtering. +Use `disabled` on individual `Combobox.Item` to disable them. - + ### Groups diff --git a/packages/components/combobox/src/Combobox.stories.tsx b/packages/components/combobox/src/Combobox.stories.tsx index ff7cab410..1015bd0dd 100644 --- a/packages/components/combobox/src/Combobox.stories.tsx +++ b/packages/components/combobox/src/Combobox.stories.tsx @@ -71,6 +71,7 @@ export const Default: StoryFn = _args => { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -102,6 +103,7 @@ export const Controlled: StoryFn = () => { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -137,6 +139,7 @@ export const ControlledOpenState: StoryFn = () => { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -161,6 +164,7 @@ export const CustomItem: StoryFn = _args => { + No results found To Kill a Mockingbird New @@ -192,16 +196,18 @@ export const CustomItem: StoryFn = _args => { ) } -export const Disabled: StoryFn = _args => { +export const CustomValueEntry: StoryFn = _args => { return (
- + + + No results found To Kill a Mockingbird War and Peace The Idiot @@ -215,19 +221,10 @@ export const Disabled: StoryFn = _args => { ) } -export const FilteringAutoFilter: StoryFn = _args => { - const items = { - 'book-1': 'To Kill a Mockingbird', - 'book-2': 'War and Peace', - 'book-3': 'The Idiot', - 'book-4': 'A Picture of Dorian Gray', - 'book-5': '1984', - 'book-6': 'Pride and Prejudice', - } - +export const Disabled: StoryFn = _args => { return (
- + @@ -235,11 +232,12 @@ export const FilteringAutoFilter: StoryFn = _args => { No results found - {Object.entries(items).map(([value, text]) => ( - - {text} - - ))} + To Kill a Mockingbird + War and Peace + The Idiot + A Picture of Dorian Gray + 1984 + Pride and Prejudice @@ -260,7 +258,7 @@ export const FilteringManual: StoryFn = () => { return (
- + { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -322,6 +321,7 @@ export const DisabledItem: StoryFn = _args => { + No results found To Kill a Mockingbird War and Peace @@ -340,12 +340,13 @@ export const DisabledItem: StoryFn = _args => { export const Grouped: StoryFn = _args => { return (
- + + No results found Best-sellers To Kill a Mockingbird @@ -378,6 +379,7 @@ export const LeadingIcon: StoryFn = _args => { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -403,6 +405,7 @@ export const ItemIndicator: StoryFn = _args => { + No results found To Kill a Mockingbird @@ -450,6 +453,7 @@ export const Statuses: StoryFn = () => { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -468,7 +472,7 @@ export const Statuses: StoryFn = () => { export const MultipleSelection: StoryFn = _args => { return (
- + @@ -503,7 +507,7 @@ export const MultipleSelectionControlled: StoryFn = () => { return (
- + { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -568,6 +573,7 @@ export const FormFieldHiddenLabel: StoryFn = _args => { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -594,6 +600,7 @@ export const FormFieldReadOnly: StoryFn = _args => { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -619,6 +626,7 @@ export const FormFieldDisabled: StoryFn = _args => { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -644,6 +652,7 @@ export const FormFieldRequired: StoryFn = _args => { + No results found To Kill a Mockingbird War and Peace The Idiot @@ -675,6 +684,7 @@ export const FormFieldValidation: StoryFn = () => { + No results found default success alert diff --git a/packages/components/combobox/src/Combobox.tsx b/packages/components/combobox/src/Combobox.tsx index 6bff53235..e65cba058 100644 --- a/packages/components/combobox/src/Combobox.tsx +++ b/packages/components/combobox/src/Combobox.tsx @@ -4,13 +4,20 @@ export type ComboboxProps = ComboboxContextProps export const Combobox = ({ children, - autoFilter = false, + autoFilter = true, disabled = false, readOnly = false, + allowCustomValue = false, ...props }: ComboboxProps) => { return ( - + {children} ) diff --git a/packages/components/combobox/src/ComboboxContext.tsx b/packages/components/combobox/src/ComboboxContext.tsx index f2e9e0517..de15112aa 100644 --- a/packages/components/combobox/src/ComboboxContext.tsx +++ b/packages/components/combobox/src/ComboboxContext.tsx @@ -61,6 +61,10 @@ export type ComboboxContextCommonProps = PropsWithChildren<{ * When true, the items will be filtered depending on the value of the input (not case-sensitive). */ autoFilter?: boolean + /** + * By default, the combobox will clear or restore the input value to the selected item value on blur. + */ + allowCustomValue?: boolean }> interface ComboboxPropsSingle { @@ -126,6 +130,7 @@ export const ComboboxProvider = ({ multiple = false, disabled: disabledProp = false, readOnly: readOnlyProp = false, + allowCustomValue = false, state: stateProp, }: ComboboxContextProps) => { // Input state @@ -172,6 +177,7 @@ export const ComboboxProvider = ({ id, labelId, inputValue, + allowCustomValue, setInputValue: handleDownshiftInputChange, onInputValueChange, filteredItems: filteredItemsMap, diff --git a/packages/components/combobox/src/ComboboxInput.tsx b/packages/components/combobox/src/ComboboxInput.tsx index f2a7b37b8..fb8fa7e83 100644 --- a/packages/components/combobox/src/ComboboxInput.tsx +++ b/packages/components/combobox/src/ComboboxInput.tsx @@ -27,54 +27,41 @@ export const Input = forwardRef( }: InputProps, forwardedRef: Ref ) => { - const { - getDropdownProps, - getInputProps, - getLabelProps, - hasPopover, - disabled, - readOnly, - inputValue, - setInputValue, - setIsInputControlled, - setLastInteractionType, - setOnInputValueChange, - multiple, - selectedItem, - selectedItems, - } = useComboboxContext() + const ctx = useComboboxContext() const isControlled = valueProp != null const placeholder = - multiple && selectedItems.length ? selectedItems.map(i => i.text).join(', ') : placeholderProp + ctx.multiple && ctx.selectedItems.length + ? ctx.selectedItems.map(i => i.text).join(', ') + : placeholderProp useEffect(() => { - setIsInputControlled(isControlled) + ctx.setIsInputControlled(isControlled) if (isControlled) { - setInputValue(valueProp as string) + ctx.setInputValue(valueProp as string) } }, [isControlled, valueProp]) useEffect(() => { // Make Downshift aware of `onValueChange` prop to dispatch it if (onValueChange) { - setOnInputValueChange(() => onValueChange) + ctx.setOnInputValueChange(() => onValueChange) } // Sync input with combobox default value - if (!multiple && selectedItem && !isControlled) { - setInputValue(selectedItem.text) + if (!ctx.multiple && ctx.selectedItem && !isControlled) { + ctx.setInputValue(ctx.selectedItem.text) } }, []) - const [PopoverTrigger, popoverTriggerProps] = hasPopover + const [PopoverTrigger, popoverTriggerProps] = ctx.hasPopover ? [Popover.Trigger, { asChild: true, type: undefined }] : [Fragment, {}] - const { ref: downshiftRef, ...downshiftInputProps } = getInputProps({ - ...getDropdownProps(), + const { ref: downshiftRef, ...downshiftInputProps } = ctx.getInputProps({ + ...ctx.getDropdownProps(), onKeyDown: () => { - setLastInteractionType('keyboard') + ctx.setLastInteractionType('keyboard') }, }) @@ -84,7 +71,7 @@ export const Input = forwardRef( <> {ariaLabel && ( - + )} @@ -94,11 +81,11 @@ export const Input = forwardRef( ref={ref} type="text" placeholder={placeholder} - disabled={disabled || readOnly} + disabled={ctx.disabled || ctx.readOnly} className={cx('text-ellipsis', className)} {...props} {...downshiftInputProps} - value={inputValue} + value={ctx.inputValue} /> diff --git a/packages/components/combobox/src/tests/Combobox.test.tsx b/packages/components/combobox/src/tests/Combobox.test.tsx index ef16c6e88..d250c5ee6 100644 --- a/packages/components/combobox/src/tests/Combobox.test.tsx +++ b/packages/components/combobox/src/tests/Combobox.test.tsx @@ -164,7 +164,7 @@ describe('Combobox', () => { // Given a combobox with no selected value yet render( - + diff --git a/packages/components/combobox/src/tests/singleSelection.test.tsx b/packages/components/combobox/src/tests/singleSelection.test.tsx index ca8f663d6..c9abbfe5f 100644 --- a/packages/components/combobox/src/tests/singleSelection.test.tsx +++ b/packages/components/combobox/src/tests/singleSelection.test.tsx @@ -13,7 +13,7 @@ describe('Combobox', () => { // Given a combobox with no selected value yet render( - + diff --git a/packages/components/combobox/src/useCombobox.ts b/packages/components/combobox/src/useCombobox.ts index 44821ecde..7d53528c1 100644 --- a/packages/components/combobox/src/useCombobox.ts +++ b/packages/components/combobox/src/useCombobox.ts @@ -26,6 +26,7 @@ export interface DownshiftProps { multiple: boolean | undefined id: string labelId: string + allowCustomValue: boolean } /** @@ -46,6 +47,7 @@ export const useCombobox = ({ id, labelId, onInputValueChange, + allowCustomValue, }: DownshiftProps) => { const items = [...itemsMap.values()] @@ -63,6 +65,14 @@ export const useCombobox = ({ }, }) + const updateInputValue = (inputValue: string | undefined) => { + if (onInputValueChange) { + if (inputValue != null) onInputValueChange(inputValue) + } else { + setInputValue(inputValue) + } + } + /** * Custom state reducer for multiple selection behaviour: * - keeps the component opened when the user selects an item @@ -73,11 +83,57 @@ export const useCombobox = ({ state, { changes, type } ) => { - if (!multiple) return changes + const match = [...itemsMap.values()].find(item => item.text === state.inputValue) + + switch (type) { + case useDownshiftCombobox.stateChangeTypes.InputBlur: + if (allowCustomValue) return changes + + /** + * If input has been cleared by the user, then we unselect the selectedItem + */ + if (state.inputValue === '') { + return { ...changes, selectedItem: null } + } + + if (match) { + return { ...changes, selectedItem: match } + } else { + const newinputValue = state.selectedItem?.text || '' + updateInputValue(newinputValue) + + return { ...changes, inputValue: newinputValue } + } + + return changes + default: + return changes + } + } + /** + * Custom state reducer for multiple selection behaviour: + * - keeps the component opened when the user selects an item + * - preserves the higlighted index when the user select an item + * - selected items can be unselected, even the last selected item (as opposed to single selection behaviour) + */ + const multipleSelectionStateReducer: UseComboboxProps['stateReducer'] = ( + state, + { changes, type } + ) => { const { selectedItems, removeSelectedItem, addSelectedItem } = downshiftMultipleSelection switch (type) { + case useDownshiftCombobox.stateChangeTypes.InputBlur: + if (allowCustomValue) { + return changes + } else { + const newinputValue = '' + updateInputValue(newinputValue) + + return { ...changes, inputValue: newinputValue } + } + case useDownshiftCombobox.stateChangeTypes.InputKeyDownEnter: case useDownshiftCombobox.stateChangeTypes.ItemClick: if (changes.selectedItem != null) { @@ -104,18 +160,9 @@ export const useCombobox = ({ type, selectedItem: newSelectedItem, }) => { - const updateInputValue = (inputValue: string | undefined) => { - if (onInputValueChange) { - if (inputValue != null) onInputValueChange(inputValue) - } else { - setInputValue(inputValue) - } - } - switch (type) { case useDownshiftCombobox.stateChangeTypes.InputKeyDownEnter: case useDownshiftCombobox.stateChangeTypes.ItemClick: - case useDownshiftCombobox.stateChangeTypes.InputBlur: if (newSelectedItem) { updateInputValue(multiple ? '' : newInputValue) } @@ -151,7 +198,7 @@ export const useCombobox = ({ if (isOpen != null) onOpenChange?.(isOpen) }, initialIsOpen: defaultOpen ?? false, - stateReducer, + stateReducer: multiple ? multipleSelectionStateReducer : stateReducer, // Controlled mode (single selection) selectedItem: value ? itemsMap.get(value as string) : undefined, initialSelectedItem: defaultValue ? itemsMap.get(defaultValue as string) : undefined,