UNPKG

d-pad

Version:

easy keyboard navigation without having to change your html

407 lines (406 loc) 13.8 kB
"use strict"; var dpad = function () { class Vec2 { constructor(x, y) { this.x = x; this.y = y; } length2() { return this.x * this.x + this.y * this.y; } length() { return Math.sqrt(this.length2()); } abs() { this.x = Math.abs(this.x); this.y = Math.abs(this.y); return this; } transpose() { const t = this.x; this.x = this.y; this.y = t; return this; } invert() { this.x = -this.x; this.y = -this.y; return this; } scale(scalar) { this.x *= scalar; this.y *= scalar; return this; } div(scalar) { this.x /= scalar; this.y /= scalar; return this; } to(other) { this.x -= other.x; this.y -= other.y; return this; } equal(other) { if (this.x !== other.x) return false; if (this.y !== other.y) return false; return true; } dot(other) { return this.x * other.x + this.y * other.y; } copy() { return new Vec2(this.x, this.y); } toString() { return `vec2{${this.x}, ${this.y}}`; } } class Rect { constructor(element) { if (!element.isConnected) console.warn("D-Pad GetBounds: Element might not be rendered or connected to the DOM.", element); const rect = element.getBoundingClientRect(); this.tl = new Vec2(rect.left, rect.top); this.br = new Vec2(rect.right, rect.bottom); } overlaps(other) { const x = this.tl.x < other.br.x && other.tl.x < this.br.x; const y = this.tl.y < other.br.y && other.tl.y < this.br.y; return x && y; } center() { const d = this.size(); d.scale(0.5); return new Vec2(this.tl.x + d.x, this.tl.y + d.x); } size() { return new Vec2(this.br.x - this.tl.x, this.br.y - this.tl.y); } /** * The shortest vector to travel between non-overlapping rectangles * If overlapping, is the vector between Rect.center()s */ vector(other) { let dx = 0; if (other.br.x < this.tl.x) { dx = other.br.x - this.tl.x; } else if (this.br.x < other.tl.x) { dx = other.tl.x - this.br.x; } let dy = 0; if (other.br.y < this.tl.y) { dy = other.br.y - this.tl.y; } else if (this.br.y < other.tl.y) { dy = other.tl.y - this.br.y; } if (dx === 0 && dy === 0) return other.center().to(this.center()); return new Vec2(dx, dy); } toString() { return `rect{ ${this.tl}, ${this.br} }`; } } let groupFocus = null; function NearestElement(target, dir, group) { const cursor = new Rect(target); const position = cursor.center(); const best = { alignment: 0, distance: Infinity, element: null, skew: Infinity }; for (const element of group) { const bounds = new Rect(element); if (bounds.overlaps(cursor)) continue; const d = cursor.vector(bounds); const distance = d.length2(); d.div(distance); const alignment = dir.dot(d); if (alignment === 0) continue; if (alignment < best.alignment) continue; if (distance > best.distance) continue; const offset = bounds.center().to(position); const axis = dir.copy().transpose().abs(); const skew = axis.dot(offset); if (alignment === best.alignment && distance == best.distance) { if (skew > best.skew) continue; } best.element = element; best.alignment = alignment; best.distance = distance; best.skew = skew; } return best === null || best === void 0 ? void 0 : best.element; } function Move(focus, dir, simulated = false) { const scope = GetGroup(focus); const best = NearestElement(focus, dir, GroupElements(scope, focus)); const from = new Rect(focus); if (best) { if (best.classList.contains("dp-group")) groupFocus = { element: best, dir, from }; else groupFocus = null; best.focus(); return true; } if (simulated) return false; if (scope != document.body) { groupFocus = { element: scope, dir: dir.invert(), from }; scope.focus(); return true; } if (groupFocus) groupFocus.dir = dir; return false; } function AutoFlow(focus, moves) { if (!CheckRule(focus, "dp-flow-auto", "dp-flow-strict")) return false; for (const move of moves) if (Move(focus, move.copy(), true)) return true; // try move return false; } function Next(focus) { setTimeout(() => { if (!focus.maxLength || focus.maxLength === -1) return false; if (focus.value.length < focus.maxLength) return false; AutoFlow(focus, autoFlow.next); }, 0); return false; // don't consume input } function Back(focus) { setTimeout(() => { if (focus.value.length !== 0) return; AutoFlow(focus, autoFlow.back); }, 0); return false; // don't consume input } function GetGroup(e) { var _a; const r = (_a = e.parentElement) === null || _a === void 0 ? void 0 : _a.closest(".dp-group"); if (r instanceof HTMLElement) return r; return document.body; } function* GroupElements(scope, ignore) { const elements = scope.querySelectorAll('input, textarea, select, button, a, *[tabindex], audio, video, summary'); for (const element of elements) { if (element === scope) continue; if (element === ignore) continue; if (!(element instanceof HTMLElement)) continue; if (element.classList.contains("dp-ignore")) continue; if (element.classList.contains("dp-disable")) continue; if (GetGroup(element) != scope) continue; if (element.hasAttribute("disabled")) continue; const style = window.getComputedStyle(element); if (style.display === "none") continue; if (style.visibility === "hidden") continue; // hidden if (style.display === "contents") continue; // not focusable yield element; } } function EnterGroup(focus) { const best = { distance: Infinity, element: null, }; for (const element of GroupElements(focus.element)) { const bounds = new Rect(element); const offset = bounds.vector(focus.from); const distance = offset.length2(); if (distance > best.distance) continue; best.element = element; best.distance = distance; } if (!best.element) return false; best.element.focus(); return true; } function CheckRule(element, on, off) { if (element.classList.contains(on)) return true; if (element.classList.contains(off)) return false; const parent = element.closest(`.${on}, .${off}`); if (!parent) return false; if (parent.classList.contains(on)) return true; if (parent.classList.contains(off)) return false; return false; } document.addEventListener("keydown", (ev) => { const focus = document.activeElement; if (!focus || !(focus instanceof HTMLElement)) return; if (!CheckRule(focus, "dp-enable", "dp-disable")) return false; const block = HandleKey(ev, focus); if (block) ev.preventDefault(); if (ev.key !== " ") return; if (focus instanceof HTMLDivElement || focus instanceof HTMLLabelElement) { ev.preventDefault(); focus.click(); return false; } }); function HandleKey(ev, focus) { if (groupFocus && ev.key === " ") return EnterGroup(groupFocus); if (focus instanceof HTMLInputElement) { switch (ev.key) { case "ArrowRight": { if (focus.selectionStart != focus.value.length) return false; return Move(focus, DIRECTION.right.copy()); } case "ArrowLeft": { if (focus.selectionStart != 0) return false; return Move(focus, DIRECTION.left.copy()); } case "ArrowUp": return Move(focus, DIRECTION.up.copy()); case "ArrowDown": return Move(focus, DIRECTION.down.copy()); case "Backspace": return Back(focus); default: { const isPotentiallyAddingChar = ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey; if (isPotentiallyAddingChar) return Next(focus); } } return false; } if (focus instanceof HTMLSelectElement) { switch (ev.key) { case "ArrowRight": return Move(focus, DIRECTION.right.copy()); case "ArrowLeft": return Move(focus, DIRECTION.left.copy()); case "ArrowUp": { if (focus.selectedIndex != 0) return false; return Move(focus, DIRECTION.up.copy()); } case "ArrowDown": { if (focus.selectedIndex != focus.options.length - 1) return false; return Move(focus, DIRECTION.down.copy()); } } return false; } if (focus instanceof HTMLTextAreaElement) { switch (ev.key) { case "ArrowRight": { const pos = GetTextAreaPosition(focus); if (pos.col !== pos.cols) return false; return Move(focus, DIRECTION.right.copy()); } case "ArrowLeft": { const pos = GetTextAreaPosition(focus); if (pos.col !== 0) return false; return Move(focus, DIRECTION.left.copy()); } case "ArrowUp": { const pos = GetTextAreaPosition(focus); if (pos.line !== 0) return false; return Move(focus, DIRECTION.up.copy()); } case "ArrowDown": { const pos = GetTextAreaPosition(focus); if (pos.line != pos.lines) return false; return Move(focus, DIRECTION.down.copy()); } case "Backspace": return Back(focus); } return false; } const dir = GetKeyDirection(ev.key); if (!dir) return false; return Move(focus, dir); } function GetTextAreaPosition(input) { let col = 0; let line = 0; for (let i = 0; i < input.selectionStart; i++) { if (input.value[i] === "\n") { col = 0; line++; continue; } col++; } let lines = line; for (let i = input.selectionStart; i < input.value.length; i++) { if (input.value[i] === "\n") lines++; } let cols = input.value.indexOf("\n", input.selectionStart); if (cols === -1) cols = input.value.length - input.selectionStart + col; else { cols = cols - input.selectionStart + col; } return { line, col, cols, lines }; } const DIRECTION = { up: new Vec2(0, -1), down: new Vec2(0, 1), left: new Vec2(-1, 0), right: new Vec2(1, 0), }; Object.freeze(DIRECTION); Object.freeze(DIRECTION.up); Object.freeze(DIRECTION.down); Object.freeze(DIRECTION.left); Object.freeze(DIRECTION.right); function GetKeyDirection(key) { if (!key.startsWith("Arrow")) return null; const d = DIRECTION[key.slice("Arrow".length).toLowerCase()]; if (!d) return null; return d.copy(); } const autoFlow = { next: [DIRECTION.right, DIRECTION.down], back: [DIRECTION.left, DIRECTION.up], }; return { direction: DIRECTION, autoFlow, }; }();