{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "focus-trap", "title": "Focus Trap", "description": "Trap Tab focus within a container element", "dependencies": [], "registryDependencies": [], "files": [ { "path": "src/hooks/use-focus-trap.ts", "content": "\"use client\";\n\nimport { type RefObject, useEffect, useRef } from \"react\";\nimport { composedContains, getDeepActiveElement, isHTMLElement } from \"./utils/element-guards\";\nimport { restoreFocus as restoreFocusTarget } from \"./utils/focus-restore\";\nimport { getFocusableElements, isFocusable } from \"./utils/focusable\";\nimport { createFocusTrapController } from \"./utils/focus-trap-controller\";\nimport { useFocusRestore } from \"./use-focus-restore\";\n\n/** Options for trapping Tab focus inside a container. */\nexport interface UseFocusTrapOptions {\n /** Element that receives focus when the trap activates. */\n initialFocus?: RefObject;\n /** Restore focus to the previously focused element when the trap releases. */\n restoreFocus?: boolean;\n /** Whether the focus trap is active. */\n enabled?: boolean;\n}\n\ninterface ActiveTrap {\n container: HTMLElement;\n restoreFocus: boolean;\n release: () => void;\n}\n\nfunction pickInitialTarget(\n container: HTMLElement,\n initialFocus: RefObject | undefined,\n): HTMLElement {\n const requested = initialFocus?.current;\n if (requested && composedContains(container, requested) && isFocusable(requested))\n return requested;\n return getFocusableElements(container)[0] ?? container;\n}\n\nfunction isInsideContainer(\n container: HTMLElement,\n target: EventTarget | null,\n): target is HTMLElement {\n return isHTMLElement(target) && composedContains(container, target);\n}\n\n/**\n * Keeps Tab and Shift+Tab focus inside a container while active, with nested\n * trap stacking and optional focus restoration on release. Active traps listen\n * for keydown and focusin in the capture phase on the container's owner document.\n */\nexport function useFocusTrap(\n containerRef: RefObject,\n options: UseFocusTrapOptions = {},\n): void {\n const { initialFocus, restoreFocus = true, enabled = true } = options;\n const activeTrapRef = useRef(null);\n // Detach listeners BEFORE focus moves out in release(), or the document-level\n // focusin recapture re-traps the restored target. restoreOnUnmount:false keeps\n // this stack hook from moving focus during its own unmount cleanup.\n const { capture, restore } = useFocusRestore({\n enabled: restoreFocus,\n restoreOnUnmount: false,\n });\n\n // No dependency array on purpose: React does not re-fire effects when\n // containerRef.current mutates while the ref object stays stable.\n useEffect(() => {\n const nextContainer = enabled ? containerRef.current : null;\n const active = activeTrapRef.current;\n if (active && active.container === nextContainer) {\n // restoreFocus is release-time policy; update in place instead of tearing\n // down (which would recapture the interior as the restore target, not the opener).\n active.restoreFocus = restoreFocus;\n return;\n }\n\n active?.release();\n activeTrapRef.current = null;\n\n if (!nextContainer) return;\n const container = nextContainer;\n const ownerDocument = container.ownerDocument;\n // Resolve the observer BEFORE any side effect (focus capture, tabindex mutation)\n // so a document without MutationObserver bails out leaving the container untouched.\n const MutationObserverCtor = ownerDocument.defaultView?.MutationObserver;\n if (typeof MutationObserverCtor !== \"function\") return;\n // Capture the opener before focus moves inside, so a false-to-true toggle can still restore to it.\n const activeAtActivation = getDeepActiveElement(ownerDocument);\n const opener =\n isHTMLElement(activeAtActivation) && !isInsideContainer(container, activeAtActivation)\n ? activeAtActivation\n : null;\n const restoreTarget = capture(ownerDocument);\n\n const controller = createFocusTrapController({\n container,\n resolveInitialFocus: () => pickInitialTarget(container, initialFocus),\n MutationObserverCtor,\n });\n // Push BEFORE arming/focusing: pushTrap suspends the previous top so its\n // focusin handler does not recapture focus away from this new trap.\n controller.activate();\n\n const activeTrap: ActiveTrap = {\n container,\n restoreFocus,\n release: () => {\n const { hasOuterTrap } = controller.release();\n\n if (activeTrap.restoreFocus) {\n const restored = restore();\n if (!restored && !hasOuterTrap) restoreFocusTarget(restoreTarget ?? opener);\n }\n },\n };\n activeTrapRef.current = activeTrap;\n });\n\n useEffect(() => {\n return () => {\n const activeTrap = activeTrapRef.current;\n if (!activeTrap) return;\n activeTrapRef.current = null;\n activeTrap.release();\n };\n }, []);\n}\n", "type": "registry:hook", "target": "src/hooks/use-focus-trap.ts" }, { "path": "src/hooks/focus-trap-controller.ts", "content": "import {\n composedContains,\n getComposedEventTarget,\n getDeepActiveElement,\n isHTMLElement,\n isHTMLInputElement,\n} from \"./element-guards\";\nimport {\n documentOrder,\n getComposedChildren,\n getFocusableElements,\n getTabbableElements,\n isFocusable,\n} from \"./focusable\";\n\ninterface TrapEntry {\n container: HTMLElement;\n ownerDocument: Document;\n resolveInitialFocus: () => HTMLElement;\n lastFocused: HTMLElement;\n handleKeyDown: (event: KeyboardEvent) => void;\n handleFocusIn: (event: FocusEvent) => void;\n observer: MutationObserver;\n observedTargets: Set;\n suspended: boolean;\n}\n\nexport interface CreateFocusTrapControllerOptions {\n container: HTMLElement;\n resolveInitialFocus: () => HTMLElement;\n MutationObserverCtor: typeof MutationObserver;\n}\n\nexport interface FocusTrapReleaseResult {\n hasOuterTrap: boolean;\n}\n\nexport interface FocusTrapController {\n activate: () => void;\n release: () => FocusTrapReleaseResult;\n}\n\nconst trapStacks = new WeakMap();\n\nconst TRAP_MUTATION_OPTIONS = {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: [\n \"disabled\",\n \"hidden\",\n \"open\",\n \"tabindex\",\n \"aria-hidden\",\n \"inert\",\n \"style\",\n \"class\",\n ],\n} as const satisfies MutationObserverInit;\n\nfunction getOpenShadowRoots(container: HTMLElement): ShadowRoot[] {\n const roots: ShadowRoot[] = [];\n const visit = (element: Element) => {\n if (element.shadowRoot) roots.push(element.shadowRoot);\n for (const child of getComposedChildren(element)) visit(child);\n };\n visit(container);\n return roots;\n}\n\nfunction observeNewTrapTargets(entry: TrapEntry): void {\n const targets: Node[] = [entry.container, ...getOpenShadowRoots(entry.container)];\n for (const target of targets) {\n if (entry.observedTargets.has(target)) continue;\n entry.observer.observe(target, TRAP_MUTATION_OPTIONS);\n entry.observedTargets.add(target);\n }\n}\n\nfunction disconnectTrapObserver(entry: TrapEntry): void {\n entry.observer.disconnect();\n entry.observedTargets.clear();\n}\n\nfunction getTrapStack(ownerDocument: Document): TrapEntry[] {\n let stack = trapStacks.get(ownerDocument);\n if (!stack) {\n stack = [];\n trapStacks.set(ownerDocument, stack);\n }\n return stack;\n}\n\nfunction armEntry(entry: TrapEntry): void {\n if (!entry.suspended) return;\n entry.ownerDocument.addEventListener(\"keydown\", entry.handleKeyDown, true);\n entry.ownerDocument.addEventListener(\"focusin\", entry.handleFocusIn, true);\n observeNewTrapTargets(entry);\n entry.suspended = false;\n}\n\nfunction suspendEntry(entry: TrapEntry): void {\n if (entry.suspended) return;\n entry.ownerDocument.removeEventListener(\"keydown\", entry.handleKeyDown, true);\n entry.ownerDocument.removeEventListener(\"focusin\", entry.handleFocusIn, true);\n disconnectTrapObserver(entry);\n entry.suspended = true;\n}\n\nfunction resumeEntry(entry: TrapEntry): void {\n armEntry(entry);\n const target =\n composedContains(entry.container, entry.lastFocused) &&\n entry.lastFocused.isConnected &&\n isFocusable(entry.lastFocused)\n ? entry.lastFocused\n : entry.resolveInitialFocus();\n target.focus();\n}\n\nfunction shouldInsertBefore(incoming: TrapEntry, existing: TrapEntry): boolean {\n return (\n incoming.container !== existing.container &&\n composedContains(incoming.container, existing.container)\n );\n}\n\nfunction pushTrap(entry: TrapEntry): boolean {\n const stack = getTrapStack(entry.ownerDocument);\n const previousTop = stack.at(-1);\n const insertIndex = stack.findIndex((existing) => shouldInsertBefore(entry, existing));\n if (insertIndex === -1) stack.push(entry);\n else stack.splice(insertIndex, 0, entry);\n\n const nextTop = stack.at(-1);\n if (previousTop && previousTop !== nextTop) suspendEntry(previousTop);\n return nextTop === entry;\n}\n\nfunction removeTrap(entry: TrapEntry): void {\n const stack = getTrapStack(entry.ownerDocument);\n const index = stack.indexOf(entry);\n if (index < 0) return;\n const wasTop = index === stack.length - 1;\n stack.splice(index, 1);\n if (wasTop) {\n const newTop = stack.at(-1);\n if (newTop) resumeEntry(newTop);\n }\n}\n\nfunction isInsideContainer(\n container: HTMLElement,\n target: EventTarget | null,\n): target is HTMLElement {\n return isHTMLElement(target) && composedContains(container, target);\n}\n\nfunction getTabbableFromAnchor(\n tabbableEls: [HTMLElement, ...HTMLElement[]],\n activeElement: HTMLElement,\n shiftKey: boolean,\n): HTMLElement {\n const firstTabbable = tabbableEls[0];\n const documentOrderEls = [...tabbableEls].sort(documentOrder);\n if (shiftKey) {\n for (let index = documentOrderEls.length - 1; index >= 0; index -= 1) {\n const candidate = documentOrderEls[index];\n if (candidate && documentOrder(candidate, activeElement) < 0) return candidate;\n }\n return documentOrderEls.at(-1) ?? firstTabbable;\n }\n\n for (const candidate of documentOrderEls) {\n if (documentOrder(activeElement, candidate) < 0) return candidate;\n }\n return documentOrderEls[0] ?? firstTabbable;\n}\n\nfunction hasExcludedCheckedRadioPeer(container: HTMLElement, element: HTMLElement): boolean {\n if (!isHTMLInputElement(element) || element.type !== \"radio\" || element.name === \"\") return false;\n const root = element.getRootNode();\n return getFocusableElements(container).some(\n (candidate) =>\n candidate !== element &&\n isHTMLInputElement(candidate) &&\n candidate.type === \"radio\" &&\n candidate.name === element.name &&\n candidate.form === element.form &&\n candidate.getRootNode() === root &&\n candidate.checked &&\n candidate.tabIndex < 0,\n );\n}\n\nexport function createFocusTrapController(\n options: CreateFocusTrapControllerOptions,\n): FocusTrapController {\n const { container, resolveInitialFocus, MutationObserverCtor } = options;\n const ownerDocument = container.ownerDocument;\n\n const hadTabIndex = container.hasAttribute(\"tabindex\");\n const originalTabIndex = container.getAttribute(\"tabindex\");\n if (!hadTabIndex) container.setAttribute(\"tabindex\", \"-1\");\n\n let activated = false;\n\n function recapture(): void {\n const { lastFocused } = trapEntry;\n const target =\n composedContains(container, lastFocused) &&\n lastFocused.isConnected &&\n isFocusable(lastFocused)\n ? lastFocused\n : resolveInitialFocus();\n target.focus();\n }\n\n function handleFocusIn(event: FocusEvent): void {\n const target = getComposedEventTarget(event);\n if (isInsideContainer(container, target)) {\n trapEntry.lastFocused = target;\n if (!trapEntry.suspended) observeNewTrapTargets(trapEntry);\n return;\n }\n recapture();\n }\n\n function handleKeyDown(event: KeyboardEvent): void {\n if (event.key !== \"Tab\") return;\n\n if (!trapEntry.suspended) observeNewTrapTargets(trapEntry);\n const tabbableEls = getTabbableElements(container);\n if (tabbableEls.length === 0) {\n event.preventDefault();\n if (getDeepActiveElement(ownerDocument) !== container) container.focus();\n return;\n }\n\n const first = tabbableEls[0];\n const last = tabbableEls[tabbableEls.length - 1];\n const activeElement = getDeepActiveElement(ownerDocument);\n\n if (!isInsideContainer(container, activeElement)) {\n event.preventDefault();\n (event.shiftKey ? last : first)?.focus();\n return;\n }\n\n if (!tabbableEls.includes(activeElement)) {\n event.preventDefault();\n getTabbableFromAnchor(\n tabbableEls as [HTMLElement, ...HTMLElement[]],\n activeElement,\n event.shiftKey,\n ).focus();\n return;\n }\n\n const activeIndex = tabbableEls.indexOf(activeElement);\n const adjacent = tabbableEls[activeIndex + (event.shiftKey ? -1 : 1)];\n if (adjacent && hasExcludedCheckedRadioPeer(container, adjacent)) {\n event.preventDefault();\n adjacent.focus();\n return;\n }\n\n if (event.shiftKey) {\n if (activeElement === first) {\n event.preventDefault();\n last?.focus();\n }\n } else {\n if (activeElement === last) {\n event.preventDefault();\n first?.focus();\n }\n }\n }\n\n const observer = new MutationObserverCtor(() => {\n if (!trapEntry.suspended) observeNewTrapTargets(trapEntry);\n const { lastFocused } = trapEntry;\n if (\n lastFocused.isConnected &&\n composedContains(container, lastFocused) &&\n isFocusable(lastFocused)\n )\n return;\n recapture();\n });\n\n const trapEntry: TrapEntry = {\n container,\n ownerDocument,\n resolveInitialFocus,\n lastFocused: container,\n handleKeyDown,\n handleFocusIn,\n observer,\n observedTargets: new Set(),\n suspended: true,\n };\n\n return {\n activate: () => {\n if (activated) return;\n activated = true;\n\n const isTopTrap = pushTrap(trapEntry);\n\n if (isTopTrap) {\n armEntry(trapEntry);\n if (!isInsideContainer(container, getDeepActiveElement(ownerDocument))) {\n resolveInitialFocus().focus();\n }\n const activeElement = getDeepActiveElement(ownerDocument);\n trapEntry.lastFocused = isInsideContainer(container, activeElement)\n ? activeElement\n : resolveInitialFocus();\n } else {\n const activeElement = getDeepActiveElement(ownerDocument);\n if (isInsideContainer(container, activeElement)) {\n trapEntry.lastFocused = activeElement;\n }\n }\n },\n release: () => {\n if (!activated) {\n return { hasOuterTrap: getTrapStack(ownerDocument).length > 0 };\n }\n activated = false;\n\n suspendEntry(trapEntry);\n removeTrap(trapEntry);\n\n if (!hadTabIndex) {\n container.removeAttribute(\"tabindex\");\n } else if (originalTabIndex !== null) {\n container.setAttribute(\"tabindex\", originalTabIndex);\n }\n\n return { hasOuterTrap: getTrapStack(ownerDocument).length > 0 };\n },\n };\n}\n", "type": "registry:hook", "target": "src/hooks/utils/focus-trap-controller.ts" }, { "path": "src/hooks/use-focus-restore.ts", "content": "\"use client\";\n\nimport { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n getDocument,\n getRestorableFocusTarget,\n type RestoreFocusOptions,\n restoreFocus,\n} from \"./utils/focus-restore\";\n\n/** Options for capturing and restoring focus around temporary UI. */\nexport interface UseFocusRestoreOptions extends RestoreFocusOptions {\n /** Whether capture and restore are active. */\n enabled?: boolean;\n /** Restore focus during cleanup if capture was called and restore was not called manually. */\n restoreOnUnmount?: boolean;\n /** Fallback element to focus when the captured target is unavailable. */\n fallback?: HTMLElement | null;\n}\n\n/** Return value from `useFocusRestore`. */\nexport interface UseFocusRestoreReturn {\n /** Stores the current focus target for the provided document. */\n capture: (ownerDocument?: Document) => HTMLElement | null;\n /** Focuses the captured or fallback target and returns whether focus moved. */\n restore: () => boolean;\n /** The last captured focus target, or null when nothing is captured. */\n target: HTMLElement | null;\n}\n\ninterface FocusRestoreEntry {\n target: HTMLElement | null;\n fallbackTargets: HTMLElement[];\n ownerDocument: Document;\n}\n\nconst focusRestoreStacks = new WeakMap();\n\nfunction getFocusRestoreStack(ownerDocument: Document): FocusRestoreEntry[] {\n let stack = focusRestoreStacks.get(ownerDocument);\n if (!stack) {\n stack = [];\n focusRestoreStacks.set(ownerDocument, stack);\n }\n return stack;\n}\n\nfunction removeEntry(entry: FocusRestoreEntry): void {\n const stack = getFocusRestoreStack(entry.ownerDocument);\n const index = stack.lastIndexOf(entry);\n if (index >= 0) stack.splice(index, 1);\n}\n\nfunction resolveOptions(options: UseFocusRestoreOptions): Required {\n return {\n enabled: options.enabled ?? true,\n restoreOnUnmount: options.restoreOnUnmount ?? true,\n preventScroll: options.preventScroll ?? false,\n fallback: options.fallback ?? null,\n };\n}\n\nfunction releaseEntry(\n entry: FocusRestoreEntry,\n shouldRestore: boolean,\n options: Required,\n): boolean {\n const stack = getFocusRestoreStack(entry.ownerDocument);\n const index = stack.lastIndexOf(entry);\n if (index < 0) return false;\n\n const isTopEntry = index === stack.length - 1;\n stack.splice(index, 1);\n\n if (!shouldRestore || !options.enabled) return false;\n\n const candidates = [entry.target, ...entry.fallbackTargets].filter(\n (candidate): candidate is HTMLElement => candidate !== null,\n );\n if (!isTopEntry) {\n for (const entryAbove of stack.slice(index)) {\n entryAbove.fallbackTargets = [\n ...candidates,\n ...entryAbove.fallbackTargets.filter((candidate) => !candidates.includes(candidate)),\n ];\n }\n return false;\n }\n\n for (const candidate of candidates) {\n if (restoreFocus(candidate, { preventScroll: options.preventScroll })) return true;\n }\n return restoreFocus(options.fallback, { preventScroll: options.preventScroll });\n}\n\n/**\n * Captures the current focus target and restores it later, with per-document\n * stack ordering for nested overlays.\n */\nexport function useFocusRestore(options: UseFocusRestoreOptions = {}): UseFocusRestoreReturn {\n const resolvedOptions = resolveOptions(options);\n const optionsRef = useRef(resolvedOptions);\n const entryRef = useRef(null);\n const [target, setTarget] = useState(null);\n\n const teardown = useEffectEvent(\n (shouldRestore: boolean, options: Required) => {\n const entry = entryRef.current;\n if (!entry) return;\n\n entryRef.current = null;\n releaseEntry(entry, shouldRestore, options);\n setTarget(null);\n },\n );\n\n // Latest-ref sync: stable focus callbacks read optionsRef, so it must update every render by design.\n useLayoutEffect(() => {\n optionsRef.current = resolvedOptions;\n });\n\n const capture = useCallback((ownerDocument?: Document) => {\n const resolvedOptions = optionsRef.current;\n const doc = ownerDocument ?? resolvedOptions.fallback?.ownerDocument ?? getDocument();\n if (!resolvedOptions.enabled || !doc) {\n const entry = entryRef.current;\n if (entry) {\n entryRef.current = null;\n removeEntry(entry);\n }\n setTarget(null);\n return null;\n }\n\n const nextTarget = getRestorableFocusTarget(doc) ?? resolvedOptions.fallback;\n const entry = entryRef.current ?? { target: null, fallbackTargets: [], ownerDocument: doc };\n\n removeEntry(entry);\n entry.target = nextTarget;\n entry.fallbackTargets = [];\n entry.ownerDocument = doc;\n entryRef.current = entry;\n getFocusRestoreStack(doc).push(entry);\n setTarget(nextTarget);\n\n return nextTarget;\n }, []);\n\n const restore = useCallback(() => {\n const resolvedOptions = optionsRef.current;\n const entry = entryRef.current;\n\n if (!entry) {\n return resolvedOptions.enabled\n ? restoreFocus(resolvedOptions.fallback, { preventScroll: resolvedOptions.preventScroll })\n : false;\n }\n\n entryRef.current = null;\n const restored = releaseEntry(entry, true, resolvedOptions);\n setTarget(null);\n return restored;\n }, []);\n\n useEffect(() => {\n if (resolvedOptions.enabled) return;\n\n teardown(false, optionsRef.current);\n }, [resolvedOptions.enabled]);\n\n useEffect(() => {\n return () => {\n teardown(optionsRef.current.restoreOnUnmount, optionsRef.current);\n };\n }, []);\n\n return { capture, restore, target };\n}\n", "type": "registry:hook", "target": "src/hooks/use-focus-restore.ts" }, { "path": "src/dom/focus-restore.ts", "content": "import { getDeepActiveElement, isHTMLElement } from \"./element-guards\";\n\n/** Options shared by the imperative focus-restore utilities. */\nexport interface RestoreFocusOptions {\n /** Passes `preventScroll` to the focus call when restoring focus. */\n preventScroll?: boolean;\n}\n\nexport function getDocument(): Document | null {\n return typeof document === \"undefined\" ? null : document;\n}\n\n/**\n * Returns the current restorable focus target, ignoring body, documentElement,\n * disconnected nodes, and missing DOM.\n */\nexport function getRestorableFocusTarget(ownerDocument?: Document): HTMLElement | null {\n const doc = ownerDocument ?? getDocument();\n if (!doc) return null;\n\n const activeElement = getDeepActiveElement(doc);\n if (!isHTMLElement(activeElement)) return null;\n if (activeElement === doc.body || activeElement === doc.documentElement) return null;\n if (!activeElement.isConnected) return null;\n\n return activeElement;\n}\n\n/**\n * Focuses a connected target and returns whether the document's active element\n * moved to it.\n */\nexport function restoreFocus(\n target: HTMLElement | null,\n options: RestoreFocusOptions = {},\n): boolean {\n if (!target?.isConnected) return false;\n\n // Older engines / some jsdom versions reject the FocusOptions argument; fall back to plain focus().\n try {\n target.focus({ preventScroll: options.preventScroll });\n } catch {\n target.focus();\n }\n\n return getDeepActiveElement(target.ownerDocument) === target;\n}\n", "type": "registry:hook", "target": "src/hooks/utils/focus-restore.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" }