-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: Add support for multi-select ComboBox #9525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
488ab85
92ccc6d
c247e8f
9b6ecfa
d9602ce
b83c5df
fa9a631
8c7b5ad
0a57789
e3621fb
4f16c61
86ef3b4
18b1702
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,14 +12,14 @@ | |
|
|
||
| import {announce} from '@react-aria/live-announcer'; | ||
| import {AriaButtonProps} from '@react-types/button'; | ||
| import {AriaComboBoxProps} from '@react-types/combobox'; | ||
| import {AriaComboBoxProps, SelectionMode} from '@react-types/combobox'; | ||
| import {ariaHideOutside} from '@react-aria/overlays'; | ||
| import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; | ||
| import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; | ||
| import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; | ||
| import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useId, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; | ||
| import {ComboBoxState} from '@react-stately/combobox'; | ||
| import {dispatchVirtualFocus} from '@react-aria/focus'; | ||
| import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; | ||
| import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef, useState} from 'react'; | ||
| import {getChildNodes, getItemCount} from '@react-stately/collections'; | ||
| // @ts-ignore | ||
| import intlMessages from '../intl/*.json'; | ||
|
|
@@ -29,7 +29,7 @@ import {useLocalizedStringFormatter} from '@react-aria/i18n'; | |
| import {useMenuTrigger} from '@react-aria/menu'; | ||
| import {useTextField} from '@react-aria/textfield'; | ||
|
|
||
| export interface AriaComboBoxOptions<T> extends Omit<AriaComboBoxProps<T>, 'children'> { | ||
| export interface AriaComboBoxOptions<T, M extends SelectionMode = 'single'> extends Omit<AriaComboBoxProps<T, M>, 'children'> { | ||
| /** The ref for the input element. */ | ||
| inputRef: RefObject<HTMLInputElement | null>, | ||
| /** The ref for the list box popover. */ | ||
|
|
@@ -57,6 +57,8 @@ export interface ComboBoxAria<T> extends ValidationResult { | |
| listBoxProps: AriaListBoxOptions<T>, | ||
| /** Props for the optional trigger button, to be passed to `useButton`. */ | ||
| buttonProps: AriaButtonProps, | ||
| /** Props for the element representing the selected value. */ | ||
| valueProps: DOMAttributes, | ||
| /** Props for the combo box description element, if any. */ | ||
| descriptionProps: DOMAttributes, | ||
| /** Props for the combo box error message element, if any. */ | ||
|
|
@@ -69,7 +71,7 @@ export interface ComboBoxAria<T> extends ValidationResult { | |
| * @param props - Props for the combo box. | ||
| * @param state - State for the select, as returned by `useComboBoxState`. | ||
| */ | ||
| export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxState<T>): ComboBoxAria<T> { | ||
| export function useComboBox<T, M extends SelectionMode = 'single'>(props: AriaComboBoxOptions<T, M>, state: ComboBoxState<T, M>): ComboBoxAria<T> { | ||
| let { | ||
| buttonRef, | ||
| popoverRef, | ||
|
|
@@ -158,7 +160,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta | |
| break; | ||
| case 'Escape': | ||
| if ( | ||
| state.selectedKey !== null || | ||
| !state.selectionManager.isEmpty || | ||
| state.inputValue === '' || | ||
| props.allowsCustomValue | ||
| ) { | ||
|
|
@@ -206,6 +208,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta | |
| state.setFocused(true); | ||
| }; | ||
|
|
||
| let valueId = useValueId([state.selectedItems, state.selectionManager.selectionMode]); | ||
| let {isInvalid, validationErrors, validationDetails} = state.displayValidation; | ||
| let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({ | ||
| ...props, | ||
|
|
@@ -217,10 +220,11 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta | |
| onFocus, | ||
| autoComplete: 'off', | ||
| validate: undefined, | ||
| [privateValidationStateProp]: state | ||
| [privateValidationStateProp]: state, | ||
| 'aria-describedby': [valueId, props['aria-describedby']].filter(Boolean).join(' ') || undefined | ||
| }, inputRef); | ||
|
|
||
| useFormReset(inputRef, state.defaultSelectedKey, state.setSelectedKey); | ||
| useFormReset(inputRef, state.defaultValue, state.setValue); | ||
|
|
||
| // Press handlers for the ComboBox button | ||
| let onPress = (e: PressEvent) => { | ||
|
|
@@ -332,6 +336,7 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta | |
| }); | ||
|
|
||
| // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically. | ||
| // TODO: do we need to do this for multi-select? | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right now I just hear "51 options available", which interrupts the default selection announcement from VO. But I also don't hear this with single select. We should discuss whether we can just remove the custom announcements entirely at this point.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That makes sense, I think ComboBox is overdue for an accessibility sweep anyways since there have been reported issues against Talkback, the whole autocomplete announcement issue with auto focusing the first option on focus, etc |
||
| let lastSelectedKey = useRef(state.selectedKey); | ||
| useEffect(() => { | ||
| if (isAppleDevice() && state.isFocused && state.selectedItem && state.selectedKey !== lastSelectedKey.current) { | ||
|
|
@@ -392,10 +397,39 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta | |
| linkBehavior: 'selection' as const, | ||
| ['UNSTABLE_itemBehavior']: 'action' | ||
| }), | ||
| valueProps: { | ||
| id: valueId | ||
| }, | ||
| descriptionProps, | ||
| errorMessageProps, | ||
| isInvalid, | ||
| validationErrors, | ||
| validationDetails | ||
| }; | ||
| } | ||
|
|
||
| // This is a modified version of useSlotId that uses useEffect instead of useLayoutEffect. | ||
| // Triggering re-renders from useLayoutEffect breaks useComboBoxState's useEffect logic in React 18. | ||
| // These re-renders preempt async state updates in the useEffect, which ends up running multiple times | ||
| // prior to the state being updated. This results in onSelectionChange being called multiple times. | ||
| // TODO: refactor useComboBoxState to avoid this. | ||
| function useValueId(depArray: ReadonlyArray<any> = []): string | undefined { | ||
| let id = useId(); | ||
| let [exists, setExists] = useState(true); | ||
| let [lastDeps, setLastDeps] = useState(depArray); | ||
|
|
||
| // If the deps changed, set exists to true so we can test whether the element exists. | ||
| if (lastDeps.some((v, i) => !Object.is(v, depArray[i]))) { | ||
| setExists(true); | ||
| setLastDeps(depArray); | ||
| } | ||
|
|
||
| useEffect(() => { | ||
| if (exists && !document.getElementById(id)) { | ||
| // eslint-disable-next-line react-hooks/set-state-in-effect | ||
| setExists(false); | ||
| } | ||
| }, [id, exists, lastDeps]); | ||
|
|
||
| return exists ? id : undefined; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what do you think about allowing users to deselect from the combobox menu via Enter on a selected item? Ariakit's combobox has similar behavior to that, and it would be more flexible for keyboard users in case a taggroup wasn't rendered alongside the ComboBox (though I'm not sure how common that would be)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mmm yeah we should support that.