@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.
148 lines (145 loc) • 6.64 kB
JavaScript
'use client';
import * as React from 'react';
import { mergeReactProps } from '../../utils/mergeReactProps.js';
import { useEventCallback } from '../../utils/useEventCallback.js';
import { useForkRef } from '../../utils/useForkRef.js';
import { ALL_KEYS, ARROW_KEYS, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP, HOME, END, buildCellMap, findNonDisabledIndex, getCellIndexOfCorner, getCellIndices, getGridNavigatedIndex, getMaxIndex, getMinIndex, getTextDirection, HORIZONTAL_KEYS, HORIZONTAL_KEYS_WITH_EXTRA_KEYS, isDisabled, isIndexOutOfBounds, VERTICAL_KEYS, VERTICAL_KEYS_WITH_EXTRA_KEYS } from '../composite.js';
// Advanced options of Composite, to be implemented later if needed.
const disabledIndices = undefined;
/**
* @ignore - internal hook.
*/
export function useCompositeRoot(params) {
const {
itemSizes,
cols = 1,
loop = true,
dense = false,
orientation = 'both',
direction,
highlightedIndex: externalHighlightedIndex,
onHighlightedIndexChange: externalSetHighlightedIndex,
rootRef: externalRef,
enableHomeAndEndKeys = false,
stopEventPropagation = false
} = params;
const [internalHighlightedIndex, internalSetHighlightedIndex] = React.useState(0);
const isGrid = cols > 1;
const highlightedIndex = externalHighlightedIndex ?? internalHighlightedIndex;
const onHighlightedIndexChange = useEventCallback(externalSetHighlightedIndex ?? internalSetHighlightedIndex);
const textDirectionRef = React.useRef(direction ?? null);
const rootRef = React.useRef(null);
const mergedRef = useForkRef(rootRef, externalRef);
const elementsRef = React.useRef([]);
const getRootProps = React.useCallback((externalProps = {}) => mergeReactProps(externalProps, {
'aria-orientation': orientation === 'both' ? undefined : orientation,
ref: mergedRef,
onKeyDown(event) {
const RELEVANT_KEYS = enableHomeAndEndKeys ? ALL_KEYS : ARROW_KEYS;
if (!RELEVANT_KEYS.includes(event.key)) {
return;
}
const element = rootRef.current;
if (!element) {
return;
}
if (textDirectionRef?.current == null) {
textDirectionRef.current = getTextDirection(element);
}
const isRtl = textDirectionRef.current === 'rtl';
let nextIndex = highlightedIndex;
const minIndex = getMinIndex(elementsRef, disabledIndices);
const maxIndex = getMaxIndex(elementsRef, disabledIndices);
if (isGrid) {
const sizes = itemSizes || Array.from({
length: elementsRef.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 = buildCellMap(sizes, cols, dense);
const minGridIndex = cellMap.findIndex(index => index != null && !isDisabled(elementsRef.current, index, disabledIndices));
// last enabled index
const maxGridIndex = cellMap.reduce((foundIndex, index, cellIndex) => index != null && !isDisabled(elementsRef.current, index, disabledIndices) ? cellIndex : foundIndex, -1);
nextIndex = cellMap[getGridNavigatedIndex({
current: cellMap.map(itemIndex => itemIndex ? elementsRef.current[itemIndex] : null)
}, {
event,
orientation,
loop,
cols,
// treat undefined (empty grid spaces) as disabled indices so we
// don't end up in them
disabledIndices: getCellIndices([...(disabledIndices || elementsRef.current.map((_, index) => isDisabled(elementsRef.current, index) ? index : undefined)), undefined], cellMap),
minIndex: minGridIndex,
maxIndex: maxGridIndex,
prevIndex: getCellIndexOfCorner(highlightedIndex > maxIndex ? minIndex : highlightedIndex, 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 === ARROW_RIGHT ? 'tr' : 'tl'),
rtl: isRtl
})]; // navigated cell will never be nullish
}
const horizontalEndKey = isRtl ? ARROW_LEFT : ARROW_RIGHT;
const toEndKeys = {
horizontal: [horizontalEndKey],
vertical: [ARROW_DOWN],
both: [horizontalEndKey, ARROW_DOWN]
}[orientation];
const horizontalStartKey = isRtl ? ARROW_RIGHT : ARROW_LEFT;
const toStartKeys = {
horizontal: [horizontalStartKey],
vertical: [ARROW_UP],
both: [horizontalStartKey, ARROW_UP]
}[orientation];
const preventedKeys = isGrid ? RELEVANT_KEYS : {
horizontal: enableHomeAndEndKeys ? HORIZONTAL_KEYS_WITH_EXTRA_KEYS : HORIZONTAL_KEYS,
vertical: enableHomeAndEndKeys ? VERTICAL_KEYS_WITH_EXTRA_KEYS : VERTICAL_KEYS,
both: RELEVANT_KEYS
}[orientation];
if (enableHomeAndEndKeys) {
if (event.key === HOME) {
nextIndex = minIndex;
} else if (event.key === END) {
nextIndex = maxIndex;
}
}
if (nextIndex === highlightedIndex && [...toEndKeys, ...toStartKeys].includes(event.key)) {
if (loop && nextIndex === maxIndex && toEndKeys.includes(event.key)) {
nextIndex = minIndex;
} else if (loop && nextIndex === minIndex && toStartKeys.includes(event.key)) {
nextIndex = maxIndex;
} else {
nextIndex = findNonDisabledIndex(elementsRef, {
startingIndex: nextIndex,
decrement: toStartKeys.includes(event.key),
disabledIndices
});
}
}
if (nextIndex !== highlightedIndex && !isIndexOutOfBounds(elementsRef, nextIndex)) {
if (stopEventPropagation) {
event.stopPropagation();
}
if (preventedKeys.includes(event.key)) {
event.preventDefault();
}
onHighlightedIndexChange(nextIndex);
// Wait for FocusManager `returnFocus` to execute.
queueMicrotask(() => {
elementsRef.current[nextIndex]?.focus();
});
}
}
}), [highlightedIndex, stopEventPropagation, cols, dense, elementsRef, isGrid, itemSizes, loop, mergedRef, onHighlightedIndexChange, orientation, enableHomeAndEndKeys]);
return React.useMemo(() => ({
getRootProps,
highlightedIndex,
onHighlightedIndexChange,
elementsRef
}), [getRootProps, highlightedIndex, onHighlightedIndexChange, elementsRef]);
}