UNPKG

@base-ui-components/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

588 lines (578 loc) 23.5 kB
import * as React from 'react'; import { isHTMLElement } from '@floating-ui/utils/dom'; import { useValueAsRef } from '@base-ui-components/utils/useValueAsRef'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { activeElement, contains, getDocument, getTarget, isTypeableCombobox, isVirtualClick, isVirtualPointerEvent, stopEvent, getFloatingFocusElement, isIndexOutOfListBounds, getMinListIndex, getMaxListIndex, getGridNavigatedIndex, isListIndexDisabled, createGridCellMap, getGridCellIndices, getGridCellIndexOfCorner, findNonDisabledListIndex } from "../utils.js"; import { useFloatingParentNodeId, useFloatingTree } from "../components/FloatingTree.js"; import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js"; import { REASONS } from "../../utils/reasons.js"; import { enqueueFocus } from "../utils/enqueueFocus.js"; import { ARROW_UP, ARROW_DOWN, ARROW_RIGHT, ARROW_LEFT } from "../utils/constants.js"; export const ESCAPE = 'Escape'; function doSwitch(orientation, vertical, horizontal) { switch (orientation) { case 'vertical': return vertical; case 'horizontal': return horizontal; default: return vertical || horizontal; } } function isMainOrientationKey(key, orientation) { const vertical = key === ARROW_UP || key === ARROW_DOWN; const horizontal = key === ARROW_LEFT || key === ARROW_RIGHT; return doSwitch(orientation, vertical, horizontal); } function isMainOrientationToEndKey(key, orientation, rtl) { const vertical = key === ARROW_DOWN; const horizontal = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; return doSwitch(orientation, vertical, horizontal) || key === 'Enter' || key === ' ' || key === ''; } function isCrossOrientationOpenKey(key, orientation, rtl) { const vertical = rtl ? key === ARROW_LEFT : key === ARROW_RIGHT; const horizontal = key === ARROW_DOWN; return doSwitch(orientation, vertical, horizontal); } function isCrossOrientationCloseKey(key, orientation, rtl, cols) { const vertical = rtl ? key === ARROW_RIGHT : key === ARROW_LEFT; const horizontal = key === ARROW_UP; if (orientation === 'both' || orientation === 'horizontal' && cols && cols > 1) { return key === ESCAPE; } return doSwitch(orientation, vertical, horizontal); } /** * Adds arrow key-based navigation of a list of items, either using real DOM * focus or virtual focus. * @see https://floating-ui.com/docs/useListNavigation */ export function useListNavigation(context, props) { const store = 'rootStore' in context ? context.rootStore : context; const open = store.useState('open'); const floatingElement = store.useState('floatingElement'); const domReferenceElement = store.useState('domReferenceElement'); const dataRef = store.context.dataRef; const { listRef, activeIndex, onNavigate: onNavigateProp = () => {}, enabled = true, selectedIndex = null, allowEscape = false, loopFocus = false, nested = false, rtl = false, virtual = false, focusItemOnOpen = 'auto', focusItemOnHover = true, openOnArrowKeyDown = true, disabledIndices = undefined, orientation = 'vertical', parentOrientation, cols = 1, scrollItemIntoView = true, itemSizes, dense = false, id, externalTree } = props; if (process.env.NODE_ENV !== 'production') { if (allowEscape) { if (!loopFocus) { console.warn('`useListNavigation` looping must be enabled to allow escaping.'); } if (!virtual) { console.warn('`useListNavigation` must be virtual to allow escaping.'); } } if (orientation === 'vertical' && cols > 1) { console.warn('In grid list navigation mode (`cols` > 1), the `orientation` should', 'be either "horizontal" or "both".'); } } const floatingFocusElement = getFloatingFocusElement(floatingElement); const floatingFocusElementRef = useValueAsRef(floatingFocusElement); const parentId = useFloatingParentNodeId(); const tree = useFloatingTree(externalTree); useIsoLayoutEffect(() => { dataRef.current.orientation = orientation; }, [dataRef, orientation]); const typeableComboboxReference = isTypeableCombobox(domReferenceElement); const focusItemOnOpenRef = React.useRef(focusItemOnOpen); const indexRef = React.useRef(selectedIndex ?? -1); const keyRef = React.useRef(null); const isPointerModalityRef = React.useRef(true); const onNavigate = useStableCallback(event => { onNavigateProp(indexRef.current === -1 ? null : indexRef.current, event); }); const previousOnNavigateRef = React.useRef(onNavigate); const previousMountedRef = React.useRef(!!floatingElement); const previousOpenRef = React.useRef(open); const forceSyncFocusRef = React.useRef(false); const forceScrollIntoViewRef = React.useRef(false); const disabledIndicesRef = useValueAsRef(disabledIndices); const latestOpenRef = useValueAsRef(open); const scrollItemIntoViewRef = useValueAsRef(scrollItemIntoView); const selectedIndexRef = useValueAsRef(selectedIndex); const focusItem = useStableCallback(() => { function runFocus(item) { if (virtual) { tree?.events.emit('virtualfocus', item); } else { enqueueFocus(item, { sync: forceSyncFocusRef.current, preventScroll: true }); } } const initialItem = listRef.current[indexRef.current]; const forceScrollIntoView = forceScrollIntoViewRef.current; if (initialItem) { runFocus(initialItem); } const scheduler = forceSyncFocusRef.current ? v => v() : requestAnimationFrame; scheduler(() => { const waitedItem = listRef.current[indexRef.current] || initialItem; if (!waitedItem) { return; } if (!initialItem) { runFocus(waitedItem); } const scrollIntoViewOptions = scrollItemIntoViewRef.current; const shouldScrollIntoView = scrollIntoViewOptions && // eslint-disable-next-line @typescript-eslint/no-use-before-define item && (forceScrollIntoView || !isPointerModalityRef.current); if (shouldScrollIntoView) { // JSDOM doesn't support `.scrollIntoView()` but it's widely supported // by all browsers. waitedItem.scrollIntoView?.(typeof scrollIntoViewOptions === 'boolean' ? { block: 'nearest', inline: 'nearest' } : scrollIntoViewOptions); } }); }); // Sync `selectedIndex` to be the `activeIndex` upon opening the floating // element. Also, reset `activeIndex` upon closing the floating element. useIsoLayoutEffect(() => { if (!enabled) { return; } if (open && floatingElement) { indexRef.current = selectedIndex ?? -1; if (focusItemOnOpenRef.current && selectedIndex != null) { // Regardless of the pointer modality, we want to ensure the selected // item comes into view when the floating element is opened. forceScrollIntoViewRef.current = true; onNavigate(); } } else if (previousMountedRef.current) { // Since the user can specify `onNavigate` conditionally // (onNavigate: open ? setActiveIndex : setSelectedIndex), // we store and call the previous function. indexRef.current = -1; previousOnNavigateRef.current(); } }, [enabled, open, floatingElement, selectedIndex, onNavigate]); // Sync `activeIndex` to be the focused item while the floating element is // open. useIsoLayoutEffect(() => { if (!enabled) { return; } if (!open) { forceSyncFocusRef.current = false; return; } if (!floatingElement) { return; } if (activeIndex == null) { forceSyncFocusRef.current = false; if (selectedIndexRef.current != null) { return; } // Reset while the floating element was open (e.g. the list changed). if (previousMountedRef.current) { indexRef.current = -1; focusItem(); } // Initial sync. if ((!previousOpenRef.current || !previousMountedRef.current) && focusItemOnOpenRef.current && (keyRef.current != null || focusItemOnOpenRef.current === true && keyRef.current == null)) { let runs = 0; const waitForListPopulated = () => { if (listRef.current[0] == null) { // Avoid letting the browser paint if possible on the first try, // otherwise use rAF. Don't try more than twice, since something // is wrong otherwise. if (runs < 2) { const scheduler = runs ? requestAnimationFrame : queueMicrotask; scheduler(waitForListPopulated); } runs += 1; } else { // initially focus the first non-disabled item indexRef.current = keyRef.current == null || isMainOrientationToEndKey(keyRef.current, orientation, rtl) || nested ? getMinListIndex(listRef) : getMaxListIndex(listRef); keyRef.current = null; onNavigate(); } }; waitForListPopulated(); } } else if (!isIndexOutOfListBounds(listRef, activeIndex)) { indexRef.current = activeIndex; focusItem(); forceScrollIntoViewRef.current = false; } }, [enabled, open, floatingElement, activeIndex, selectedIndexRef, nested, listRef, orientation, rtl, onNavigate, focusItem, disabledIndicesRef]); // Ensure the parent floating element has focus when a nested child closes // to allow arrow key navigation to work after the pointer leaves the child. useIsoLayoutEffect(() => { if (!enabled || floatingElement || !tree || virtual || !previousMountedRef.current) { return; } const nodes = tree.nodesRef.current; const parent = nodes.find(node => node.id === parentId)?.context?.elements.floating; const activeEl = activeElement(getDocument(floatingElement)); const treeContainsActiveEl = nodes.some(node => node.context && contains(node.context.elements.floating, activeEl)); if (parent && !treeContainsActiveEl && isPointerModalityRef.current) { parent.focus({ preventScroll: true }); } }, [enabled, floatingElement, tree, parentId, virtual]); useIsoLayoutEffect(() => { previousOnNavigateRef.current = onNavigate; previousOpenRef.current = open; previousMountedRef.current = !!floatingElement; }); useIsoLayoutEffect(() => { if (!open) { keyRef.current = null; focusItemOnOpenRef.current = focusItemOnOpen; } }, [open, focusItemOnOpen]); const hasActiveIndex = activeIndex != null; const item = React.useMemo(() => { function syncCurrentTarget(event) { if (!latestOpenRef.current) { return; } const index = listRef.current.indexOf(event.currentTarget); if (index !== -1 && indexRef.current !== index) { indexRef.current = index; onNavigate(event); } } const itemProps = { onFocus(event) { forceSyncFocusRef.current = true; syncCurrentTarget(event); }, onClick: ({ currentTarget }) => currentTarget.focus({ preventScroll: true }), // Safari onMouseMove(event) { forceSyncFocusRef.current = true; forceScrollIntoViewRef.current = false; if (focusItemOnHover) { syncCurrentTarget(event); } }, onPointerLeave(event) { if (!latestOpenRef.current || !isPointerModalityRef.current || event.pointerType === 'touch') { return; } forceSyncFocusRef.current = true; const relatedTarget = event.relatedTarget; if (!focusItemOnHover || listRef.current.includes(relatedTarget)) { return; } indexRef.current = -1; onNavigate(event); if (!virtual) { floatingFocusElementRef.current?.focus({ preventScroll: true }); } } }; return itemProps; }, [latestOpenRef, floatingFocusElementRef, focusItemOnHover, listRef, onNavigate, virtual]); const getParentOrientation = React.useCallback(() => { return parentOrientation ?? tree?.nodesRef.current.find(node => node.id === parentId)?.context?.dataRef?.current.orientation; }, [parentId, tree, parentOrientation]); const commonOnKeyDown = useStableCallback(event => { isPointerModalityRef.current = false; forceSyncFocusRef.current = true; // When composing a character, Chrome fires ArrowDown twice. Firefox/Safari // don't appear to suffer from this. `event.isComposing` is avoided due to // Safari not supporting it properly (although it's not needed in the first // place for Safari, just avoiding any possible issues). if (event.which === 229) { return; } // If the floating element is animating out, ignore navigation. Otherwise, // the `activeIndex` gets set to 0 despite not being open so the next time // the user ArrowDowns, the first item won't be focused. if (!latestOpenRef.current && event.currentTarget === floatingFocusElementRef.current) { return; } if (nested && isCrossOrientationCloseKey(event.key, orientation, rtl, cols)) { // If the nested list's close key is also the parent navigation key, // let the parent navigate. Otherwise, stop propagating the event. if (!isMainOrientationKey(event.key, getParentOrientation())) { stopEvent(event); } store.setOpen(false, createChangeEventDetails(REASONS.listNavigation, event.nativeEvent)); if (isHTMLElement(domReferenceElement)) { if (virtual) { tree?.events.emit('virtualfocus', domReferenceElement); } else { domReferenceElement.focus(); } } return; } const currentIndex = indexRef.current; const minIndex = getMinListIndex(listRef, disabledIndices); const maxIndex = getMaxListIndex(listRef, disabledIndices); if (!typeableComboboxReference) { if (event.key === 'Home') { stopEvent(event); indexRef.current = minIndex; onNavigate(event); } if (event.key === 'End') { stopEvent(event); indexRef.current = maxIndex; onNavigate(event); } } // Grid navigation. if (cols > 1) { const sizes = itemSizes || Array.from({ length: listRef.current.length }, () => ({ width: 1, height: 1 })); // To calculate movements on the grid, we use hypothetical cell indices // as if every item was 1x1, then convert back to real indices. const cellMap = createGridCellMap(sizes, cols, dense); const minGridIndex = cellMap.findIndex(index => index != null && !isListIndexDisabled(listRef, index, disabledIndices)); // last enabled index const maxGridIndex = cellMap.reduce((foundIndex, index, cellIndex) => index != null && !isListIndexDisabled(listRef, index, disabledIndices) ? cellIndex : foundIndex, -1); const index = cellMap[getGridNavigatedIndex({ current: cellMap.map(itemIndex => itemIndex != null ? listRef.current[itemIndex] : null) }, { event, orientation, loopFocus, rtl, cols, // treat undefined (empty grid spaces) as disabled indices so we // don't end up in them disabledIndices: getGridCellIndices([...((typeof disabledIndices !== 'function' ? disabledIndices : null) || listRef.current.map((_, listIndex) => isListIndexDisabled(listRef, listIndex, disabledIndices) ? listIndex : undefined)), undefined], cellMap), minIndex: minGridIndex, maxIndex: maxGridIndex, prevIndex: getGridCellIndexOfCorner(indexRef.current > maxIndex ? minIndex : indexRef.current, sizes, cellMap, cols, // use a corner matching the edge closest to the direction // we're moving in so we don't end up in the same item. Prefer // top/left over bottom/right. // eslint-disable-next-line no-nested-ternary event.key === ARROW_DOWN ? 'bl' : event.key === (rtl ? ARROW_LEFT : ARROW_RIGHT) ? 'tr' : 'tl'), stopEvent: true })]; if (index != null) { indexRef.current = index; onNavigate(event); } if (orientation === 'both') { return; } } if (isMainOrientationKey(event.key, orientation)) { stopEvent(event); // Reset the index if no item is focused. if (open && !virtual && activeElement(event.currentTarget.ownerDocument) === event.currentTarget) { indexRef.current = isMainOrientationToEndKey(event.key, orientation, rtl) ? minIndex : maxIndex; onNavigate(event); return; } if (isMainOrientationToEndKey(event.key, orientation, rtl)) { if (loopFocus) { if (currentIndex >= maxIndex) { if (allowEscape && currentIndex !== listRef.current.length) { indexRef.current = -1; } else { // Give time for virtualizers to update the listRef. forceSyncFocusRef.current = false; indexRef.current = minIndex; } } else { indexRef.current = findNonDisabledListIndex(listRef, { startingIndex: currentIndex, disabledIndices }); } } else { indexRef.current = Math.min(maxIndex, findNonDisabledListIndex(listRef, { startingIndex: currentIndex, disabledIndices })); } } else if (loopFocus) { if (currentIndex <= minIndex) { if (allowEscape && currentIndex !== -1) { indexRef.current = listRef.current.length; } else { // Give time for virtualizers to update the listRef. forceSyncFocusRef.current = false; indexRef.current = maxIndex; } } else { indexRef.current = findNonDisabledListIndex(listRef, { startingIndex: currentIndex, decrement: true, disabledIndices }); } } else { indexRef.current = Math.max(minIndex, findNonDisabledListIndex(listRef, { startingIndex: currentIndex, decrement: true, disabledIndices })); } if (isIndexOutOfListBounds(listRef, indexRef.current)) { indexRef.current = -1; } onNavigate(event); } }); const ariaActiveDescendantProp = React.useMemo(() => { return virtual && open && hasActiveIndex && { 'aria-activedescendant': `${id}-${activeIndex}` }; }, [virtual, open, hasActiveIndex, id, activeIndex]); const floating = React.useMemo(() => { return { 'aria-orientation': orientation === 'both' ? undefined : orientation, ...(!typeableComboboxReference ? ariaActiveDescendantProp : {}), onKeyDown(event) { // Close submenu on Shift+Tab if (event.key === 'Tab' && event.shiftKey && open && !virtual) { // If the event originated from within a nested element (e.g., a Dialog opened from // within the menu), don't close the menu. The nested element has its own focus // management and should handle the Tab key. const target = getTarget(event.nativeEvent); if (target && !contains(floatingFocusElementRef.current, target)) { return; } stopEvent(event); store.setOpen(false, createChangeEventDetails(REASONS.focusOut, event.nativeEvent)); if (isHTMLElement(domReferenceElement)) { domReferenceElement.focus(); } return; } commonOnKeyDown(event); }, onPointerMove() { isPointerModalityRef.current = true; } }; }, [ariaActiveDescendantProp, commonOnKeyDown, floatingFocusElementRef, orientation, typeableComboboxReference, store, open, virtual, domReferenceElement]); const trigger = React.useMemo(() => { function checkVirtualMouse(event) { if (focusItemOnOpen === 'auto' && isVirtualClick(event.nativeEvent)) { focusItemOnOpenRef.current = !virtual; } } function checkVirtualPointer(event) { // `pointerdown` fires first, reset the state then perform the checks. focusItemOnOpenRef.current = focusItemOnOpen; if (focusItemOnOpen === 'auto' && isVirtualPointerEvent(event.nativeEvent)) { focusItemOnOpenRef.current = true; } } return { onKeyDown(event) { // non-reactive open state (to prevent re-creation of the handler) const currentOpen = store.select('open'); isPointerModalityRef.current = false; const isArrowKey = event.key.startsWith('Arrow'); const isParentCrossOpenKey = isCrossOrientationOpenKey(event.key, getParentOrientation(), rtl); const isMainKey = isMainOrientationKey(event.key, orientation); const isNavigationKey = (nested ? isParentCrossOpenKey : isMainKey) || event.key === 'Enter' || event.key.trim() === ''; if (virtual && currentOpen) { return commonOnKeyDown(event); } // If a floating element should not open on arrow key down, avoid // setting `activeIndex` while it's closed. if (!currentOpen && !openOnArrowKeyDown && isArrowKey) { return undefined; } if (isNavigationKey) { const isParentMainKey = isMainOrientationKey(event.key, getParentOrientation()); keyRef.current = nested && isParentMainKey ? null : event.key; } if (nested) { if (isParentCrossOpenKey) { stopEvent(event); if (currentOpen) { indexRef.current = getMinListIndex(listRef, disabledIndicesRef.current); onNavigate(event); } else { store.setOpen(true, createChangeEventDetails(REASONS.listNavigation, event.nativeEvent, event.currentTarget)); } } return undefined; } if (isMainKey) { if (selectedIndexRef.current != null) { indexRef.current = selectedIndexRef.current; } stopEvent(event); if (!currentOpen && openOnArrowKeyDown) { store.setOpen(true, createChangeEventDetails(REASONS.listNavigation, event.nativeEvent, event.currentTarget)); } else { commonOnKeyDown(event); } if (currentOpen) { onNavigate(event); } } return undefined; }, onFocus(event) { if (store.select('open') && !virtual) { indexRef.current = -1; onNavigate(event); } }, onPointerDown: checkVirtualPointer, onPointerEnter: checkVirtualPointer, onMouseDown: checkVirtualMouse, onClick: checkVirtualMouse }; }, [commonOnKeyDown, disabledIndicesRef, focusItemOnOpen, listRef, nested, onNavigate, store, openOnArrowKeyDown, orientation, getParentOrientation, rtl, selectedIndexRef, virtual]); const reference = React.useMemo(() => { return { ...ariaActiveDescendantProp, ...trigger }; }, [ariaActiveDescendantProp, trigger]); return React.useMemo(() => enabled ? { reference, floating, item, trigger } : {}, [enabled, reference, floating, trigger, item]); }