UNPKG

@tanstack/hotkeys

Version:

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

283 lines (281 loc) • 8.85 kB
import { normalizeKeyName } from "./constants.js"; //#region src/_event-target.ts function isElement(node) { return node?.nodeType === 1; } /** Whether a target is a document or window, which receive every key press in their document. */ function isDocumentLevel(target) { return "document" in target || target.nodeType === 9; } /** Returns the document a listener target belongs to, in any frame. */ function getTargetDocument(target) { return "document" in target ? target.document : target.ownerDocument ?? target; } /** The focused element, looking inside open shadow roots. */ function getDeepActiveElement(doc) { let active = doc.activeElement; while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; return active; } /** The next node a key event bubbles to: a slot, a shadow host, or the parent. */ function composedParent(node) { return node.assignedSlot ?? node.parentNode ?? (node.host || null); } /** * 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 or on content slotted into it. */ function targetHasFocus(target) { if (isDocumentLevel(target)) return true; for (let node = getDeepActiveElement(target.ownerDocument); node; node = composedParent(node)) if (node === target) return true; return false; } const VERTICAL = ["ArrowUp", "ArrowDown"]; const HORIZONTAL = ["ArrowLeft", "ArrowRight"]; const ENTER = ["Enter"]; const SPACE = ["Space"]; const ACTIVATION = ["Enter", "Space"]; const TYPEAHEAD = "typeahead"; const MENU = [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "Enter", "Space", TYPEAHEAD ]; const COMBOBOX = [ "ArrowUp", "ArrowDown", "Home", "End", "Enter", "Space", TYPEAHEAD ]; const GRID = [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "Enter", "Space" ]; const RADIOS = [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Space" ]; const NAVIGATION = [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown" ]; const SPIN = [ "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown" ]; const CARET = [ "ArrowLeft", "ArrowRight", "Home", "End" ]; /** * Input types that aren't text fields, with the keys each one keeps. Browsers * report unknown types as `'text'`. */ const INPUT_KEYS = { button: ACTIVATION, submit: ACTIVATION, reset: ACTIVATION, image: ACTIVATION, color: ACTIVATION, file: ACTIVATION, checkbox: SPACE, radio: RADIOS, range: NAVIGATION }; function getRole(element) { return element.getAttribute("role")?.trim().split(/\s+/)[0]; } /** * Checks if an element is a text field that hotkeys should not type over: * - text-entry `<input>` types (text, number, date, and similar) that aren't read-only * - `<textarea>` elements that aren't read-only, and `<select>` elements * - elements with `role="textbox"` or `role="searchbox"` * - contenteditable elements * * Checkbox, radio, range, color, file, and button-type inputs are not text * fields. Hotkeys skip only the keys those controls use themselves. */ function isInputElement(element) { if (!isElement(element)) return false; switch (element.localName) { case "input": { const input = element; return !INPUT_KEYS[input.type] && !input.readOnly; } case "textarea": return !element.readOnly; case "select": return true; } const role = getRole(element); return role === "textbox" || role === "searchbox" || element.isContentEditable; } /** * Whether the focused custom element may be a text field whose contents can't * be seen from outside: * - a host with focus inside a closed shadow root. Without a `tabindex` it * can't hold focus itself, and an open root would have shown the focused * element inside it. * - a form-associated element without a `role` attribute naming another control */ function isHiddenTextField(element) { if (!element.localName.includes("-")) return false; return element.constructor.formAssociated === true ? !element.hasAttribute("role") : !element.hasAttribute("tabindex"); } /** * 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/). * Editable text fields never get here: they block every key that types text. */ function getOwnedKeys(element) { const orientation = element.getAttribute("aria-orientation"); switch (getRole(element)) { 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 MENU; case "listbox": return [ ...orientation === "horizontal" ? HORIZONTAL : VERTICAL, "Home", "End", "Enter", "Space", TYPEAHEAD ]; case "combobox": return COMBOBOX; case "tablist": return [ ...orientation === "vertical" ? VERTICAL : HORIZONTAL, "Home", "End", "Enter", "Space" ]; case "toolbar": return [ ...orientation === "vertical" ? VERTICAL : HORIZONTAL, "Home", "End" ]; case "grid": case "treegrid": return GRID; case "radiogroup": return RADIOS; case "slider": return NAVIGATION; case "spinbutton": return SPIN; } switch (element.localName) { case "button": case "summary": return ACTIVATION; case "input": return INPUT_KEYS[element.type] ?? CARET; case "textarea": return NAVIGATION; 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) && /^.$/u.test(event.key); } /** * Finds the first element on the event's way out that blocks it, walking out * from the focused element. A key event starts there in a browser. One sent * by a test may start at an ancestor of the focused element or at the * document, and the walk from focus passes through those too. Only an event * that starts where focus can't be seen, such as inside a closed shadow root, * uses its own path. */ function findBlocker(event, doc, test) { const focused = getDeepActiveElement(doc); let elements = []; for (let node = focused; node; node = composedParent(node)) if (isElement(node)) elements.push(node); const origin = event.composedPath()[0]; if (isElement(origin) && !elements.includes(origin)) elements = event.composedPath().filter(isElement); const index = elements.findIndex((element) => test(element) || element === focused && isHiddenTextField(element)); return { elements, index }; } /** Whether the blocking element is inside `target`. A target never blocks itself. */ function blocks({ elements, index }, target) { return index >= 0 && (isDocumentLevel(target) || index < elements.indexOf(target)); } const ignoreCache = /* @__PURE__ */ new WeakMap(); /** * Returns whether a hotkey on `target` should skip an event because a control * inside the target handles the key itself: * - text fields, including ARIA text roles and custom elements that hide one (typing) * - buttons, links, `<summary>`, and button-like inputs (Enter and Space activation) * - checkbox, radio, and range inputs, and ARIA widgets such as menus, * listboxes, tabs, and grids (the keys they use) * * A hotkey targeted at a control receives that control's keys. */ function shouldIgnoreInputEvent(event, target) { let blocker = ignoreCache.get(event); if (!blocker) { const key = normalizeKeyName(event.key); blocker = findBlocker(event, getTargetDocument(target), (element) => isInputElement(element) || ownsKey(element, event, key)); ignoreCache.set(event, blocker); } return blocks(blocker, target); } /** * Returns whether an event comes from a text field inside `target`, as * `isInputElement` and hidden custom text fields define them. Buttons, links, * and other controls don't count. */ function isTypingEvent(event, target) { return blocks(findBlocker(event, getTargetDocument(target), isInputElement), target); } //#endregion export { getTargetDocument, isDocumentLevel, isInputElement, isTypingEvent, shouldIgnoreInputEvent, targetHasFocus };