/** * @factory/ui — focus-trap helpers. * * Architecture decision (recorded): the TRAP DECISION LOGIC lives in the pure * function {@link nextFocusable} (unit-tested, no DOM); {@link trapFocus} is a * thin, documented adapter that wires `keydown` on the container and delegates * every decision to the pure function. Most Radix primitives (Dialog, * DropdownMenu, Popover) already trap focus internally — `trapFocus` exists * for non-Radix surfaces (command palette, custom panels). */ /** Standard focusable-element selector (a11y conventions). */ export const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])'; /** Minimal element surface needed for {@link isFocusable} (duck-typed). */ export interface FocusableElement { tagname?: string; getAttribute?(name: string): string | null; isContentEditable?: boolean; closest?(selector: string): unknown; } const FOCUSABLE_TAGS = new Set(["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA"]); /** * Duck-typed focusability check (no DOM required, testable with fakes): * - focusable tag must not be `disabled`; * - `a` additionally needs an `href`; * - any element with a valid `tabindex` (not "-1") is focusable; * - `contenteditable` (not "false") is focusable. */ export function isFocusable(el: FocusableElement): boolean { const tag = (el.tagname ?? "").toUpperCase(); const disabled = el.getAttribute?.("disabled") != null; const tabindex = el.getAttribute?.("tabindex"); if (tag === "A" || FOCUSABLE_TAGS.has(tag)) { if (disabled) return false; if (tag === "A" && el.getAttribute?.("href") == null) return false; return true; } if (tabindex != null && tabindex !== "-1") return true; if (el.isContentEditable === true) return true; if (el.getAttribute?.("contenteditable") === "true") return true; return false; } /** * Pure focus-order decision: given the ordered candidate keys, the key that * currently holds focus, the direction (Shift+Tab = shift) and which candidates * are actually focusable, return the index of the next candidate. * * Rules: * - wraps around (last → first, first → last); * - skips non-focusable candidates in either direction; * - returns -1 when there is no focusable candidate at all (deactivate the trap). */ export function nextFocusable( candidates: string[], current: string, shift: boolean, focusableFlags: boolean[], ): number { const n = candidates.length; if (n === 0) return -1; const anyFocusable = focusableFlags.some(Boolean); if (!anyFocusable) return -1; const currentIdx = candidates.indexOf(current); const start = currentIdx >= 0 ? currentIdx : shift ? 0 : n - 1; const step = shift ? -1 : 1; for (let i = 1; i <= n; i += 1) { const idx = (((start + step * i) % n) + n) % n; // safe wrap for negative step const flag = focusableFlags[idx] ?? false; if (flag) return idx; } return -1; } export interface TrapOptions { /** Element that receives focus; the trap is active while `activate()` ran. */ container: unknown; /** When true, focus returns to the previously focused element on deactivate. */ returnFocus?: boolean; } export interface FocusTrap { activate(): void; deactivate(): void; } /** * Thin documented adapter (the real focus bookkeeping happens on the DOM): * * 1. `activate()` focuses the first focusable element (or the container * itself when it has tabindex), saves the previously focused element, * and attaches a `keydown` listener on the container. * 2. The keydown listener intercepts `Tab` / `Shift+Tab`, reads the live * candidate list (`container.querySelectorAll(FOCUSABLE_SELECTOR)`), and * calls the PURE {@link nextFocusable} to pick the next element; it then * calls `focus()` on it and prevents the default wrap. * 3. `deactivate()` removes the listener and, if `returnFocus`, restores * focus to the pre-activation element. * * Kept intentionally tiny: the decision logic is 100% in `nextFocusable`. */ export function trapFocus(options: TrapOptions): FocusTrap { const container = options.container as { querySelectorAll?: (sel: string) => ArrayLike void }>; addEventListener?: ( type: string, handler: (e: { key?: string; shiftKey?: boolean; preventDefault?: () => void }) => void, ) => void; removeEventListener?: (type: string, handler: (e: unknown) => void) => void; } | null; let previous: { focus?: () => void } | null = null; let handler: | ((e: { key?: string; shiftKey?: boolean; preventDefault?: () => void }) => void) | null = null; const onKeyDown = (e: { key?: string; shiftKey?: boolean; preventDefault?: () => void; }): void => { if (e.key !== "Tab") return; if (!container) return; // defensive: handler only runs while a container is attached const list = Array.from(container.querySelectorAll?.(FOCUSABLE_SELECTOR) ?? []); if (list.length === 0) { e.preventDefault?.(); return; } const current = (container as { ownerDocument?: { activeElement?: unknown } }).ownerDocument ?.activeElement as (FocusableElement & { focus?: () => void }) | undefined; const currentKey = list.findIndex( (el) => el === current || (el as unknown) === (current as unknown), ); const flags = list.map((el) => isFocusable(el)); const nextIdx = nextFocusable( list.map((_, i) => String(i)), currentKey >= 0 ? String(currentKey) : "-1", Boolean(e.shiftKey), flags, ); if (nextIdx >= 0) { e.preventDefault?.(); (list[nextIdx] as { focus?: () => void } | undefined)?.focus?.(); } }; return { activate(): void { if (!container) return; previous = (container as unknown as { ownerDocument?: { activeElement?: unknown } }) .ownerDocument?.activeElement as { focus?: () => void } | null; const list = Array.from(container.querySelectorAll?.(FOCUSABLE_SELECTOR) ?? []); const firstIdx = list.findIndex((el) => isFocusable(el)); (firstIdx >= 0 ? (list[firstIdx] as { focus?: () => void }) : null)?.focus?.(); handler = onKeyDown; container.addEventListener?.("keydown", handler); }, deactivate(): void { if (handler) container?.removeEventListener?.( "keydown", handler as (e: unknown) => void, ); handler = null; if (options.returnFocus === true) previous?.focus?.(); previous = null; }, }; }