@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.
574 lines (563 loc) • 22.8 kB
JavaScript
;
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.ESCAPE = void 0;
exports.useListNavigation = useListNavigation;
var React = _interopRequireWildcard(require("react"));
var _dom = require("@floating-ui/utils/dom");
var _useLatestRef = require("@base-ui-components/utils/useLatestRef");
var _useEventCallback = require("@base-ui-components/utils/useEventCallback");
var _useIsoLayoutEffect = require("@base-ui-components/utils/useIsoLayoutEffect");
var _utils = require("../utils");
var _FloatingTree = require("../components/FloatingTree");
var _enqueueFocus = require("../utils/enqueueFocus");
var _constants = require("../utils/constants");
const ESCAPE = exports.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 === _constants.ARROW_UP || key === _constants.ARROW_DOWN;
const horizontal = key === _constants.ARROW_LEFT || key === _constants.ARROW_RIGHT;
return doSwitch(orientation, vertical, horizontal);
}
function isMainOrientationToEndKey(key, orientation, rtl) {
const vertical = key === _constants.ARROW_DOWN;
const horizontal = rtl ? key === _constants.ARROW_LEFT : key === _constants.ARROW_RIGHT;
return doSwitch(orientation, vertical, horizontal) || key === 'Enter' || key === ' ' || key === '';
}
function isCrossOrientationOpenKey(key, orientation, rtl) {
const vertical = rtl ? key === _constants.ARROW_LEFT : key === _constants.ARROW_RIGHT;
const horizontal = key === _constants.ARROW_DOWN;
return doSwitch(orientation, vertical, horizontal);
}
function isCrossOrientationCloseKey(key, orientation, rtl, cols) {
const vertical = rtl ? key === _constants.ARROW_RIGHT : key === _constants.ARROW_LEFT;
const horizontal = key === _constants.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
*/
function useListNavigation(context, props) {
const {
open,
onOpenChange,
elements,
floatingId
} = context;
const {
listRef,
activeIndex,
onNavigate: onNavigateProp = () => {},
enabled = true,
selectedIndex = null,
allowEscape = false,
loop = false,
nested = false,
rtl = false,
virtual = false,
focusItemOnOpen = 'auto',
focusItemOnHover = true,
openOnArrowKeyDown = true,
disabledIndices = undefined,
orientation = 'vertical',
parentOrientation,
cols = 1,
scrollItemIntoView = true,
virtualItemRef,
itemSizes,
dense = false
} = props;
if (process.env.NODE_ENV !== 'production') {
if (allowEscape) {
if (!loop) {
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 = (0, _utils.getFloatingFocusElement)(elements.floating);
const floatingFocusElementRef = (0, _useLatestRef.useLatestRef)(floatingFocusElement);
const parentId = (0, _FloatingTree.useFloatingParentNodeId)();
const tree = (0, _FloatingTree.useFloatingTree)();
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
context.dataRef.current.orientation = orientation;
}, [context, orientation]);
const typeableComboboxReference = (0, _utils.isTypeableCombobox)(elements.domReference);
const focusItemOnOpenRef = React.useRef(focusItemOnOpen);
const indexRef = React.useRef(selectedIndex ?? -1);
const keyRef = React.useRef(null);
const isPointerModalityRef = React.useRef(true);
const onNavigate = (0, _useEventCallback.useEventCallback)(() => {
onNavigateProp(indexRef.current === -1 ? null : indexRef.current);
});
const previousOnNavigateRef = React.useRef(onNavigate);
const previousMountedRef = React.useRef(!!elements.floating);
const previousOpenRef = React.useRef(open);
const forceSyncFocusRef = React.useRef(false);
const forceScrollIntoViewRef = React.useRef(false);
const disabledIndicesRef = (0, _useLatestRef.useLatestRef)(disabledIndices);
const latestOpenRef = (0, _useLatestRef.useLatestRef)(open);
const scrollItemIntoViewRef = (0, _useLatestRef.useLatestRef)(scrollItemIntoView);
const selectedIndexRef = (0, _useLatestRef.useLatestRef)(selectedIndex);
const [activeId, setActiveId] = React.useState();
const focusItem = (0, _useEventCallback.useEventCallback)(() => {
function runFocus(item) {
if (virtual) {
if (item.id?.endsWith('-fui-option')) {
item.id = `${floatingId}-${Math.random().toString(16).slice(2, 10)}`;
}
setActiveId(item.id);
tree?.events.emit('virtualfocus', item);
if (virtualItemRef) {
virtualItemRef.current = item;
}
} else {
(0, _enqueueFocus.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 =
// eslint-disable-next-line @typescript-eslint/no-use-before-define
scrollIntoViewOptions && 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.
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
if (!enabled) {
return;
}
if (open && elements.floating) {
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;
indexRef.current = selectedIndex;
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, elements.floating, selectedIndex, onNavigate]);
// Sync `activeIndex` to be the focused item while the floating element is
// open.
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
if (!enabled) {
return;
}
if (!open) {
return;
}
if (!elements.floating) {
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 {
indexRef.current = keyRef.current == null || isMainOrientationToEndKey(keyRef.current, orientation, rtl) || nested ? (0, _utils.getMinListIndex)(listRef, disabledIndicesRef.current) : (0, _utils.getMaxListIndex)(listRef, disabledIndicesRef.current);
keyRef.current = null;
onNavigate();
}
};
waitForListPopulated();
}
} else if (!(0, _utils.isIndexOutOfListBounds)(listRef, activeIndex)) {
indexRef.current = activeIndex;
focusItem();
forceScrollIntoViewRef.current = false;
}
}, [enabled, open, elements.floating, 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.
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
if (!enabled || elements.floating || !tree || virtual || !previousMountedRef.current) {
return;
}
const nodes = tree.nodesRef.current;
const parent = nodes.find(node => node.id === parentId)?.context?.elements.floating;
const activeEl = (0, _utils.activeElement)((0, _utils.getDocument)(elements.floating));
const treeContainsActiveEl = nodes.some(node => node.context && (0, _utils.contains)(node.context.elements.floating, activeEl));
if (parent && !treeContainsActiveEl && isPointerModalityRef.current) {
parent.focus({
preventScroll: true
});
}
}, [enabled, elements.floating, tree, parentId, virtual]);
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
previousOnNavigateRef.current = onNavigate;
previousOpenRef.current = open;
previousMountedRef.current = !!elements.floating;
});
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
if (!open) {
keyRef.current = null;
focusItemOnOpenRef.current = focusItemOnOpen;
}
}, [open, focusItemOnOpen]);
const hasActiveIndex = activeIndex != null;
const item = React.useMemo(() => {
function syncCurrentTarget(currentTarget) {
if (!latestOpenRef.current) {
return;
}
const index = listRef.current.indexOf(currentTarget);
if (index !== -1 && indexRef.current !== index) {
indexRef.current = index;
onNavigate();
}
}
const itemProps = {
onFocus({
currentTarget
}) {
forceSyncFocusRef.current = true;
syncCurrentTarget(currentTarget);
},
onClick: ({
currentTarget
}) => currentTarget.focus({
preventScroll: true
}),
// Safari
onMouseMove({
currentTarget
}) {
forceSyncFocusRef.current = true;
forceScrollIntoViewRef.current = false;
if (focusItemOnHover) {
syncCurrentTarget(currentTarget);
}
},
onPointerLeave({
pointerType
}) {
if (!isPointerModalityRef.current || pointerType === 'touch') {
return;
}
forceSyncFocusRef.current = true;
if (!focusItemOnHover) {
return;
}
indexRef.current = -1;
onNavigate();
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 = (0, _useEventCallback.useEventCallback)(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())) {
(0, _utils.stopEvent)(event);
}
onOpenChange(false, event.nativeEvent, 'list-navigation');
if ((0, _dom.isHTMLElement)(elements.domReference)) {
if (virtual) {
tree?.events.emit('virtualfocus', elements.domReference);
} else {
elements.domReference.focus();
}
}
return;
}
const currentIndex = indexRef.current;
const minIndex = (0, _utils.getMinListIndex)(listRef, disabledIndices);
const maxIndex = (0, _utils.getMaxListIndex)(listRef, disabledIndices);
if (!typeableComboboxReference) {
if (event.key === 'Home') {
(0, _utils.stopEvent)(event);
indexRef.current = minIndex;
onNavigate();
}
if (event.key === 'End') {
(0, _utils.stopEvent)(event);
indexRef.current = maxIndex;
onNavigate();
}
}
// 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 = (0, _utils.createGridCellMap)(sizes, cols, dense);
const minGridIndex = cellMap.findIndex(index => index != null && !(0, _utils.isListIndexDisabled)(listRef, index, disabledIndices));
// last enabled index
const maxGridIndex = cellMap.reduce((foundIndex, index, cellIndex) => index != null && !(0, _utils.isListIndexDisabled)(listRef, index, disabledIndices) ? cellIndex : foundIndex, -1);
const index = cellMap[(0, _utils.getGridNavigatedIndex)({
current: cellMap.map(itemIndex => itemIndex != null ? listRef.current[itemIndex] : null)
}, {
event,
orientation,
loop,
rtl,
cols,
// treat undefined (empty grid spaces) as disabled indices so we
// don't end up in them
disabledIndices: (0, _utils.getGridCellIndices)([...((typeof disabledIndices !== 'function' ? disabledIndices : null) || listRef.current.map((_, listIndex) => (0, _utils.isListIndexDisabled)(listRef, listIndex, disabledIndices) ? listIndex : undefined)), undefined], cellMap),
minIndex: minGridIndex,
maxIndex: maxGridIndex,
prevIndex: (0, _utils.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 === _constants.ARROW_DOWN ? 'bl' : event.key === (rtl ? _constants.ARROW_LEFT : _constants.ARROW_RIGHT) ? 'tr' : 'tl'),
stopEvent: true
})];
if (index != null) {
indexRef.current = index;
onNavigate();
}
if (orientation === 'both') {
return;
}
}
if (isMainOrientationKey(event.key, orientation)) {
(0, _utils.stopEvent)(event);
// Reset the index if no item is focused.
if (open && !virtual && (0, _utils.activeElement)(event.currentTarget.ownerDocument) === event.currentTarget) {
indexRef.current = isMainOrientationToEndKey(event.key, orientation, rtl) ? minIndex : maxIndex;
onNavigate();
return;
}
if (isMainOrientationToEndKey(event.key, orientation, rtl)) {
if (loop) {
indexRef.current =
// eslint-disable-next-line no-nested-ternary
currentIndex >= maxIndex ? allowEscape && currentIndex !== listRef.current.length ? -1 : minIndex : (0, _utils.findNonDisabledListIndex)(listRef, {
startingIndex: currentIndex,
disabledIndices
});
} else {
indexRef.current = Math.min(maxIndex, (0, _utils.findNonDisabledListIndex)(listRef, {
startingIndex: currentIndex,
disabledIndices
}));
}
} else if (loop) {
indexRef.current =
// eslint-disable-next-line no-nested-ternary
currentIndex <= minIndex ? allowEscape && currentIndex !== -1 ? listRef.current.length : maxIndex : (0, _utils.findNonDisabledListIndex)(listRef, {
startingIndex: currentIndex,
decrement: true,
disabledIndices
});
} else {
indexRef.current = Math.max(minIndex, (0, _utils.findNonDisabledListIndex)(listRef, {
startingIndex: currentIndex,
decrement: true,
disabledIndices
}));
}
if ((0, _utils.isIndexOutOfListBounds)(listRef, indexRef.current)) {
indexRef.current = -1;
}
onNavigate();
}
});
const ariaActiveDescendantProp = React.useMemo(() => {
return virtual && open && hasActiveIndex && {
'aria-activedescendant': activeId
};
}, [virtual, open, hasActiveIndex, activeId]);
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) {
(0, _utils.stopEvent)(event);
onOpenChange(false, event.nativeEvent, 'list-navigation');
if ((0, _dom.isHTMLElement)(elements.domReference)) {
elements.domReference.focus();
}
return;
}
commonOnKeyDown(event);
},
onPointerMove() {
isPointerModalityRef.current = true;
}
};
}, [ariaActiveDescendantProp, commonOnKeyDown, orientation, typeableComboboxReference, onOpenChange, open, virtual, elements.domReference]);
const reference = React.useMemo(() => {
function checkVirtualMouse(event) {
if (focusItemOnOpen === 'auto' && (0, _utils.isVirtualClick)(event.nativeEvent)) {
focusItemOnOpenRef.current = true;
}
}
function checkVirtualPointer(event) {
// `pointerdown` fires first, reset the state then perform the checks.
focusItemOnOpenRef.current = focusItemOnOpen;
if (focusItemOnOpen === 'auto' && (0, _utils.isVirtualPointerEvent)(event.nativeEvent)) {
focusItemOnOpenRef.current = true;
}
}
return {
...ariaActiveDescendantProp,
onKeyDown(event) {
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 && open) {
return commonOnKeyDown(event);
}
// If a floating element should not open on arrow key down, avoid
// setting `activeIndex` while it's closed.
if (!open && !openOnArrowKeyDown && isArrowKey) {
return undefined;
}
if (isNavigationKey) {
const isParentMainKey = isMainOrientationKey(event.key, getParentOrientation());
keyRef.current = nested && isParentMainKey ? null : event.key;
}
if (nested) {
if (isParentCrossOpenKey) {
(0, _utils.stopEvent)(event);
if (open) {
indexRef.current = (0, _utils.getMinListIndex)(listRef, disabledIndicesRef.current);
onNavigate();
} else {
onOpenChange(true, event.nativeEvent, 'list-navigation');
}
}
return undefined;
}
if (isMainKey) {
if (selectedIndex != null) {
indexRef.current = selectedIndex;
}
(0, _utils.stopEvent)(event);
if (!open && openOnArrowKeyDown) {
onOpenChange(true, event.nativeEvent, 'list-navigation');
} else {
commonOnKeyDown(event);
}
if (open) {
onNavigate();
}
}
return undefined;
},
onFocus() {
if (open && !virtual) {
indexRef.current = -1;
onNavigate();
}
},
onPointerDown: checkVirtualPointer,
onPointerEnter: checkVirtualPointer,
onMouseDown: checkVirtualMouse,
onClick: checkVirtualMouse
};
}, [ariaActiveDescendantProp, commonOnKeyDown, disabledIndicesRef, focusItemOnOpen, listRef, nested, onNavigate, onOpenChange, open, openOnArrowKeyDown, orientation, getParentOrientation, rtl, selectedIndex, virtual]);
return React.useMemo(() => enabled ? {
reference,
floating,
item
} : {}, [enabled, reference, floating, item]);
}