{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "navigation", "title": "Navigation", "description": "Standalone keyboard navigation for role-based lists and tabs", "dependencies": [], "registryDependencies": [], "files": [ { "path": "src/hooks/use-navigation.ts", "content": "\"use client\";\n\nimport type { KeyboardEvent, RefObject } from \"react\";\nimport { dispatchNavigationKey, resolveDirectionKeys } from \"./utils/navigation-dispatch\";\nimport {\n composedContains,\n getComposedEventTarget,\n getOwnerView,\n isEditableElement,\n isNode,\n} from \"./utils/element-guards\";\nimport type { NavigationItemType } from \"./utils/navigation-items\";\nimport { useNavigationCore } from \"./utils/navigation-core\";\n\n/** ARIA role or data-contract item type used for navigation item discovery. */\nexport type NavigationRole = NavigationItemType;\n\n/** Options for standalone role-based list navigation. */\nexport interface UseNavigationOptions {\n /** Ref to the container element holding navigable items. */\n containerRef: RefObject;\n /** ARIA role used to query navigable children within the container. */\n role: NavigationRole;\n /** Controlled highlight value. When provided, the hook operates in controlled mode. */\n highlighted?: TValue | null;\n /** Initial highlighted value in uncontrolled mode. */\n defaultHighlighted?: TValue | null;\n /** Called when the controlled highlight value should change. */\n onHighlightChange?: (value: TValue | null) => void;\n /** Called for Space selection and as the Enter fallback when onEnter is not provided. */\n onSelect?: (value: TValue, event: globalThis.KeyboardEvent) => void;\n /** Called for Enter selection, overriding the onSelect Enter fallback when provided. */\n onEnter?: (value: TValue, event: globalThis.KeyboardEvent) => void;\n /** Wrap around when reaching the first or last item. */\n wrap?: boolean;\n /** Whether the navigation hook is active. */\n enabled?: boolean;\n /** Call preventDefault() on handled keyboard events. */\n preventDefault?: boolean;\n /**\n * Called when the user tries to navigate past the first or last item.\n * Receives the direction, originating event, and key that hit the boundary.\n */\n onNavigationBoundaryReached?: (\n direction: \"previous\" | \"next\",\n event: globalThis.KeyboardEvent,\n key: string,\n ) => void;\n /** Custom key names to move highlight up or left. */\n upKeys?: readonly string[];\n /** Custom key names to move highlight down or right. */\n downKeys?: readonly string[];\n /** Navigation axis for default arrow keys. */\n orientation?: \"vertical\" | \"horizontal\";\n /** Skip aria-disabled, data-disabled, and native disabled items during navigation. */\n skipDisabled?: boolean;\n /**\n * Move DOM focus to the next item. When false, focus stays on the composite owner, which should\n * expose the highlighted option through aria-activedescendant.\n */\n moveFocus?: boolean;\n /** Ignore items owned by nested composite containers such as nested listboxes. */\n scopeToContainer?: boolean;\n /** Advanced owner selector override for roles without a standard composite owner. */\n ownerSelector?: string | null;\n}\n\n/** Return value from `useNavigation`. */\nexport interface UseNavigationReturn {\n /** The value of the currently highlighted item, or null. */\n highlighted: TValue | null;\n /** Returns true if the given value is the highlighted item. */\n isHighlighted: (value: TValue) => boolean;\n /** Imperatively set the highlighted item. Pass null to clear. */\n highlight: (value: TValue | null) => void;\n /** Keyboard event handler to attach to the container element. */\n onKeyDown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Adds standalone keyboard navigation, selection, and focus tracking to a\n * role-based list. Attach the returned `onKeyDown` handler to the container.\n */\nexport function useNavigation(\n options: UseNavigationOptions,\n): UseNavigationReturn {\n const {\n containerRef,\n enabled = true,\n preventDefault = true,\n orientation = \"vertical\",\n upKeys,\n downKeys,\n onEnter,\n onSelect,\n } = options;\n\n const { resolvedUpKeys, resolvedDownKeys } = resolveDirectionKeys(orientation, upKeys, downKeys);\n\n const {\n highlighted,\n isHighlighted,\n highlight,\n move,\n focusIndex,\n handleSelect,\n handleEnter,\n getElements,\n } = useNavigationCore(options);\n const handlesEnter = Boolean(onEnter || onSelect);\n const handlesSpace = Boolean(onSelect);\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented) return;\n if (!enabled) return;\n if (event.ctrlKey || event.metaKey || event.altKey) return;\n\n const key = event.key;\n const isMoveKey = resolvedUpKeys.includes(key) || resolvedDownKeys.includes(key);\n const isActivationKey = (key === \"Enter\" && handlesEnter) || (key === \" \" && handlesSpace);\n const isSpecialKey = key === \"Home\" || key === \"End\" || isActivationKey;\n if (!isMoveKey && !isSpecialKey) return;\n\n const elements = getElements();\n const target = getComposedEventTarget(event.nativeEvent);\n const isOwnItem =\n isNode(target, getOwnerView(containerRef.current)) &&\n elements.some((el) => composedContains(el, target));\n\n // Editable non-item bubbling into a wrapper that owns the items: let native handle it.\n if (isEditableElement(target)) {\n const currentTarget = event.currentTarget;\n const ownsItems =\n currentTarget != null &&\n elements.length > 0 &&\n elements.every((el) => currentTarget.contains(el));\n if (!isOwnItem && (ownsItems || elements.length === 0)) return;\n }\n\n // Don't swallow Enter/Space/Home/End for a non-navigation control beside the list.\n if (!isOwnItem) {\n const fromContainer = target === containerRef.current || target === event.currentTarget;\n const noItemToActOn = isActivationKey || elements.length === 0;\n if (!fromContainer && noItemToActOn) return;\n }\n\n if (preventDefault) event.preventDefault();\n\n dispatchNavigationKey(key, {\n resolvedUpKeys,\n resolvedDownKeys,\n move: (delta) => move(delta, event.nativeEvent, key),\n focusIndex,\n handleSelect: handlesSpace ? (e) => handleSelect(e) : undefined,\n handleEnter: handlesEnter ? (e) => handleEnter(e) : undefined,\n total: elements.length,\n nativeEvent: event.nativeEvent,\n });\n };\n\n return { highlighted, isHighlighted, highlight, onKeyDown };\n}\n", "type": "registry:hook", "target": "src/hooks/use-navigation.ts" }, { "path": "src/hooks/use-navigation/core.ts", "content": "\"use client\";\n\nimport { type RefObject, useState } from \"react\";\nimport { containsActiveElement } from \"./focusable\";\nimport { getFocusedNavigationValue, getNavigationItems } from \"./navigation-items\";\nimport type { NavigationRole, UseNavigationOptions } from \"../use-navigation\";\n\nexport type UseNavigationCoreOptions = Omit<\n UseNavigationOptions,\n \"enabled\" | \"preventDefault\" | \"orientation\" | \"upKeys\" | \"downKeys\"\n>;\n\nexport interface UseNavigationCoreReturn {\n highlighted: TValue | null;\n isHighlighted: (value: TValue) => boolean;\n highlight: (value: TValue | null) => void;\n move: (delta: 1 | -1, event?: globalThis.KeyboardEvent, key?: string) => void;\n focusIndex: (index: number) => boolean;\n handleSelect: (event: globalThis.KeyboardEvent) => void;\n handleEnter: (event: globalThis.KeyboardEvent) => void;\n getElements: () => HTMLElement[];\n}\n\nfunction queryNavigationElements(\n containerRef: RefObject,\n role: NavigationRole,\n skipDisabled: boolean,\n scopeToContainer: boolean,\n ownerSelector: string | null | undefined,\n): HTMLElement[] {\n return getNavigationItems(containerRef.current, {\n type: role,\n skipDisabled,\n scopeToContainer,\n ownerSelector,\n });\n}\n\nfunction wrapIndex(index: number, length: number, wrap: boolean): number | null {\n if (index < 0) return wrap ? length - 1 : null;\n if (index >= length) return wrap ? 0 : null;\n return index;\n}\n\n/**\n * Shared list-navigation state machine used by the standalone and provider-backed\n * navigation hooks.\n */\nexport function useNavigationCore({\n containerRef,\n role,\n highlighted: controlledHighlighted,\n defaultHighlighted = null,\n onSelect,\n onEnter,\n onHighlightChange,\n wrap = true,\n onNavigationBoundaryReached,\n skipDisabled = true,\n moveFocus = false,\n scopeToContainer = true,\n ownerSelector,\n}: UseNavigationCoreOptions): UseNavigationCoreReturn {\n const [internalHighlighted, setInternalHighlighted] = useState(defaultHighlighted);\n const isControlled = controlledHighlighted !== undefined;\n const highlighted = isControlled ? (controlledHighlighted ?? null) : internalHighlighted;\n\n const setFocusedValue = (nextValue: TValue | null) => {\n if (!isControlled) setInternalHighlighted(nextValue);\n onHighlightChange?.(nextValue);\n };\n\n const getElements = () =>\n queryNavigationElements(containerRef, role, skipDisabled, scopeToContainer, ownerSelector);\n\n const getFocusedIndex = (): number => {\n const elements = getElements();\n if (elements.length === 0) return -1;\n\n const focusedIndex = elements.findIndex(containsActiveElement);\n if (focusedIndex >= 0) return focusedIndex;\n\n if (highlighted !== null) {\n const index = elements.findIndex((el) => el.dataset.value === highlighted);\n if (index >= 0) return index;\n }\n\n return -1;\n };\n\n const getCurrentValue = (): TValue | null => {\n const focusedValue = getFocusedNavigationValue(containerRef.current, {\n type: role,\n skipDisabled,\n scopeToContainer,\n ownerSelector,\n });\n // DOM boundary: data-value is opaque to TS; consumers parameterize TValue.\n if (focusedValue !== null) return focusedValue as TValue;\n\n const elements = getElements();\n\n if (highlighted !== null) {\n return elements.some((el) => el.dataset.value === highlighted) ? highlighted : null;\n }\n\n return null;\n };\n\n const focusIndex = (index: number, knownElements?: HTMLElement[]): boolean => {\n const elements = knownElements ?? getElements();\n const el = elements[index];\n if (!el) return false;\n const nextValue = el.dataset.value;\n if (nextValue === undefined) return false;\n\n el.scrollIntoView?.({ block: \"nearest\" });\n if (moveFocus) {\n el.focus();\n // Native disabled controls can't take DOM focus; report failure to step past.\n if (!containsActiveElement(el)) return false;\n }\n // DOM boundary: data-value is opaque to TS; consumers parameterize TValue.\n setFocusedValue(nextValue as TValue);\n return true;\n };\n\n const move = (delta: 1 | -1, event?: globalThis.KeyboardEvent, key?: string) => {\n const elements = getElements();\n if (elements.length === 0) return;\n\n const current = getFocusedIndex();\n let rawNext = current + delta;\n // Bounded by item count so an all-disabled wrap list can't loop forever.\n for (let attempts = 0; attempts < elements.length; attempts += 1) {\n const next = wrapIndex(rawNext, elements.length, wrap);\n if (next === null) {\n const direction = delta < 0 ? \"previous\" : \"next\";\n if (event && key) onNavigationBoundaryReached?.(direction, event, key);\n return;\n }\n if (next === current) return;\n if (focusIndex(next, elements)) return;\n if (!moveFocus) return;\n rawNext = next + delta;\n }\n };\n\n const handleSelect = (event: globalThis.KeyboardEvent) => {\n const currentValue = getCurrentValue();\n if (currentValue !== null) onSelect?.(currentValue, event);\n };\n\n const handleEnter = (event: globalThis.KeyboardEvent) => {\n const currentValue = getCurrentValue();\n if (currentValue === null) return;\n if (onEnter) onEnter(currentValue, event);\n else onSelect?.(currentValue, event);\n };\n\n const isHighlighted = (v: TValue) => highlighted === v;\n const highlight = (v: TValue | null) => setFocusedValue(v);\n\n return {\n highlighted,\n isHighlighted,\n highlight,\n move,\n focusIndex,\n handleSelect,\n handleEnter,\n getElements,\n };\n}\n", "type": "registry:hook", "target": "src/hooks/utils/navigation-core.ts" }, { "path": "src/core/navigation-dispatch.ts", "content": "const VERTICAL_UP_KEYS = [\"ArrowUp\"] as const;\nconst VERTICAL_DOWN_KEYS = [\"ArrowDown\"] as const;\nconst HORIZONTAL_UP_KEYS = [\"ArrowLeft\"] as const;\nconst HORIZONTAL_DOWN_KEYS = [\"ArrowRight\"] as const;\n\n/** Resolves default previous/next keys for a vertical or horizontal list. */\nexport function resolveDirectionKeys(\n orientation: \"vertical\" | \"horizontal\",\n upKeys?: readonly string[],\n downKeys?: readonly string[],\n): { resolvedUpKeys: readonly string[]; resolvedDownKeys: readonly string[] } {\n return {\n resolvedUpKeys: upKeys ?? (orientation === \"vertical\" ? VERTICAL_UP_KEYS : HORIZONTAL_UP_KEYS),\n resolvedDownKeys:\n downKeys ?? (orientation === \"vertical\" ? VERTICAL_DOWN_KEYS : HORIZONTAL_DOWN_KEYS),\n };\n}\n\n/**\n * Dispatches one navigation key to movement, edge, and activation callbacks.\n * Returns whether the key was handled.\n */\nexport function dispatchNavigationKey(\n key: string,\n ctx: {\n resolvedUpKeys: readonly string[];\n resolvedDownKeys: readonly string[];\n move: (dir: 1 | -1) => void;\n focusIndex: (index: number) => boolean;\n handleSelect?: (event: globalThis.KeyboardEvent) => void;\n handleEnter?: (event: globalThis.KeyboardEvent) => void;\n total: number;\n nativeEvent: globalThis.KeyboardEvent;\n },\n): boolean {\n if (ctx.resolvedUpKeys.includes(key)) {\n ctx.move(-1);\n return true;\n }\n\n if (ctx.resolvedDownKeys.includes(key)) {\n ctx.move(1);\n return true;\n }\n\n switch (key) {\n case \"Home\":\n // Step forward so a native-disabled first item is skipped, matching arrow stepping.\n for (let index = 0; index < ctx.total; index += 1) {\n if (ctx.focusIndex(index)) break;\n }\n return true;\n case \"End\":\n for (let index = ctx.total - 1; index >= 0; index -= 1) {\n if (ctx.focusIndex(index)) break;\n }\n return true;\n case \"Enter\":\n if (!ctx.handleEnter) return false;\n ctx.handleEnter(ctx.nativeEvent);\n return true;\n case \" \":\n if (!ctx.handleSelect) return false;\n ctx.handleSelect(ctx.nativeEvent);\n return true;\n }\n\n return false;\n}\n", "type": "registry:hook", "target": "src/hooks/utils/navigation-dispatch.ts" }, { "path": "src/dom/navigation-items.ts", "content": "import { composedClosest } from \"./element-guards\";\nimport { containsActiveElement, documentOrder, isReachable } from \"./focusable\";\n\n/** Data attribute used by @diffgazer/keys to mark navigable DOM items. */\nexport const NAVIGATION_ITEM_ATTRIBUTE = \"data-diffgazer-navigation-item\";\n\n/** Navigation item types recognized by DOM query helpers and navigation hooks. */\nexport type NavigationItemType =\n | \"radio\"\n | \"checkbox\"\n | \"option\"\n | \"menuitem\"\n | \"menuitemcheckbox\"\n | \"menuitemradio\"\n | \"button\"\n | \"tab\";\n\n/** Query used to discover navigable items inside a container. */\nexport interface NavigationItemQuery {\n /** Item role or data-contract type to query. */\n type: NavigationItemType;\n /**\n * Exclude items that expose or inherit a disabled state: aria-disabled,\n * data-disabled, or native disabled on the item or an ancestor.\n */\n skipDisabled?: boolean;\n /** Exclude items owned by nested composite containers. */\n scopeToContainer?: boolean;\n /** Override the composite owner selector used for scoping, or null to disable owner scoping. */\n ownerSelector?: string | null;\n}\n\nfunction disabledSelector(skipDisabled: boolean): string {\n return skipDisabled ? ':not([aria-disabled=\"true\"]):not([data-disabled]):not(:disabled)' : \"\";\n}\n\n/** True when an aria-disabled/data-disabled ancestor (not the element itself) disables the item. */\nfunction hasDisabledAncestor(element: HTMLElement): boolean {\n const disabledContainer = composedClosest(element, '[aria-disabled=\"true\"],[data-disabled]');\n return disabledContainer !== null && disabledContainer !== element;\n}\n\nfunction findElements(container: HTMLElement, selector: string): HTMLElement[] {\n return Array.from(container.querySelectorAll(selector));\n}\n\nfunction queryAllMatchingGroups(\n container: HTMLElement,\n selectors: string[],\n filter?: (element: HTMLElement) => boolean,\n): HTMLElement[] {\n const seen = new Set();\n const merged: HTMLElement[] = [];\n\n for (const selector of selectors) {\n const elements = findElements(container, selector);\n for (const el of elements) {\n if (seen.has(el)) continue;\n if (filter && !filter(el)) continue;\n seen.add(el);\n merged.push(el);\n }\n }\n\n // Merged results from multiple selectors may interleave; restore DOM order.\n if (merged.length > 1) {\n merged.sort(documentOrder);\n }\n\n return merged;\n}\n\nfunction matchesNavigationDataContract(element: HTMLElement, type: NavigationItemType): boolean {\n const explicitType = element.getAttribute(NAVIGATION_ITEM_ATTRIBUTE);\n return (\n explicitType === null || explicitType === \"\" || explicitType === \"true\" || explicitType === type\n );\n}\n\nfunction buildNavigationSelectors(type: NavigationItemType, skipDisabled: boolean): string[] {\n const disabled = disabledSelector(skipDisabled);\n const nativeRoleSelectors: Partial> = {\n button: [`button[data-value]${disabled}`],\n checkbox: [`input[type=\"checkbox\"][data-value]${disabled}`],\n radio: [`input[type=\"radio\"][data-value]${disabled}`],\n };\n\n return [\n `[${NAVIGATION_ITEM_ATTRIBUTE}][data-value]${disabled}`,\n `[role=\"${type}\"][data-value]${disabled}`,\n ...(nativeRoleSelectors[type] ?? []),\n ];\n}\n\nfunction ownerSelectorForType(type: NavigationItemType): string | null {\n switch (type) {\n case \"radio\":\n return '[role=\"radiogroup\"]';\n case \"checkbox\":\n return '[role=\"group\"]';\n case \"option\":\n return '[role=\"listbox\"]';\n case \"menuitem\":\n case \"menuitemcheckbox\":\n case \"menuitemradio\":\n return '[role=\"menu\"]';\n case \"tab\":\n return '[role=\"tablist\"]';\n case \"button\":\n return null;\n }\n}\n\nfunction isOwnedByContainer(\n element: HTMLElement,\n container: HTMLElement,\n query: NavigationItemQuery,\n): boolean {\n if (query.scopeToContainer === false) return true;\n\n if (query.ownerSelector !== undefined) {\n if (query.ownerSelector === null) return true;\n const owner = element.closest(query.ownerSelector);\n return owner === null || owner === container;\n }\n\n const ownerSelector = ownerSelectorForType(query.type);\n if (!ownerSelector) return true;\n\n const owner = element.closest(ownerSelector);\n return owner === null || owner === container;\n}\n\n/**\n * Finds navigable descendants matching the role/data contract in DOM order.\n * Items hidden by `hidden`, `inert`, or `aria-hidden=\"true\"` (self or ancestor,\n * across shadow boundaries) are always excluded because they are not\n * accessibility-reachable. Disabled items and items under an\n * aria-disabled/data-disabled ancestor are skipped by default.\n */\nexport function getNavigationItems(\n container: HTMLElement | null,\n query: NavigationItemQuery,\n): HTMLElement[] {\n if (!container) return [];\n\n const skipDisabled = query.skipDisabled ?? true;\n\n return queryAllMatchingGroups(\n container,\n buildNavigationSelectors(query.type, skipDisabled),\n (element) =>\n matchesNavigationDataContract(element, query.type) &&\n isOwnedByContainer(element, container, query) &&\n isReachable(element) &&\n (!skipDisabled || !hasDisabledAncestor(element)),\n );\n}\n\n/** Finds one navigable item by its `data-value`. */\nexport function findNavigationItemByValue(\n container: HTMLElement | null,\n query: NavigationItemQuery & { value: string },\n): HTMLElement | null {\n return (\n getNavigationItems(container, query).find((element) => element.dataset.value === query.value) ??\n null\n );\n}\n\n/** Returns the public data attributes needed for role-independent navigation. */\nexport function getNavigationItemProps(\n type: NavigationItemType,\n value: string,\n): {\n \"data-diffgazer-navigation-item\": NavigationItemType;\n \"data-value\": string;\n} {\n return {\n [NAVIGATION_ITEM_ATTRIBUTE]: type,\n \"data-value\": value,\n };\n}\n\n/** Returns the `data-value` for the navigable item containing DOM focus. */\nexport function getFocusedNavigationValue(\n container: HTMLElement | null,\n query: NavigationItemQuery,\n): string | null {\n const focusedItem = getNavigationItems(container, query).find(containsActiveElement);\n return focusedItem?.dataset.value ?? null;\n}\n\n/**\n * Moves DOM focus to a navigable item by value, with optional first/last\n * fallback, and returns the focused value.\n */\nexport function focusNavigationItem(\n container: HTMLElement | null,\n query: NavigationItemQuery & {\n value: string;\n fallback?: \"first\" | \"last\" | \"none\";\n preventScroll?: boolean;\n },\n): string | null {\n const items = getNavigationItems(container, query);\n const target =\n items.find((element) => element.dataset.value === query.value) ??\n (query.fallback === \"first\" ? items[0] : undefined) ??\n (query.fallback === \"last\" ? items.at(-1) : undefined) ??\n null;\n\n if (!target || target.dataset.value === undefined) return null;\n\n target.focus({ preventScroll: query.preventScroll });\n return target.dataset.value;\n}\n", "type": "registry:hook", "target": "src/hooks/utils/navigation-items.ts" }, { "path": "src/core/navigation-directions.ts", "content": "/** Vertical movement direction derived from ArrowUp/ArrowDown, their k/j aliases, or boundaries. */\nexport type VerticalDirection = \"up\" | \"down\";\n\n/** Orientation-neutral boundary direction emitted by navigation hooks. */\nexport type BoundaryDirection = \"previous\" | \"next\";\n\nconst LIST_NAVIGATION_KEYS = new Set([\n \"ArrowUp\",\n \"ArrowDown\",\n \"j\",\n \"k\",\n \"Home\",\n \"End\",\n \"Enter\",\n \" \",\n]);\n\n/**\n * Returns true for keys a list may consume, including the j/k vim aliases. The aliases are not\n * vertical defaults: list components opt into them through `upKeys`/`downKeys`.\n */\nexport function isListNavigationKey(key: string): boolean {\n return LIST_NAVIGATION_KEYS.has(key);\n}\n\n/** Maps ArrowUp/ArrowDown and their k/j vim aliases to a semantic vertical direction. */\nexport function getVerticalArrowDirection(key: string): VerticalDirection | null {\n if (key === \"ArrowUp\" || key === \"k\") return \"up\";\n if (key === \"ArrowDown\" || key === \"j\") return \"down\";\n return null;\n}\n\n/** Converts a previous/next boundary to up/down. */\nexport function toVerticalBoundaryDirection(direction: BoundaryDirection): VerticalDirection;\n/** Converts a boundary to up/down only when the triggering key was vertical. */\nexport function toVerticalBoundaryDirection(\n direction: BoundaryDirection,\n key: string,\n): VerticalDirection | null;\n/** Implementation for boundary-to-vertical direction mapping. */\nexport function toVerticalBoundaryDirection(\n direction: BoundaryDirection,\n key?: string,\n): VerticalDirection | null {\n if (key !== undefined && getVerticalArrowDirection(key) === null) return null;\n return direction === \"previous\" ? \"up\" : \"down\";\n}\n", "type": "registry:hook", "target": "src/hooks/utils/navigation-directions.ts" }, { "path": "src/dom/focusable.ts", "content": "import {\n composedClosest,\n composedContains,\n getDeepActiveElement,\n getShadowHost,\n isHTMLElement,\n isHTMLInputElement,\n} from \"./element-guards\";\n\nconst FOCUSABLE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button:not([disabled])\",\n 'input:not([type=\"hidden\"]):not([disabled])',\n \"select:not([disabled])\",\n \"textarea:not([disabled])\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"audio[controls]\",\n \"video[controls]\",\n '[contenteditable]:not([contenteditable=\"false\"])',\n \"details > summary:first-of-type\",\n \"[tabindex]:not([disabled])\",\n].join(\",\");\n\nfunction getComposedParentElement(element: Element): Element | null {\n return element.assignedSlot ?? element.parentElement ?? getShadowHost(element);\n}\n\nexport function getComposedChildren(element: Element): Element[] {\n if (element.shadowRoot) return Array.from(element.shadowRoot.children);\n if (element.localName === \"slot\") {\n const assigned = (element as HTMLSlotElement).assignedElements({ flatten: true });\n if (assigned.length > 0) return assigned;\n }\n return Array.from(element.children);\n}\n\nfunction isHidden(element: HTMLElement): boolean {\n const elementVisibility = element.ownerDocument.defaultView?.getComputedStyle(element).visibility;\n if (elementVisibility === \"hidden\" || elementVisibility === \"collapse\") return true;\n\n let current: Element | null = element;\n while (current) {\n const style = current.ownerDocument.defaultView?.getComputedStyle(current);\n // area is display:none by default in every UA stylesheet yet remains\n // focusable via its own href; only its ancestors' display can hide it.\n const isOwnAreaDisplayNone = current === element && current.localName === \"area\";\n if (\n current.getAttribute(\"hidden\") === \"until-found\" ||\n (!isOwnAreaDisplayNone && style?.display === \"none\") ||\n style?.getPropertyValue(\"content-visibility\") === \"hidden\"\n ) {\n return true;\n }\n current = getComposedParentElement(current);\n }\n return false;\n}\n\nfunction isInert(element: HTMLElement): boolean {\n return composedClosest(element, \"[inert]\") !== null;\n}\n\nfunction isAriaHidden(element: HTMLElement): boolean {\n // Per ARIA, aria-hidden=\"false\" does NOT re-expose content hidden by an aria-hidden=\"true\" ancestor.\n return composedClosest(element, '[aria-hidden=\"true\"]') !== null;\n}\n\nfunction isHiddenByClosedDetails(element: HTMLElement): boolean {\n let current = getComposedParentElement(element);\n\n while (current) {\n if (current.localName === \"details\" && !current.hasAttribute(\"open\")) {\n const firstSummary = Array.from(current.children).find(\n (child) => child.localName === \"summary\",\n );\n if (!firstSummary || !composedContains(firstSummary, element)) return true;\n }\n current = getComposedParentElement(current);\n }\n\n return false;\n}\n\n/**\n * Returns false when a hidden, inert, or aria-hidden=\"true\" self-or-ancestor\n * (across shadow boundaries) removes the element from keyboard reach. Shared\n * with navigation discovery so it skips the same unreachable items focus does.\n */\nexport function isReachable(element: HTMLElement): boolean {\n return (\n !isHidden(element) &&\n !isInert(element) &&\n !isAriaHidden(element) &&\n !isHiddenByClosedDetails(element)\n );\n}\n\nexport function isInsideDisabledFieldset(element: HTMLElement): boolean {\n let fieldset = element.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = fieldset.querySelector(\":scope > legend\");\n if (legend?.contains(element)) {\n // Descendants of the first are not disabled per spec; keep searching upward.\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n continue;\n }\n return true;\n }\n return false;\n}\n\n/**\n * Returns true for visible, non-disabled elements that can receive programmatic\n * focus, including `tabIndex={-1}` targets.\n */\nexport function isFocusable(element: HTMLElement | null): boolean {\n if (!isHTMLElement(element)) return false;\n if (!element.matches(FOCUSABLE_SELECTOR)) return false;\n if (!isReachable(element)) return false;\n if (isInsideDisabledFieldset(element)) return false;\n return true;\n}\n\n// jsdom returns compound-selector matches out of tree order; normalize.\nconst DOCUMENT_POSITION_PRECEDING = 0x02;\nconst DOCUMENT_POSITION_FOLLOWING = 0x04;\n\nfunction nativeDocumentOrder(a: HTMLElement, b: HTMLElement): number {\n const pos = a.compareDocumentPosition(b);\n if (pos & DOCUMENT_POSITION_FOLLOWING) return -1;\n if (pos & DOCUMENT_POSITION_PRECEDING) return 1;\n return 0;\n}\n\nfunction composedPath(element: HTMLElement): Element[] {\n const path: Element[] = [];\n let current: Element | null = element;\n while (current) {\n path.push(current);\n current = getComposedParentElement(current);\n }\n return path.reverse();\n}\n\n/** Sort comparator that returns elements in composed order across open shadow roots. */\nexport function documentOrder(a: HTMLElement, b: HTMLElement): number {\n if (a === b) return 0;\n\n const aPath = composedPath(a);\n const bPath = composedPath(b);\n let index = 0;\n while (aPath[index] && aPath[index] === bPath[index]) index += 1;\n\n if (index === 0) return nativeDocumentOrder(a, b);\n if (index === aPath.length) return -1;\n if (index === bPath.length) return 1;\n\n const parent = aPath[index - 1];\n const aSibling = aPath[index];\n const bSibling = bPath[index];\n if (!parent || !aSibling || !bSibling) return nativeDocumentOrder(a, b);\n\n const children = getComposedChildren(parent);\n const aIndex = children.indexOf(aSibling);\n const bIndex = children.indexOf(bSibling);\n return aIndex >= 0 && bIndex >= 0 ? aIndex - bIndex : nativeDocumentOrder(a, b);\n}\n\nfunction getComposedDescendants(container: HTMLElement): HTMLElement[] {\n const matches: HTMLElement[] = [];\n const visit = (element: Element) => {\n if (\n isHTMLElement(element) &&\n element.matches(FOCUSABLE_SELECTOR) &&\n !element.shadowRoot?.delegatesFocus\n ) {\n matches.push(element);\n }\n for (const child of getComposedChildren(element)) visit(child);\n };\n\n for (const child of getComposedChildren(container)) visit(child);\n return matches;\n}\n\n/**\n * Returns all focusable descendants in DOM order, including programmatic focus\n * targets that are not reachable by Tab.\n */\nexport function getFocusableElements(container: HTMLElement | null): HTMLElement[] {\n if (!container) return [];\n return getComposedDescendants(container).filter((element) => isFocusable(element));\n}\n\nfunction isRadioInput(element: HTMLElement): element is HTMLInputElement {\n return isHTMLInputElement(element) && element.type === \"radio\";\n}\n\nfunction isRadioGroupTabStop(element: HTMLElement, focusableElements: HTMLElement[]): boolean {\n if (!isRadioInput(element) || element.name === \"\") return true;\n const root = element.getRootNode();\n\n const group = focusableElements.filter(\n (candidate) =>\n isRadioInput(candidate) &&\n candidate.name === element.name &&\n candidate.form === element.form &&\n candidate.getRootNode() === root,\n );\n const checked = group.find((candidate) => isRadioInput(candidate) && candidate.checked);\n return element === (checked ?? group[0]);\n}\n\nfunction tabIndexOrder(element: HTMLElement): number {\n return element.tabIndex > 0 ? element.tabIndex : Number.MAX_SAFE_INTEGER;\n}\n\n/**\n * Returns browser Tab-order descendants, excluding negative tabindex targets\n * and collapsing native radio groups to one Tab stop.\n */\nexport function getTabbableElements(container: HTMLElement | null): HTMLElement[] {\n if (!container) return [];\n const focusableElements = getFocusableElements(container).filter(\n (element) => element.tabIndex >= 0,\n );\n return focusableElements\n .map((element, index) => ({ element, index }))\n .filter(({ element }) => isRadioGroupTabStop(element, focusableElements))\n .sort((a, b) => tabIndexOrder(a.element) - tabIndexOrder(b.element) || a.index - b.index)\n .map(({ element }) => element);\n}\n\n/** Returns the first focusable descendant in DOM order. */\nexport function getFirstFocusableElement(container: HTMLElement | null): HTMLElement | null {\n return getFocusableElements(container)[0] ?? null;\n}\n\n/** Returns true when the element contains its owner document's deep active element, across shadow roots. */\nexport function containsActiveElement(element: HTMLElement): boolean {\n const activeElement = getDeepActiveElement(element.ownerDocument);\n return isHTMLElement(activeElement) && composedContains(element, activeElement);\n}\n", "type": "registry:hook", "target": "src/hooks/utils/focusable.ts" }, { "path": "src/dom/element-guards.ts", "content": "/** Returns the `Window` realm for a DOM value, or null for non-DOM values. */\nexport function getOwnerView(value: unknown): (Window & typeof globalThis) | null {\n const element = value as { ownerDocument?: Document } | null;\n return element?.ownerDocument?.defaultView ?? null;\n}\n\n/** Realm-safe HTMLElement guard for elements from iframes or other documents. */\nexport function isHTMLElement(value: unknown): value is HTMLElement {\n const View = getOwnerView(value);\n return Boolean(View && value instanceof View.HTMLElement);\n}\n\n/** Realm-safe HTMLInputElement guard. */\nexport function isHTMLInputElement(value: unknown): value is HTMLInputElement {\n const View = getOwnerView(value);\n return Boolean(View && value instanceof View.HTMLInputElement);\n}\n\n/** Realm-safe HTMLTextAreaElement guard. */\nexport function isHTMLTextAreaElement(value: unknown): value is HTMLTextAreaElement {\n const View = getOwnerView(value);\n return Boolean(View && value instanceof View.HTMLTextAreaElement);\n}\n\n/** Realm-safe Node guard using a known owner window. */\nexport function isNode(\n value: unknown,\n ownerView: (Window & typeof globalThis) | null,\n): value is Node {\n return Boolean(ownerView && value instanceof ownerView.Node);\n}\n\n/** Returns the shadow host when a node lives in a shadow root, else null. */\nexport function getShadowHost(node: Node): Element | null {\n const view = getOwnerView(node);\n const root = node.getRootNode();\n return view && root instanceof view.ShadowRoot ? root.host : null;\n}\n\n/** Returns the deepest active element, descending through open shadow roots. */\nexport function getDeepActiveElement(root: Document | ShadowRoot): Element | null {\n let active = root.activeElement;\n while (active?.shadowRoot?.activeElement) {\n active = active.shadowRoot.activeElement;\n }\n return active;\n}\n\n/** Returns true when `container` contains `target` in the composed tree, across shadow boundaries. */\nexport function composedContains(container: Node, target: Node | null): boolean {\n let node: Node | null = target;\n while (node) {\n if (container.contains(node)) return true;\n node = getShadowHost(node);\n }\n return false;\n}\n\n/** Returns the nearest self-or-ancestor matching `selector` in the composed tree, across shadow boundaries. */\nexport function composedClosest(element: Element, selector: string): Element | null {\n let current: Element | null = element;\n while (current) {\n const match = current.closest(selector);\n if (match) return match;\n current = getShadowHost(current);\n }\n return null;\n}\n\n/**\n * Returns the deepest composed event target, past the retargeted shadow host.\n * Empty `composedPath()` (before dispatch) falls back to `event.target`.\n */\nexport function getComposedEventTarget(event: Event): EventTarget | null {\n return event.composedPath()[0] ?? event.target;\n}\n\n/**\n * Returns true for form-like or editable elements that can own keyboard input,\n * including select and non-text input types.\n */\nexport function isInputElement(target: EventTarget | null): boolean {\n if (!isHTMLElement(target)) return false;\n const tag = target.tagName.toLowerCase();\n return Boolean(\n tag === \"input\" || tag === \"textarea\" || tag === \"select\" || target.isContentEditable,\n );\n}\n\nconst NON_EDITABLE_INPUT_TYPES = new Set([\n \"button\",\n \"checkbox\",\n \"color\",\n \"file\",\n \"hidden\",\n \"image\",\n \"radio\",\n \"range\",\n \"reset\",\n \"submit\",\n]);\n\nfunction hasContentEditableAttribute(element: HTMLElement): boolean {\n if (element.isContentEditable) return true;\n const value = element.getAttribute(\"contenteditable\");\n if (value === null) return false;\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Returns true only for enabled text-editable targets that should keep typing\n * keys instead of global shortcuts.\n */\nexport function isEditableElement(target: EventTarget | null): boolean {\n if (!isHTMLElement(target)) return false;\n\n if (isHTMLTextAreaElement(target)) {\n return !target.disabled && !target.readOnly;\n }\n\n if (isHTMLInputElement(target)) {\n if (target.disabled || target.readOnly) return false;\n const type = (target.type || \"text\").toLowerCase();\n return !NON_EDITABLE_INPUT_TYPES.has(type);\n }\n\n return hasContentEditableAttribute(target);\n}\n", "type": "registry:hook", "target": "src/hooks/utils/element-guards.ts" } ], "meta": { "client": true }, "type": "registry:hook" }