{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "focus-restore", "title": "Focus Restore", "description": "Capture and restore focus around overlays and temporary UI with nested stack safety", "dependencies": [], "registryDependencies": [], "files": [ { "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/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" }