Skip to content

Commit b99c420

Browse files
devongovettsnowystingerLFDanLu
authored
feat: Add support for multi-select ComboBox (adobe#9525)
* feat: Add support for multi-select ComboBox * Add docs example * Render multiple hidden inputs for multi-select * Add a couple tests * Omit from RSP * Add more tests * Fix tests * Add ComboBoxValue and update docs * Associate ComboBoxValue via aria-describedby * fix TS and test * work around issue in React 18 tests * deselect focused selected items on Enter * fix combobox value text for docs in dark mode * fix docs types --------- Co-authored-by: Robert Snow <rsnow@adobe.com> Co-authored-by: Daniel Lu <dl1644@gmail.com> Co-authored-by: Robert Snow <snowystinger@gmail.com>
1 parent 1f6dc85 commit b99c420

File tree

20 files changed

+738
-146
lines changed

20 files changed

+738
-146
lines changed

packages/@react-aria/combobox/src/useComboBox.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212

1313
import {announce} from '@react-aria/live-announcer';
1414
import {AriaButtonProps} from '@react-types/button';
15-
import {AriaComboBoxProps} from '@react-types/combobox';
15+
import {AriaComboBoxProps, SelectionMode} from '@react-types/combobox';
1616
import {ariaHideOutside} from '@react-aria/overlays';
1717
import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox';
1818
import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared';
19-
import {chain, getActiveElement, getEventTarget, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils';
19+
import {chain, getActiveElement, getEventTarget, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useId, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils';
2020
import {ComboBoxState} from '@react-stately/combobox';
2121
import {dispatchVirtualFocus} from '@react-aria/focus';
22-
import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react';
22+
import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef, useState} from 'react';
2323
import {getChildNodes, getItemCount} from '@react-stately/collections';
2424
// @ts-ignore
2525
import intlMessages from '../intl/*.json';
@@ -29,7 +29,7 @@ import {useLocalizedStringFormatter} from '@react-aria/i18n';
2929
import {useMenuTrigger} from '@react-aria/menu';
3030
import {useTextField} from '@react-aria/textfield';
3131

32-
export interface AriaComboBoxOptions<T> extends Omit<AriaComboBoxProps<T>, 'children'> {
32+
export interface AriaComboBoxOptions<T, M extends SelectionMode = 'single'> extends Omit<AriaComboBoxProps<T, M>, 'children'> {
3333
/** The ref for the input element. */
3434
inputRef: RefObject<HTMLInputElement | null>,
3535
/** The ref for the list box popover. */
@@ -57,6 +57,8 @@ export interface ComboBoxAria<T> extends ValidationResult {
5757
listBoxProps: AriaListBoxOptions<T>,
5858
/** Props for the optional trigger button, to be passed to `useButton`. */
5959
buttonProps: AriaButtonProps,
60+
/** Props for the element representing the selected value. */
61+
valueProps: DOMAttributes,
6062
/** Props for the combo box description element, if any. */
6163
descriptionProps: DOMAttributes,
6264
/** Props for the combo box error message element, if any. */
@@ -69,7 +71,7 @@ export interface ComboBoxAria<T> extends ValidationResult {
6971
* @param props - Props for the combo box.
7072
* @param state - State for the select, as returned by `useComboBoxState`.
7173
*/
72-
export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxState<T>): ComboBoxAria<T> {
74+
export function useComboBox<T, M extends SelectionMode = 'single'>(props: AriaComboBoxOptions<T, M>, state: ComboBoxState<T, M>): ComboBoxAria<T> {
7375
let {
7476
buttonRef,
7577
popoverRef,
@@ -158,7 +160,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
158160
break;
159161
case 'Escape':
160162
if (
161-
state.selectedKey !== null ||
163+
!state.selectionManager.isEmpty ||
162164
state.inputValue === '' ||
163165
props.allowsCustomValue
164166
) {
@@ -206,6 +208,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
206208
state.setFocused(true);
207209
};
208210

211+
let valueId = useValueId([state.selectedItems, state.selectionManager.selectionMode]);
209212
let {isInvalid, validationErrors, validationDetails} = state.displayValidation;
210213
let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({
211214
...props,
@@ -217,10 +220,11 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
217220
onFocus,
218221
autoComplete: 'off',
219222
validate: undefined,
220-
[privateValidationStateProp]: state
223+
[privateValidationStateProp]: state,
224+
'aria-describedby': [valueId, props['aria-describedby']].filter(Boolean).join(' ') || undefined
221225
}, inputRef);
222226

223-
useFormReset(inputRef, state.defaultSelectedKey, state.setSelectedKey);
227+
useFormReset(inputRef, state.defaultValue, state.setValue);
224228

225229
// Press handlers for the ComboBox button
226230
let onPress = (e: PressEvent) => {
@@ -332,6 +336,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
332336
});
333337

334338
// Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically.
339+
// TODO: do we need to do this for multi-select?
335340
let lastSelectedKey = useRef(state.selectedKey);
336341
useEffect(() => {
337342
if (isAppleDevice() && state.isFocused && state.selectedItem && state.selectedKey !== lastSelectedKey.current) {
@@ -392,10 +397,39 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
392397
linkBehavior: 'selection' as const,
393398
['UNSTABLE_itemBehavior']: 'action'
394399
}),
400+
valueProps: {
401+
id: valueId
402+
},
395403
descriptionProps,
396404
errorMessageProps,
397405
isInvalid,
398406
validationErrors,
399407
validationDetails
400408
};
401409
}
410+
411+
// This is a modified version of useSlotId that uses useEffect instead of useLayoutEffect.
412+
// Triggering re-renders from useLayoutEffect breaks useComboBoxState's useEffect logic in React 18.
413+
// These re-renders preempt async state updates in the useEffect, which ends up running multiple times
414+
// prior to the state being updated. This results in onSelectionChange being called multiple times.
415+
// TODO: refactor useComboBoxState to avoid this.
416+
function useValueId(depArray: ReadonlyArray<any> = []): string | undefined {
417+
let id = useId();
418+
let [exists, setExists] = useState(true);
419+
let [lastDeps, setLastDeps] = useState(depArray);
420+
421+
// If the deps changed, set exists to true so we can test whether the element exists.
422+
if (lastDeps.some((v, i) => !Object.is(v, depArray[i]))) {
423+
setExists(true);
424+
setLastDeps(depArray);
425+
}
426+
427+
useEffect(() => {
428+
if (exists && !document.getElementById(id)) {
429+
// eslint-disable-next-line react-hooks/set-state-in-effect
430+
setExists(false);
431+
}
432+
}, [id, exists, lastDeps]);
433+
434+
return exists ? id : undefined;
435+
}

packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2479,7 +2479,8 @@ describe('SearchAutocomplete', function () {
24792479
expect(input).toHaveValue('test');
24802480

24812481
let button = getByTestId('submit');
2482-
await act(async () => await user.click(button));
2482+
// For some reason, user.click() causes act warnings related to suspense...
2483+
await act(() => button.click());
24832484
expect(input).toHaveValue('hi');
24842485
});
24852486
}

packages/@react-spectrum/combobox/test/ComboBox.test.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5286,11 +5286,12 @@ describe('ComboBox', function () {
52865286
expect(input).toHaveValue('One');
52875287

52885288
let button = getByTestId('submit');
5289-
await act(async () => await user.click(button));
5289+
// For some reason, user.click() causes act warnings related to suspense...
5290+
await act(() => button.click());
52905291
expect(input).toHaveValue('Two');
52915292

52925293
rerender(<Test formValue="key" />);
5293-
await act(async () => await user.click(button));
5294+
await user.click(button);
52945295
expect(document.querySelector('input[name=combobox]')).toHaveValue('2');
52955296
});
52965297
}

packages/@react-spectrum/s2/src/ComboBox.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
SectionProps,
3434
Virtualizer
3535
} from 'react-aria-components';
36-
import {AsyncLoadable, GlobalDOMAttributes, HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared';
36+
import {AsyncLoadable, GlobalDOMAttributes, HelpTextProps, LoadingState, SingleSelection, SpectrumLabelableProps} from '@react-types/shared';
3737
import {AvatarContext} from './Avatar';
3838
import {BaseCollection, CollectionNode, createLeafComponent} from '@react-aria/collections';
3939
import {baseColor, focusRing, space, style} from '../style' with {type: 'macro'};
@@ -79,7 +79,8 @@ export interface ComboboxStyleProps {
7979
size?: 'S' | 'M' | 'L' | 'XL'
8080
}
8181
export interface ComboBoxProps<T extends object> extends
82-
Omit<AriaComboBoxProps<T>, 'children' | 'style' | 'className' | 'render' | 'defaultFilter' | 'allowsEmptyCollection' | keyof GlobalDOMAttributes>,
82+
Omit<AriaComboBoxProps<T>, 'children' | 'style' | 'className' | 'render' | 'defaultFilter' | 'allowsEmptyCollection' | 'selectionMode' | 'selectedKey' | 'defaultSelectedKey' | 'onSelectionChange' | 'value' | 'defaultValue' | 'onChange' | keyof GlobalDOMAttributes>,
83+
Omit<SingleSelection, 'disallowEmptySelection'>,
8384
ComboboxStyleProps,
8485
StyleProps,
8586
SpectrumLabelableProps,

0 commit comments

Comments
 (0)