UNPKG

@crossed/primitive

Version:

A universal & performant styling library for React Native, Next.js & React

242 lines (241 loc) 8.58 kB
"use client"; import { jsx } from "react/jsx-runtime"; import * as React from "react"; import { createCollection } from "./Collections"; import { composeEventHandlers, createScope, useCallbackRef, useComposedRefs, useDirection, useUncontrolled, withStaticProperties } from "@crossed/core"; import { Primitive } from "./Primitive"; import { Slot } from "./Slot"; const ENTRY_FOCUS = "rovingFocusGroup.onEntryFocus"; const EVENT_OPTIONS = { bubbles: false, cancelable: true }; const GROUP_NAME = "RovingFocusGroup"; const [Collection, useCollection] = createCollection( GROUP_NAME ); const [RovingFocusProvider, useRovingFocusContext] = createScope({}); const RovingFocusGroup = React.forwardRef((props, forwardedRef) => { return /* @__PURE__ */ jsx(Collection.Provider, { children: /* @__PURE__ */ jsx(Collection.Slot, { children: /* @__PURE__ */ jsx(RovingFocusGroupImpl, { ...props, ref: forwardedRef }) }) }); }); RovingFocusGroup.displayName = GROUP_NAME; const RovingFocusGroupImpl = React.forwardRef((props, forwardedRef) => { const { orientation, loop = false, dir, currentTabStopId: currentTabStopIdProp, defaultCurrentTabStopId, onCurrentTabStopIdChange, onEntryFocus, ...groupProps } = props; const ref = React.useRef(null); const composedRefs = useComposedRefs(forwardedRef, ref); const direction = useDirection(dir); const [currentTabStopId = null, setCurrentTabStopId] = useUncontrolled({ value: currentTabStopIdProp, defaultValue: defaultCurrentTabStopId, onChange: onCurrentTabStopIdChange }); const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false); const handleEntryFocus = useCallbackRef(onEntryFocus); const getItems = useCollection(); const isClickFocusRef = React.useRef(false); const [focusableItemsCount, setFocusableItemsCount] = React.useState(0); React.useEffect(() => { const node = ref.current; if (node) { node.addEventListener(ENTRY_FOCUS, handleEntryFocus); return () => node.removeEventListener(ENTRY_FOCUS, handleEntryFocus); } return () => { }; }, [handleEntryFocus]); return /* @__PURE__ */ jsx( RovingFocusProvider, { orientation, dir: direction, loop, currentTabStopId, onItemFocus: React.useCallback( (tabStopId) => setCurrentTabStopId(tabStopId), [setCurrentTabStopId] ), onItemShiftTab: React.useCallback(() => setIsTabbingBackOut(true), []), onFocusableItemAdd: React.useCallback( () => setFocusableItemsCount((prevCount) => prevCount + 1), [] ), onFocusableItemRemove: React.useCallback( () => setFocusableItemsCount((prevCount) => prevCount - 1), [] ), children: /* @__PURE__ */ jsx( Primitive.div, { tabIndex: isTabbingBackOut || focusableItemsCount === 0 ? -1 : 0, "data-orientation": orientation, ...groupProps, ref: composedRefs, style: { outline: "none", ...props.style }, onMouseDown: composeEventHandlers(props.onMouseDown, () => { isClickFocusRef.current = true; }), onFocus: composeEventHandlers(props.onFocus, (event) => { const isKeyboardFocus = !isClickFocusRef.current; if (event.target === event.currentTarget && isKeyboardFocus && !isTabbingBackOut) { const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); event.currentTarget.dispatchEvent(entryFocusEvent); if (!entryFocusEvent.defaultPrevented) { const items = getItems().filter((item) => item.focusable); const activeItem = items.find((item) => item.active); const currentItem = items.find( (item) => item.id === currentTabStopId ); const candidateItems = [activeItem, currentItem, ...items].filter( Boolean ); const candidateNodes = candidateItems.map( (item) => item.ref.current ); focusFirst(candidateNodes); } } isClickFocusRef.current = false; }), onBlur: composeEventHandlers( props.onBlur, () => setIsTabbingBackOut(false) ) } ) } ); }); const ITEM_NAME = "RovingFocusGroupItem"; const RovingFocusGroupItem = React.forwardRef((props, forwardedRef) => { const { focusable = true, active = false, tabStopId, ...itemProps } = props; const autoId = React.useId(); const id = tabStopId || autoId; const context = useRovingFocusContext(); const isCurrentTabStop = context.currentTabStopId === id; const getItems = useCollection(); const { onFocusableItemAdd, onFocusableItemRemove } = context; React.useEffect(() => { if (focusable) { onFocusableItemAdd == null ? void 0 : onFocusableItemAdd(); return () => onFocusableItemRemove == null ? void 0 : onFocusableItemRemove(); } return () => { }; }, [focusable, onFocusableItemAdd, onFocusableItemRemove]); return /* @__PURE__ */ jsx(Collection.ItemSlot, { id, focusable, active, children: /* @__PURE__ */ jsx( Slot, { tabIndex: isCurrentTabStop ? 0 : -1, "data-orientation": context.orientation, ...itemProps, ref: forwardedRef, onMouseDown: composeEventHandlers(props.onMouseDown, (event) => { var _a; if (!focusable) event.preventDefault(); else (_a = context.onItemFocus) == null ? void 0 : _a.call(context, id); }), onFocus: composeEventHandlers( props.onFocus, () => { var _a; return (_a = context.onItemFocus) == null ? void 0 : _a.call(context, id); } ), onKeyDown: composeEventHandlers(props.onKeyDown, (event) => { var _a; if (event.key === "Tab" && event.shiftKey) { (_a = context.onItemShiftTab) == null ? void 0 : _a.call(context); return; } if (event.target !== event.currentTarget) return; const focusIntent = getFocusIntent( event, context.orientation, context.dir ); if (focusIntent !== void 0) { event.preventDefault(); const items = getItems().filter((item) => item.focusable); let candidateNodes = items.map((item) => item.ref.current); if (focusIntent === "last") candidateNodes.reverse(); else if (focusIntent === "prev" || focusIntent === "next") { if (focusIntent === "prev") candidateNodes.reverse(); const currentIndex = candidateNodes.indexOf(event.currentTarget); candidateNodes = context.loop ? wrapArray(candidateNodes, currentIndex + 1) : candidateNodes.slice(currentIndex + 1); } setTimeout(() => focusFirst(candidateNodes)); } }) } ) }); }); RovingFocusGroupItem.displayName = ITEM_NAME; const MAP_KEY_TO_FOCUS_INTENT = { ArrowLeft: "prev", ArrowUp: "prev", ArrowRight: "next", ArrowDown: "next", PageUp: "first", Home: "first", PageDown: "last", End: "last" }; function getDirectionAwareKey(key, dir) { if (dir !== "rtl") return key; return key === "ArrowLeft" ? "ArrowRight" : key === "ArrowRight" ? "ArrowLeft" : key; } function getFocusIntent(event, orientation, dir) { const key = getDirectionAwareKey(event.key, dir); if (orientation === "vertical" && ["ArrowLeft", "ArrowRight"].includes(key)) return void 0; if (orientation === "horizontal" && ["ArrowUp", "ArrowDown"].includes(key)) return void 0; return MAP_KEY_TO_FOCUS_INTENT[key]; } function focusFirst(candidates) { var _a; const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; for (const candidate of candidates) { if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; (_a = candidate.focus) == null ? void 0 : _a.call(candidate); if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; } } function wrapArray(array, startIndex) { return array.map((_, index) => array[(startIndex + index) % array.length]); } const Root = RovingFocusGroup; const Item = RovingFocusGroupItem; const RovingFocus = withStaticProperties(Root, { Item }); export { Item, Root, RovingFocus, RovingFocusGroup, RovingFocusGroupItem, RovingFocusProvider, useRovingFocusContext }; //# sourceMappingURL=RovingFocus.js.map