UNPKG

@tanstack/hotkeys

Version:

Type-safe, framework-agnostic keyboard hotkey management for the browser

205 lines (203 loc) • 7.47 kB
import { normalizeKeyName } from "./constants.js"; //#region src/_event-target.ts function isNode(target) { return typeof target?.nodeType === "number"; } function isElement(target) { return isNode(target) && target.nodeType === 1; } function isWindow(target) { return "document" in target; } /** Returns the document a listener target belongs to, in any frame. */ function getTargetDocument(target) { if (isWindow(target)) return target.document; if (target.nodeType === 9) return target; return target.ownerDocument; } /** * Whether key presses reach a listener on `target` right now. Document and * window listeners receive every key press in their document. An element * receives them only while focus is inside it, including inside a shadow * root it contains. */ function targetHasFocus(target) { if (isWindow(target) || target.nodeType === 9) return true; const active = target.getRootNode().activeElement; return !!active && target.contains(active); } /** * Checks if an element is an input-like element that should be ignored for hotkeys. * * This includes: * - HTMLInputElement (all input types except button, submit, reset) * - HTMLTextAreaElement * - HTMLSelectElement * - Elements with contentEditable enabled * * Button-type inputs (button, submit, reset) are excluded so hotkeys like * Mod+S and Escape fire when the user has tabbed to a form button. */ function isInputElement(element) { if (!isElement(element)) return false; if (element.localName === "input") { const type = element.type.toLowerCase(); return type !== "button" && type !== "submit" && type !== "reset"; } if (element.localName === "textarea" || element.localName === "select") return true; return element.isContentEditable === true; } /** * Returns the focused element for the document associated with where hotkey * listeners are attached (listener root). Use this instead of the global * `document.activeElement` so registrations scoped to an iframe (or another * document) read focus from the correct tree. */ function getActiveElementForListenerTarget(target) { if (typeof document === "undefined") return null; return getTargetDocument(target).activeElement; } const VERTICAL = ["ArrowUp", "ArrowDown"]; const HORIZONTAL = ["ArrowLeft", "ArrowRight"]; const ARROWS = [...VERTICAL, ...HORIZONTAL]; const EDGES = ["Home", "End"]; const PAGES = ["PageUp", "PageDown"]; const ACTIVATION = ["Enter", "Space"]; const TYPEAHEAD = "typeahead"; /** * Returns the keys a control handles itself: activation for buttons and * links, and navigation for ARIA widgets, following the WAI-ARIA Authoring * Practices keyboard patterns (https://www.w3.org/WAI/ARIA/apg/patterns/). */ function getOwnedKeys(element) { const orientation = element.getAttribute("aria-orientation"); switch (element.getAttribute("role")?.trim().split(/\s+/)[0]) { case "button": case "tab": case "option": case "treeitem": case "menuitem": case "menuitemcheckbox": case "menuitemradio": return ACTIVATION; case "link": return ["Enter"]; case "checkbox": case "switch": case "radio": return ["Space"]; case "menu": case "menubar": case "tree": return [ ...ARROWS, ...EDGES, ...ACTIVATION, TYPEAHEAD ]; case "listbox": return [ ...orientation === "horizontal" ? HORIZONTAL : VERTICAL, ...EDGES, ...ACTIVATION, TYPEAHEAD ]; case "combobox": return [ ...VERTICAL, ...EDGES, ...ACTIVATION, TYPEAHEAD ]; case "tablist": return [ ...orientation === "vertical" ? VERTICAL : HORIZONTAL, ...EDGES, ...ACTIVATION ]; case "toolbar": return [...orientation === "vertical" ? VERTICAL : HORIZONTAL, ...EDGES]; case "grid": case "treegrid": return [ ...ARROWS, ...EDGES, ...PAGES, ...ACTIVATION ]; case "radiogroup": return [...ARROWS, "Space"]; case "slider": return [ ...ARROWS, ...EDGES, ...PAGES ]; case "spinbutton": return [ ...VERTICAL, ...EDGES, ...PAGES ]; } switch (element.localName) { case "button": case "summary": return ACTIVATION; case "input": { const type = element.type; return type === "button" || type === "submit" || type === "reset" ? ACTIVATION : []; } case "a": case "area": return element.hasAttribute("href") ? ["Enter"] : []; } return []; } /** * Whether a control handles this key itself. Ctrl, Meta, and Alt combinations * are never owned; Enter and Space are owned only without modifiers, while * navigation and typeahead keys allow Shift (for example, to extend a selection). */ function ownsKey(element, event, key) { if (event.ctrlKey || event.metaKey || event.altKey) return false; const owned = getOwnedKeys(element); if (key === "Enter" || key === "Space") return !event.shiftKey && owned.includes(key); return owned.includes(key) || owned.includes(TYPEAHEAD) && /^\S$/u.test(event.key); } /** * Whether the focused element or an element on the event's path passes `test`. * Only elements inside the registration target count: walking outward from * the event's origin stops at the registration target, so a hotkey targeted * at a control, or at an element inside it, is not blocked by that control. */ function someElementInsideTarget(event, listenerTarget, registrationTarget, test) { const scoped = !isWindow(registrationTarget) && registrationTarget.nodeType !== 9; const focused = getActiveElementForListenerTarget(listenerTarget); if (focused && focused !== registrationTarget && (!scoped || registrationTarget.contains(focused)) && test(focused)) return true; const path = event.composedPath(); for (const element of path.length > 0 ? path : [event.target]) { if (element === registrationTarget) break; if (isElement(element) && test(element)) return true; } return false; } /** * Returns whether a hotkey should skip an event because a focused control * inside the registration target handles the key itself: * - text inputs, textareas, selects, and contenteditable elements (typing) * - buttons, links, and `<summary>` elements (Enter and Space activation) * - ARIA widgets such as menus, listboxes, tabs, and grids (navigation keys) */ function shouldIgnoreInputEvent(event, listenerTarget, registrationTarget) { const key = normalizeKeyName(event.key); return someElementInsideTarget(event, listenerTarget, registrationTarget, (element) => isInputElement(element) || ownsKey(element, event, key)); } /** * Returns whether an event comes from a text input, textarea, select, or * contenteditable element in the target's document. Unlike * `shouldIgnoreInputEvent`, buttons, links, and ARIA widgets don't count. */ function isTypingEvent(event, target) { return someElementInsideTarget(event, target, target, isInputElement); } /** * Checks if an event is for the given target (originated from or bubbled to it). * * For document/window targets, also accepts the document's root element as * currentTarget to handle Brave and other browsers where currentTarget may be * documentElement instead of document when listeners are attached to document. */ function isEventForTarget(event, target) { if (event.currentTarget === target) return true; if (isWindow(target) || target.nodeType === 9) return event.currentTarget === getTargetDocument(target).documentElement; return isNode(event.target) && target.contains(event.target); } //#endregion export { getActiveElementForListenerTarget, getTargetDocument, isEventForTarget, isInputElement, isTypingEvent, shouldIgnoreInputEvent, targetHasFocus };