| 1 | /** |
| 2 | * The "/" focus-search shortcut shared by the FAQ and docs search boxes. |
| 3 | * |
| 4 | * A single-character shortcut must not steal keys the user is typing |
| 5 | * elsewhere (WCAG 2.1.4 Character Key Shortcuts): it ignores modified keys, |
| 6 | * IME composition, keys an inner widget already handled (defaultPrevented), |
| 7 | * and any event aimed at an editable control — native fields, |
| 8 | * contenteditable, or an ARIA text widget — including one inside a shadow |
| 9 | * root, which the window listener would otherwise only see as its host. |
| 10 | */ |
| 11 | |
| 12 | type ShortcutTarget = { |
| 13 | tagName?: string; |
| 14 | isContentEditable?: boolean; |
| 15 | getAttribute?: (name: string) => string | null; |
| 16 | } | null; |
| 17 | |
| 18 | export type ShortcutKeyEvent = Pick< |
| 19 | KeyboardEvent, |
| 20 | "key" | "ctrlKey" | "metaKey" | "altKey" | "isComposing" |
| 21 | > & { |
| 22 | target: EventTarget | ShortcutTarget | null; |
| 23 | defaultPrevented?: boolean; |
| 24 | composedPath?: () => ReadonlyArray<EventTarget | ShortcutTarget>; |
| 25 | }; |
| 26 | |
| 27 | const EDITABLE_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]); |
| 28 | const EDITABLE_ROLES = new Set(["textbox", "searchbox", "combobox"]); |
| 29 | |
| 30 | export function isEditableTarget(target: EventTarget | ShortcutTarget | null): boolean { |
| 31 | if (!target || typeof target !== "object") return false; |
| 32 | const el = target as ShortcutTarget & object; |
| 33 | if (el.isContentEditable) return true; |
| 34 | if (typeof el.tagName === "string" && EDITABLE_TAGS.has(el.tagName.toUpperCase())) return true; |
| 35 | const role = typeof el.getAttribute === "function" ? el.getAttribute("role") : null; |
| 36 | return role !== null && EDITABLE_ROLES.has(role.trim().toLowerCase()); |
| 37 | } |
| 38 | |
| 39 | export function isSlashShortcut(e: ShortcutKeyEvent): boolean { |
| 40 | if (e.key !== "/") return false; |
| 41 | if (e.defaultPrevented) return false; |
| 42 | if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return false; |
| 43 | const origin = e.composedPath?.()[0] ?? e.target; |
| 44 | return !isEditableTarget(origin); |
| 45 | } |
| 46 |