@@ -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..839d79b78 100644
--- a/packages/components/combobox/src/Combobox.tsx
+++ b/packages/components/combobox/src/Combobox.tsx
@@ -2,18 +2,8 @@ import { type ComboboxContextProps, ComboboxProvider } from './ComboboxContext'
export type ComboboxProps = ComboboxContextProps
-export const Combobox = ({
- children,
- autoFilter = false,
- disabled = false,
- readOnly = false,
- ...props
-}: ComboboxProps) => {
- return (
-
- {children}
-
- )
+export const Combobox = ({ children, ...props }: ComboboxProps) => {
+ return {children}
}
Combobox.displayName = 'Combobox'
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 && (
- {ariaLabel}
+ {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/multipleSelection.test.tsx b/packages/components/combobox/src/tests/multipleSelection.test.tsx
index 581ed0571..81cf6ab7c 100644
--- a/packages/components/combobox/src/tests/multipleSelection.test.tsx
+++ b/packages/components/combobox/src/tests/multipleSelection.test.tsx
@@ -1,4 +1,4 @@
-import { render } from '@testing-library/react'
+import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
@@ -114,5 +114,73 @@ describe('Combobox', () => {
expect(getInput('Book').getAttribute('placeholder')).toBe('Pick a book')
expect(getItem('1984')).toHaveAttribute('aria-selected', 'false')
})
+
+ describe('blur behaviour', () => {
+ it('should not clear input value when custom value is allowed', async () => {
+ const user = userEvent.setup()
+
+ // Given a combobox that allows custom input value
+ render(
+
+
+
+
+
+
+ War and Peace
+ 1984
+ Pride and Prejudice
+
+
+
+ )
+
+ // Then placeholder should be displayed
+ expect(getInput('Book').getAttribute('placeholder')).toBe('Pick a book')
+
+ // When the user type "pri" in the input
+ await user.click(getInput('Book'))
+ await user.keyboard('{p}{r}{i}')
+
+ // When the user focus leaves the input
+ await user.click(document.body)
+
+ // Then input value is preserved as custom values are allowed
+ expect(screen.getByDisplayValue('pri')).toBeInTheDocument()
+ })
+
+ it('should clear input value if custom value is not allowed', async () => {
+ const user = userEvent.setup()
+
+ // Given a combobox that does not allow custom input value
+ render(
+
+
+
+
+
+
+ War and Peace
+ 1984
+ Pride and Prejudice
+
+
+
+ )
+
+ // Then placeholder should be displayed
+ expect(getInput('Book').getAttribute('placeholder')).toBe('Pick a book')
+
+ // When the user type "pri" to filter first item matching, then select it
+ await user.click(getInput('Book'))
+ await user.keyboard('{p}{r}{i}')
+
+ // When the user focus leaves the input
+ await user.click(document.body)
+
+ // Then input value has been cleared
+ expect(screen.getByDisplayValue('')).toBeInTheDocument()
+ })
+ })
})
})
diff --git a/packages/components/combobox/src/tests/singleSelection.test.tsx b/packages/components/combobox/src/tests/singleSelection.test.tsx
index ca8f663d6..7704b2e07 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(
-
+
@@ -167,5 +167,174 @@ describe('Combobox', () => {
expect(queryItem('1984')).not.toBeInTheDocument()
expect(getItem('Pride and Prejudice')).toHaveAttribute('aria-selected', 'true')
})
+
+ describe('blur behaviour', () => {
+ it('should not clear input value when custom value is allowed', async () => {
+ const user = userEvent.setup()
+
+ // Given a combobox that allows custom input value
+ render(
+
+
+
+
+
+
+ War and Peace
+ 1984
+ Pride and Prejudice
+
+
+
+ )
+
+ // Then placeholder should be displayed
+ expect(getInput('Book').getAttribute('placeholder')).toBe('Pick a book')
+
+ // When the user type "pri" in the input
+ await user.click(getInput('Book'))
+ await user.keyboard('{p}{r}{i}')
+
+ // When the user focus leaves the input
+ await user.click(document.body)
+
+ // Then input value is preserved as custom values are allowed
+ expect(screen.getByDisplayValue('pri')).toBeInTheDocument()
+ })
+
+ it('should clear input value if custom value is not allowed', async () => {
+ const user = userEvent.setup()
+
+ // Given a combobox that does not allow custom input value
+ render(
+
+
+
+
+
+
+ War and Peace
+ 1984
+ Pride and Prejudice
+
+
+
+ )
+
+ // Then placeholder should be displayed
+ expect(getInput('Book').getAttribute('placeholder')).toBe('Pick a book')
+
+ // When the user type "pri" to filter first item matching, then select it
+ await user.click(getInput('Book'))
+ await user.keyboard('{p}{r}{i}')
+
+ // When the user focus leaves the input
+ await user.click(document.body)
+
+ // Then input value has been cleared
+ expect(screen.getByDisplayValue('')).toBeInTheDocument()
+ })
+
+ it('should clear selected item if input value is an empty string', async () => {
+ const user = userEvent.setup()
+
+ // Given a combobox that does not allow custom input value
+ render(
+
+
+
+
+
+
+ War and Peace
+ 1984
+ Pride and Prejudice
+
+
+
+ )
+
+ // Then 1984 should be selected
+ expect(getItem('1984')).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByDisplayValue('1984')).toBeInTheDocument()
+
+ // When the user clears the input and focus outside of it
+ await user.clear(screen.getByDisplayValue('1984'))
+ await user.click(document.body)
+
+ // Then item has been unselected and input remains cleared
+ expect(getItem('1984')).toHaveAttribute('aria-selected', 'false')
+ expect(screen.getByDisplayValue('')).toBeInTheDocument()
+ })
+
+ it('should update input value to matching item if value matches precisely', async () => {
+ const user = userEvent.setup()
+
+ // Given a combobox that does not allow custom input value
+ render(
+
+
+
+
+
+
+ War and Peace
+ 1984
+ Pride and Prejudice
+
+
+
+ )
+
+ // Then 1984 should be selected
+ expect(getItem('1984')).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByDisplayValue('1984')).toBeInTheDocument()
+
+ const input = getInput('Book')
+ // When the user changes the input to the value of another item and focus outside of it
+ await user.clear(input)
+ await user.type(input, 'war and peace') // notice this is not case-sensitive
+ await user.click(document.body)
+
+ // Then item has been unselected and item matching the input value has been selected
+ expect(getItem('1984')).toHaveAttribute('aria-selected', 'false')
+ expect(getItem('War and Peace')).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByDisplayValue('War and Peace')).toBeInTheDocument()
+ })
+
+ it('should sync input value to selected value if value does not match', async () => {
+ const user = userEvent.setup()
+
+ // Given a combobox that does not allow custom input value
+ render(
+
+
+
+
+
+
+ War and Peace
+ 1984
+ Pride and Prejudice
+
+
+
+ )
+
+ // Then 1984 should be selected
+ expect(getItem('1984')).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByDisplayValue('1984')).toBeInTheDocument()
+
+ const input = getInput('Book')
+ // When the user changes the input to the value of another item and focus outside of it
+ await user.clear(input)
+ await user.type(input, 'A value that does not match any item')
+ await user.click(document.body)
+
+ // Then item remain selected and the input is synced with its text value
+ expect(getItem('1984')).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByDisplayValue('1984')).toBeInTheDocument()
+ })
+ })
})
})
diff --git a/packages/components/combobox/src/useCombobox.ts b/packages/components/combobox/src/useCombobox.ts
index 44821ecde..f2e725792 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,58 @@ export const useCombobox = ({
state,
{ changes, type }
) => {
- if (!multiple) return changes
+ const match = [...itemsMap.values()].find(
+ item => item.text.toLowerCase() === state.inputValue.toLowerCase()
+ )
+
+ const fallbackText = state.selectedItem?.text || ''
+
+ 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) {
+ updateInputValue(match.text)
+
+ return { ...changes, selectedItem: match, inputValue: match.text }
+ }
+
+ updateInputValue(fallbackText)
+
+ return { ...changes, inputValue: fallbackText }
+
+ 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
+
+ updateInputValue('')
+
+ return { ...changes, inputValue: '' }
+
case useDownshiftCombobox.stateChangeTypes.InputKeyDownEnter:
case useDownshiftCombobox.stateChangeTypes.ItemClick:
if (changes.selectedItem != null) {
@@ -104,18 +161,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 +199,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,