import type { AriaAttributes, ButtonHTMLAttributes, InputHTMLAttributes, KeyboardEvent, RefObject } from 'react'; import { useEffect, useMemo, useRef } from 'react'; import type { AriaButtonProps, AriaListBoxOptions } from 'react-aria'; import { chain, ListKeyboardDelegate, mergeProps, useId, useOverlayTrigger, useTextField } from 'react-aria'; import { getItemId, listData } from '@react-aria/listbox'; import { ariaHideOutside } from '@react-aria/overlays'; import { useSelectableCollection } from '@react-aria/selection'; import { filterDOMProps, useLabels } from '@react-aria/utils'; import type { AriaLabelingProps, DOMProps, FocusableElement, FocusableProps, InputBase, PressEvent, } from '@react-types/shared'; import type { MultiSelectState } from './useMultiSelectState'; export interface TriggerOptions extends Omit, FocusableProps {} /** Options for useMultiSelect hook. */ export interface UseMultiSelectOptions extends TriggerOptions, AriaLabelingProps, DOMProps { /** The ref for the optional list box popup trigger button. */ triggerRef?: RefObject; /** The ref for the list box popover. */ popoverRef: RefObject; /** The ref for the input element. */ filterRef: RefObject; /** The ref for the list box. */ listRef: RefObject; } /** Props that are turned by useMultiSelect hook. */ export interface MultiSelectAria { /** Props for the trigger button, to be passed to [useButton](useButton.html). */ inputProps: AriaButtonProps & Pick, 'role'> & Pick; /** Props for the popover element. */ popoverProps: DOMProps; /** Props for the dialog element. */ dialogProps: DOMProps & AriaLabelingProps; /** Props for the select all button. */ toggleAllProps: AriaButtonProps & Pick, 'tabIndex'>; /** Props for the filter input element. */ filterProps: InputHTMLAttributes; /** Props for the list box. */ listProps: AriaListBoxOptions; } export function useMultiSelect(props: UseMultiSelectOptions, state: MultiSelectState): MultiSelectAria { const { popoverRef, filterRef, listRef, autoFocus, isDisabled, id } = props; const backupBtnRef = useRef(null); const listBoxId = useId(); const triggerRef = props.triggerRef ?? backupBtnRef; const { triggerProps: inputProps, overlayProps: popoverProps } = useOverlayTrigger( { type: 'listbox', }, state, triggerRef ); // Set listbox id so it can be used when calling getItemId later listData.set(state, { id: listBoxId }); const { collection } = state; const { disabledKeys } = state.selectionManager; const delegate = useMemo( () => new ListKeyboardDelegate({ collection, disabledKeys, ref: listRef, }), [collection, disabledKeys, listRef] ); // Use useSelectableCollection to get the keyboard handlers to apply to the textfield const { collectionProps } = useSelectableCollection({ selectionManager: state.selectionManager, keyboardDelegate: delegate, disallowTypeAhead: false, disallowEmptySelection: true, shouldFocusWrap: false, ref: filterRef, // Prevent item scroll behavior from being applied here, should be handled in the user's Popover + ListBox component isVirtualized: true, }); const onTriggerKeyDown = (e: KeyboardEvent) => { if (e.nativeEvent.isComposing) { return; } switch (e.key) { case 'Enter': { // Prevent form submission if menu is open since we may be selecting a option if (state.isOpen && e.key === 'Enter') { e.preventDefault(); } break; } case 'ArrowDown': { state.open('first'); filterRef.current?.focus(); break; } case 'ArrowUp': { state.open('last'); filterRef.current?.focus(); break; } } }; const onFilterKeyDown = (e: KeyboardEvent) => { if (e.nativeEvent.isComposing) { return; } switch (e.key) { case 'Tab': { if (e.shiftKey) { e.preventDefault(); state.close(); } break; } case 'Enter': { e.preventDefault(); if (state.selectionManager.focusedKey != null) { state.selectionManager.toggleSelection(state.selectionManager.focusedKey); } break; } case 'Escape': { state.close(); break; } case 'ArrowDown': { if (!state.selectionManager.isFocused) { state.selectionManager.setFocused(true); } break; } case 'ArrowUp': { if (!state.selectionManager.isFocused) { state.selectionManager.setFocused(true); } break; } } if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') { collectionProps.onKeyDown?.(e); } }; const onFilterBlur = () => { state.selectionManager.setFocused(false); state.setFilterFocused(false); }; const onFilterFocus = () => { state.selectionManager.setFocused(true); state.setFilterFocused(true); }; const onSelectAllKeyDown = (e: KeyboardEvent) => { if (e.nativeEvent.isComposing) { return; } switch (e.key) { case 'Escape': { state.close(); break; } } }; const onSelectedAllPress = () => { state.toggleFiltered(); }; const { inputProps: filterInputProps } = useTextField( { onChange: state.setSearchQuery, onBlur: onFilterBlur, value: state.searchQuery, onFocus: onFilterFocus, onKeyDown: onFilterKeyDown, autoComplete: 'off', validate: undefined, inputElementType: 'input', 'aria-label': 'Filter options', }, filterRef ); const onInputPress = (e: PressEvent) => { if (e.pointerType === 'touch') { state.toggle(null); } }; const onInputPressStart = (e: PressEvent) => { if (e.pointerType !== 'touch') { state.toggle(null); } }; const inputAriaProps = filterDOMProps(props); const inputLabelProps = useLabels({ id: id ?? inputProps.id, 'aria-label': props['aria-label'], 'aria-labelledby': props['aria-labelledby'], }); const dialogLabelProps = useLabels({ 'aria-label': props['aria-label'], 'aria-labelledby': props['aria-labelledby'], }); const listBoxLabelProps = useLabels({ id: listBoxId, 'aria-label': props['aria-label'], 'aria-labelledby': props['aria-labelledby'], }); const focusedItem = state.selectionManager.focusedKey != null && state.isOpen ? state.collection.getItem(state.selectionManager.focusedKey) : undefined; useEffect(() => { if (state.isOpen) { return ariaHideOutside( [triggerRef.current, popoverRef.current].filter((element): element is Element => element != null) ); } }, [state.isOpen, triggerRef, popoverRef]); return { inputProps: { ...inputProps, ...inputAriaProps, ...inputLabelProps, onKeyDown: chain(inputProps.onKeyDown, onTriggerKeyDown, props.onKeyDown), onKeyUp: props.onKeyUp, onPress: onInputPress, onPressStart: onInputPressStart, onBlur: props.onBlur, onFocus: props.onFocus, onFocusChange: props.onFocusChange, isDisabled, role: 'combobox', 'aria-autocomplete': 'none', autoFocus, }, popoverProps, dialogProps: dialogLabelProps, filterProps: mergeProps(filterInputProps, { 'aria-activedescendant': focusedItem ? getItemId(state, focusedItem.key) : undefined, role: 'combobox', autoCorrect: 'off', spellCheck: 'false', autoFocus: true, 'aria-autocomplete': 'list', 'aria-haspopup': 'listbox', 'aria-expanded': state.isOpen, 'aria-controls': listBoxId, }), toggleAllProps: { onKeyDown: onSelectAllKeyDown, onPress: onSelectedAllPress, 'aria-pressed': state.selectionManager.isSelectAll, tabIndex: 0, }, listProps: mergeProps(listBoxLabelProps, { autoFocus: false, disallowEmptySelection: true, shouldUseVirtualFocus: filterRef.current === document.activeElement, shouldSelectOnPressUp: true, linkBehavior: 'selection' as const, }), }; }