diff --git a/packages/react-aria-components/docs/ComboBox.mdx b/packages/react-aria-components/docs/ComboBox.mdx index 30b302628f1..5ebe5f63311 100644 --- a/packages/react-aria-components/docs/ComboBox.mdx +++ b/packages/react-aria-components/docs/ComboBox.mdx @@ -512,6 +512,48 @@ any value within the field. ``` +### Custom values with multiple selection + +When `allowsCustomValue` is combined with `selectionMode="multiple"`, users can both select predefined items and type custom values. Pressing Enter or Tab with a non-matching input adds the typed text as a custom value to the selection. The input clears after each selection so the user can continue searching. Pressing Escape discards the typed text without affecting the current selection. + +Custom values appear in `selectedItems` as nodes with `value: null` and `textValue` set to the custom string. You can distinguish them from collection items by checking `item.value`. + +```tsx example +import {ComboBoxStateContext, TagGroup, TagList, Tag} from 'react-aria-components'; + + + +
+ + +
+ + {state => state && ( + { + if (Array.isArray(state.value)) { + state.setValue(state.value.filter(k => !keys.has(k))); + } + }}> + item.value ?? {id: item.key, name: item.textValue})}> + {item => {item.name}} + + + )} + + +
No results found
}> + Red Panda + Cat + Dog + Kangaroo + Snake +
+
+
+``` + ### HTML forms ComboBox supports the `name` prop for integration with HTML forms. By default, the `id` of the selected item will be submitted to the server. If the `formValue` prop is set to `"text"` or the `allowsCustomValue` prop is true, the text in the input field will be submitted instead. diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index d6ef3c99a87..d59b9fb3f4e 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -140,7 +140,7 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: formValue = 'key', allowsCustomValue } = props; - if (allowsCustomValue) { + if (allowsCustomValue && props.selectionMode !== 'multiple') { formValue = 'text'; } diff --git a/packages/react-aria-components/stories/ComboBox.stories.tsx b/packages/react-aria-components/stories/ComboBox.stories.tsx index 95e5c268950..0a5f7847d52 100644 --- a/packages/react-aria-components/stories/ComboBox.stories.tsx +++ b/packages/react-aria-components/stories/ComboBox.stories.tsx @@ -20,6 +20,7 @@ import {ListBox} from '../src/ListBox'; import {ListBoxLoadMoreItem} from '../src/ListBox'; import {ListLayout} from 'react-stately/useVirtualizerState'; import {LoadingSpinner, MyListBoxItem} from './utils'; +import {Key} from '@react-types/shared'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {Popover} from '../src/Popover'; import React, {JSX, useMemo, useState} from 'react'; @@ -437,6 +438,159 @@ export const MultiSelectComboBox: ComboBoxStory = () => ( ); +export const MultiSelectComboBoxAllowsCustomValue: ComboBoxStory = () => ( + + +
+ + +
+ + {state => state && ( + item.value ?? {id: item.key, name: item.textValue})} + renderEmptyState={() => 'No selected items'} + onRemove={(keys) => { + if (Array.isArray(state.value)) { + state.setValue(state.value.filter(k => !keys.has(k))); + } + }}> + {item => {item.name}} + + )} + + + + renderEmptyState={renderEmptyState} + data-testid="combo-box-list-box" + className={styles.menu}> + {item => {item.name}} + + +
+); + +export function MultiSelectComboBoxAllowsCustomValueControlled() { + let [selectedKeys, setSelectedKeys] = useState([]); + let [inputValue, setInputValue] = useState(''); + let {contains} = useFilter({sensitivity: 'base'}); + let filteredItems = useMemo(() => usStateOptions.filter(item => contains(item.name, inputValue)), [inputValue, contains]); + + return ( +
+
+
inputValue: {JSON.stringify(inputValue)}
+
selectedKeys: {JSON.stringify(selectedKeys)}
+
+ + +
+ + +
+ + {state => state && ( + item.value ?? {id: item.key, name: item.textValue})} + renderEmptyState={() => 'No selected items'} + onRemove={(keys) => { + setSelectedKeys(prev => prev.filter(k => !keys.has(k))); + }}> + {item => {item.name}} + + )} + + + + renderEmptyState={renderEmptyState} + className={styles.menu}> + {item => {item.name}} + + +
+
+ ); +} + +export function MultiSelectComboBoxAllowsCustomValueForm() { + let [submittedData, setSubmittedData] = useState(null); + + return ( +
+
{ + e.preventDefault(); + let formData = new FormData(e.currentTarget); + let entries: Record = {}; + for (let [key, value] of formData.entries()) { + if (!entries[key]) { + entries[key] = []; + } + entries[key].push(value); + } + setSubmittedData(JSON.stringify(entries, null, 2)); + }}> + + +
+ + +
+ + {state => state && ( + item.value ?? {id: item.key, name: item.textValue})} + renderEmptyState={() => 'No selected items'} + onRemove={(keys) => { + if (Array.isArray(state.value)) { + state.setValue(state.value.filter(k => !keys.has(k))); + } + }}> + {item => {item.name}} + + )} + + + + renderEmptyState={renderEmptyState} + className={styles.menu}> + {item => {item.name}} + + +
+ + + {submittedData && ( +
+          {submittedData}
+        
+ )} +
+ ); +} + const usStateOptions = [ {id: 'AL', name: 'Alabama'}, {id: 'AK', name: 'Alaska'}, diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index af75cba5644..583d8c2d356 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -786,16 +786,211 @@ describe('ComboBox', () => { }); it('should support multi-select with custom value', async () => { - // allowsCustomValue doesn't really make sense to use with multi-selection, but test it anyway. - let {container} = render(); + let onChange = jest.fn(); + let {container} = render(); let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); await user.tab(); await user.keyboard('Test'); expect(comboboxTester.combobox).toHaveValue('Test'); + // Custom value should be committed to selection and input cleared await user.tab(); - expect(comboboxTester.combobox).toHaveValue('Test'); + expect(comboboxTester.combobox).toHaveValue(''); + expect(onChange).toHaveBeenCalledWith(['Test']); + }); + + it('should not clear selection when pressing Escape with multi-select and allowsCustomValue', async () => { + let onChange = jest.fn(); + let {container} = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + + await user.tab(); + await user.keyboard('den'); + expect(comboboxTester.combobox).toHaveValue('den'); + + await user.keyboard('{Escape}'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should not clear selection when pressing Tab with multi-select and allowsCustomValue and existing selection', async () => { + let onChange = jest.fn(); + let {container} = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + + await user.tab(); + await user.keyboard('den'); + expect(comboboxTester.combobox).toHaveValue('den'); + + await user.tab(); + // "den" doesn't match any item, so it's added as a custom value + expect(onChange).toHaveBeenCalledWith(['1', '2', 'den']); + expect(comboboxTester.combobox).toHaveValue(''); + }); + + it('should deselect custom value when typing it again in multi-select', async () => { + let onChange = jest.fn(); + let {container} = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + + await user.tab(); + await user.keyboard('Test'); + await user.tab(); + expect(onChange).toHaveBeenCalledWith(['Test']); + + onChange.mockClear(); + await user.click(comboboxTester.combobox); + await user.keyboard('Test'); + await user.tab(); + // Should deselect "Test" + expect(onChange).toHaveBeenCalledWith([]); + }); + + it('should deselect custom value when typing it again and pressing Enter in multi-select', async () => { + let onChange = jest.fn(); + let {container} = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + + await user.tab(); + await user.keyboard('Custom'); + await user.keyboard('{Enter}'); + // Should deselect "Custom" + expect(onChange).toHaveBeenCalledWith([]); + expect(comboboxTester.combobox).toHaveValue(''); + }); + + it('should not add empty custom values in multi-select', async () => { + let onChange = jest.fn(); + let {container} = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + + await user.tab(); + await user.keyboard(' '); + await user.tab(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should select existing item instead of creating custom value when input matches item text in multi-select', async () => { + let onChange = jest.fn(); + let {container} = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + + await user.tab(); + await user.keyboard('Cat'); + + act(() => { + jest.runAllTimers(); + }); + + await user.tab(); + // Should select the existing "Cat" item (id: '1'), not create a custom "Cat" value + expect(onChange).toHaveBeenCalledWith(['1']); + expect(comboboxTester.combobox).toHaveValue(''); + }); + + it('should clear input when selecting an item from the list in multi-select', async () => { + let onChange = jest.fn(); + let {container} = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + + await comboboxTester.open(); + await user.keyboard('C'); + + act(() => { + jest.runAllTimers(); + }); + + // Arrow down to first item and select it + await user.keyboard('{ArrowDown}{Enter}'); + expect(onChange).toHaveBeenCalledWith(['1']); + expect(comboboxTester.combobox).toHaveValue(''); + }); + + it('should submit custom values in form data for multi-select with allowsCustomValue', async () => { + let {container} = render( +
+ + + +