/* * Copyright 2024 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, RefObject, ValueBase} from '@react-types/shared'; import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getEventTarget, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality, getPointerType} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; import {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; export interface CollectionOptions extends DOMProps, AriaLabelingProps { /** Whether the collection items should use virtual focus instead of being focused directly. */ shouldUseVirtualFocus: boolean, /** Whether typeahead is disabled. */ disallowTypeAhead: boolean } export interface InputProps extends DOMProps, FocusEvents, KeyboardEvents, Pick, 'onChange' | 'value'>, Pick {} export interface AriaAutocompleteProps extends AutocompleteProps { /** * An optional filter function used to determine if a option should be included in the autocomplete list. * Include this if the items you are providing to your wrapped collection aren't filtered by default. */ filter?: (textValue: string, inputValue: string, node: Node) => boolean, /** * Whether or not to focus the first item in the collection after a filter is performed. Note this is only applicable * if virtual focus behavior is not turned off via `disableVirtualFocus`. * @default false */ disableAutoFocusFirst?: boolean, /** * Whether the autocomplete should disable virtual focus, instead making the wrapped collection directly tabbable. * @default false */ disableVirtualFocus?: boolean } export interface AriaAutocompleteOptions extends Omit, 'children'> { /** The ref for the wrapped collection element. */ inputRef: RefObject, /** The ref for the wrapped collection element. */ collectionRef: RefObject } export interface AutocompleteAria { /** Props for the autocomplete input element. These should be passed to the input's aria hooks (e.g. useTextField/useSearchField/etc) respectively. */ inputProps: InputProps, /** Props for the collection, to be passed to collection's respective aria hook (e.g. useMenu). */ collectionProps: CollectionOptions, /** Ref to attach to the wrapped collection. */ collectionRef: RefObject, /** A filter function that returns if the provided collection node should be filtered out of the collection. */ filter?: (nodeTextValue: string, node: Node) => boolean } /** * Provides the behavior and accessibility implementation for an autocomplete component. * An autocomplete combines a text input with a collection, allowing users to filter the collection's contents match a query. * @param props - Props for the autocomplete. * @param state - State for the autocomplete, as returned by `useAutocompleteState`. */ export function useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { inputRef, collectionRef, filter, disableAutoFocusFirst = false, disableVirtualFocus = false } = props; let collectionId = useId(); let timeout = useRef | undefined>(undefined); let delayNextActiveDescendant = useRef(false); let queuedActiveDescendant = useRef(null); // For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually // moving focus back to the subtriggers let isMobileScreenReader = getInteractionModality() === 'virtual' && (isIOS() || isAndroid()); let [shouldUseVirtualFocus, setShouldUseVirtualFocus] = useState(!isMobileScreenReader && !disableVirtualFocus); // Tracks if a collection has been connected to the autocomplete. If false, we don't want to add various attributes to the autocomplete input // since it isn't attached to a filterable collection (e.g. Tabs) let [hasCollection, setHasCollection] = useState(false); useEffect(() => { return () => clearTimeout(timeout.current); }, []); let updateActiveDescendantEvent = useEffectEvent((e: Event) => { // Ensure input is focused if the user clicks on the collection directly. // don't trigger on touch so that mobile keyboard doesnt appear when tapping on options if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current && getPointerType() !== 'touch') { inputRef.current.focus(); } let target = getEventTarget(e) as Element | null; if (e.isTrusted || !target || queuedActiveDescendant.current === target.id) { return; } clearTimeout(timeout.current); if (target !== collectionRef.current) { if (delayNextActiveDescendant.current) { queuedActiveDescendant.current = target.id; timeout.current = setTimeout(() => { state.setFocusedNodeId(target.id); }, 500); } else { queuedActiveDescendant.current = target.id; state.setFocusedNodeId(target.id); } } else if (queuedActiveDescendant.current && !document.getElementById(queuedActiveDescendant.current)) { // If we recieve a focus event refocusing the collection, either we have newly refocused the input and are waiting for the // wrapped collection to refocus the previously focused node if any OR // we are in a state where we've filtered to such a point that there aren't any matching items in the collection to focus. // In this case we want to clear tracked item if any and clear active descendant queuedActiveDescendant.current = null; state.setFocusedNodeId(null); } delayNextActiveDescendant.current = false; }); let [collectionNode, setCollectionNode] = useState(null); let callbackRef = useCallback((node) => { setCollectionNode(node); if (node != null) { // If useSelectableCollection isn't passed shouldUseVirtualFocus even when useAutocomplete provides it // that means the collection doesn't support it (e.g. Table). If that is the case, we need to disable it here regardless // of what the user's provided so that the input doesn't recieve the onKeyDown and autocomplete props. if (node.getAttribute('tabindex') != null) { setShouldUseVirtualFocus(false); } setHasCollection(true); } else { setHasCollection(false); } }, []); useLayoutEffect(() => { if (collectionNode != null) { // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement // of the letter you just typed. If we recieve another focus event then we clear the queued update collectionNode.addEventListener('focusin', updateActiveDescendantEvent); } return () => { collectionNode?.removeEventListener('focusin', updateActiveDescendantEvent); }; }, [collectionNode]); // Make sure to memo so that React doesn't keep registering a new event listeners on every rerender of the wrapped collection let mergedCollectionRef = useObjectRef(useMemo(() => mergeRefs(collectionRef, callbackRef), [collectionRef, callbackRef])); let focusFirstItem = useCallback(() => { delayNextActiveDescendant.current = true; collectionRef.current?.dispatchEvent( new CustomEvent(FOCUS_EVENT, { cancelable: true, bubbles: true, detail: { focusStrategy: 'first' } }) ); }, [collectionRef]); let clearVirtualFocus = useCallback((clearFocusKey?: boolean) => { moveVirtualFocus(getActiveElement()); queuedActiveDescendant.current = null; state.setFocusedNodeId(null); let clearFocusEvent = new CustomEvent(CLEAR_FOCUS_EVENT, { cancelable: true, bubbles: true, detail: { clearFocusKey } }); clearTimeout(timeout.current); delayNextActiveDescendant.current = false; collectionRef.current?.dispatchEvent(clearFocusEvent); }, [collectionRef, state]); let lastInputType = useRef(''); useEvent(inputRef, 'input', e => { let {inputType} = e as InputEvent; lastInputType.current = inputType; }); let onChange = (value: string) => { // Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when modifying the text via // copy paste/backspacing/undo/redo for screen reader announcements if (lastInputType.current === 'insertText' && !disableAutoFocusFirst) { focusFirstItem(); } else if (lastInputType.current && (lastInputType.current.includes('insert') || lastInputType.current.includes('delete') || lastInputType.current.includes('history'))) { clearVirtualFocus(true); // If onChange was triggered before the timeout actually updated the activedescendant, we need to fire // our own dispatchVirtualFocus so focusVisible gets reapplied on the input if (getVirtuallyFocusedElement(document) === inputRef.current) { dispatchVirtualFocus(inputRef.current!, null); } } state.setInputValue(value); }; let keyDownTarget = useRef(null); // For textfield specific keydown operations let onKeyDown = (e: BaseEvent>) => { keyDownTarget.current = getEventTarget(e) as Element; if (e.nativeEvent.isComposing) { return; } let focusedNodeId = queuedActiveDescendant.current; switch (e.key) { case 'a': if (isCtrlKeyPressed(e)) { return; } break; case 'Escape': // Early return for Escape here so it doesn't leak the Escape event from the simulated collection event below and // close the dialog prematurely. Ideally that should be up to the discretion of the input element hence the check // for isPropagationStopped if (e.isDefaultPrevented()) { return; } break; case ' ': // Space shouldn't trigger onAction so early return. return; case 'Tab': // Don't propogate Tab down to the collection, otherwise we will try to focus the collection via useSelectableCollection's Tab handler (aka shift tab logic) // We want FocusScope to handle Tab if one exists (aka sub dialog), so special casepropogate if ('continuePropagation' in e) { e.continuePropagation(); } return; case 'Home': case 'End': case 'PageDown': case 'PageUp': case 'ArrowUp': case 'ArrowDown': { if ((e.key === 'Home' || e.key === 'End') && focusedNodeId == null && e.shiftKey) { return; } // Prevent these keys from moving the text cursor in the input e.preventDefault(); // Move virtual focus into the wrapped collection let focusCollection = new CustomEvent(FOCUS_EVENT, { cancelable: true, bubbles: true }); collectionRef.current?.dispatchEvent(focusCollection); break; } } // Emulate the keyboard events that happen in the input field in the wrapped collection. This is for triggering things like onAction via Enter // or moving focus from one item to another. Stop propagation on the input event if it isn't already stopped so it doesn't leak out. For events // like ESC, the dispatched event below will bubble out of the collection and be stopped if handled by useSelectableCollection, otherwise will bubble // as expected if (!e.isPropagationStopped()) { e.stopPropagation(); } let shouldPerformDefaultAction = true; if (collectionRef.current !== null) { if (focusedNodeId == null) { shouldPerformDefaultAction = collectionRef.current?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ) || false; } else { let item = document.getElementById(focusedNodeId); if (item) { shouldPerformDefaultAction = item?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ) || false; } } } if (shouldPerformDefaultAction) { switch (e.key) { case 'ArrowLeft': case 'ArrowRight': { // Clear the activedescendant so NVDA announcements aren't interrupted but retain the focused key in the collection so the // user's keyboard navigation restarts from where they left off clearVirtualFocus(); break; } case 'Enter': // Trigger click action on item when Enter key was pressed. if (focusedNodeId != null) { let item = document.getElementById(focusedNodeId); item?.dispatchEvent(new PointerEvent('click', e.nativeEvent)); } break; } } else { // TODO: check if we can do this, want to stop textArea from using its default Enter behavior so items are properly triggered e.preventDefault(); } }; let onKeyUpCapture = useEffectEvent((e) => { // Dispatch simulated key up events for things like triggering links in listbox // Make sure to stop the propagation of the input keyup event so that the simulated keyup/down pair // is detected by usePress instead of the original keyup originating from the input if (getEventTarget(e) === keyDownTarget.current) { e.stopImmediatePropagation(); let focusedNodeId = queuedActiveDescendant.current; if (focusedNodeId == null) { collectionRef.current?.dispatchEvent( new KeyboardEvent(e.type, e) ); } else { let item = document.getElementById(focusedNodeId); item?.dispatchEvent( new KeyboardEvent(e.type, e) ); } } }); useEffect(() => { document.addEventListener('keyup', onKeyUpCapture, true); return () => { document.removeEventListener('keyup', onKeyUpCapture, true); }; }, []); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); let collectionProps = useLabels({ id: collectionId, 'aria-label': stringFormatter.format('collectionLabel') }); let filterFn = useCallback((nodeTextValue: string, node: Node) => { if (filter) { return filter(nodeTextValue, state.inputValue, node); } return true; }, [state.inputValue, filter]); // Be sure to clear/restore the virtual + collection focus when blurring/refocusing the field so we only show the // focus ring on the virtually focused collection when are actually interacting with the Autocomplete let onBlur = (e: ReactFocusEvent) => { if (!e.isTrusted) { return; } let lastFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null; if (lastFocusedNode) { dispatchVirtualBlur(lastFocusedNode, e.relatedTarget); } }; let onFocus = (e: ReactFocusEvent) => { if (!e.isTrusted) { return; } let curFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null; if (curFocusedNode) { let target = getEventTarget(e); queueMicrotask(() => { // instead of focusing the last focused node, just focus the collection instead and have the collection handle what item to focus via useSelectableCollection/Item dispatchVirtualBlur(target, collectionRef.current); dispatchVirtualFocus(collectionRef.current!, target); }); } }; // Only apply the autocomplete specific behaviors if the collection component wrapped by it is actually // being filtered/allows filtering by the Autocomplete. let inputProps = { value: state.inputValue, onChange } as AriaTextFieldProps; let virtualFocusProps = { onKeyDown, 'aria-activedescendant': state.focusedNodeId ?? undefined, onBlur, onFocus }; if (hasCollection) { inputProps = { ...inputProps, ...(shouldUseVirtualFocus && virtualFocusProps), enterKeyHint: 'go', 'aria-controls': collectionId, // TODO: readd proper logic for completionMode = complete (aria-autocomplete: both) 'aria-autocomplete': 'list', // This disable's iOS's autocorrect suggestions, since the autocomplete provides its own suggestions. autoCorrect: 'off', // This disable's the macOS Safari spell check auto corrections. spellCheck: 'false', autoComplete: 'off' }; } return { inputProps, collectionProps: mergeProps(collectionProps, { shouldUseVirtualFocus, disallowTypeAhead: shouldUseVirtualFocus }), collectionRef: mergedCollectionRef, filter: filter != null ? filterFn : undefined }; }