From c1e28224aa2e1450031669d9a7e359b63e04687d Mon Sep 17 00:00:00 2001 From: Volpeon Date: Wed, 22 Jul 2026 19:40:59 +0200 Subject: Init --- src/scripts/lib/keyboardNav.ts | 272 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 src/scripts/lib/keyboardNav.ts (limited to 'src/scripts/lib/keyboardNav.ts') diff --git a/src/scripts/lib/keyboardNav.ts b/src/scripts/lib/keyboardNav.ts new file mode 100644 index 0000000..4c79a08 --- /dev/null +++ b/src/scripts/lib/keyboardNav.ts @@ -0,0 +1,272 @@ +import { getVerticalNeighbor } from "./dom.ts"; + +function focusPrev( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = [...owner.querySelectorAll(itemSelector)]; + const i = items.indexOf(current); + + const prev = i > 0 ? items[i - 1] : wrap ? items.at(-1) : undefined; + + if (!prev || prev === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + prev.removeAttribute("tabindex"); + } + prev.focus(); + return true; +} + +function focusNext( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = [...owner.querySelectorAll(itemSelector)]; + const i = items.indexOf(current); + + const next = i < items.length - 1 ? items[i + 1] : wrap ? items[0] : undefined; + + if (!next || next === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + next.removeAttribute("tabindex"); + } + next.focus(); + return true; +} + +function focusUp( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = [...owner.querySelectorAll(itemSelector)]; + let neighbor: HTMLElement | undefined = getVerticalNeighbor(-1, items, current); + + if (!neighbor && wrap) { + neighbor = items.at(-1); + } + + if (!neighbor || neighbor === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + neighbor.removeAttribute("tabindex"); + } + neighbor.focus(); + return true; +} + +function focusDown( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = owner.querySelectorAll(itemSelector); + let neighbor = getVerticalNeighbor(1, items, current); + + if (!neighbor && wrap) { + neighbor = items[0]; + } + + if (!neighbor || neighbor === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + neighbor.removeAttribute("tabindex"); + } + neighbor.focus(); + return true; +} + +function focusFirst( + owner: HTMLElement, + itemSelector: string, + stateful: boolean, + current: HTMLElement, +) { + const cards = [...owner.querySelectorAll(itemSelector)]; + const first = cards[0]; + + if (!first || first === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + first.removeAttribute("tabindex"); + } + first.focus(); + return true; +} + +function focusLast( + owner: HTMLElement, + itemSelector: string, + stateful: boolean, + current: HTMLElement, +) { + const cards = [...owner.querySelectorAll(itemSelector)]; + const last = cards.at(-1); + + if (!last || last === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + last.removeAttribute("tabindex"); + } + last.focus(); + return true; +} + +function onKeyDown( + owner: HTMLElement, + itemSelector: string, + horizontal: boolean, + vertical: boolean, + wrap: boolean, + stateful: boolean, + e: KeyboardEvent, +) { + let handled = false; + + if (!e.currentTarget) { + return; + } + + switch (e.key) { + case "ArrowLeft": { + if (horizontal) { + handled = focusPrev(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "ArrowRight": { + if (horizontal) { + handled = focusNext(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "ArrowUp": { + if (vertical) { + handled = focusUp(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "ArrowDown": { + if (vertical) { + handled = focusDown(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "Home": { + handled = focusFirst(owner, itemSelector, stateful, e.currentTarget as HTMLElement); + break; + } + + case "End": { + handled = focusLast(owner, itemSelector, stateful, e.currentTarget as HTMLElement); + break; + } + } + + if (handled) { + e.stopPropagation(); + e.preventDefault(); + } +} + +export function mountKeyboardNav() { + const keyboardNavEls = document.querySelectorAll(".js-keyboard-nav"); + + for (const keyboardNavEl of keyboardNavEls) { + const itemSelector = keyboardNavEl.dataset.keyboardNavItem ?? ""; + + if (!itemSelector) { + console.error("Keyboard nav: No item class provided"); + continue; + } + + let horizontal = false, + vertical = false, + wrap = false, + stateful = false; + + switch (keyboardNavEl.dataset.keyboardNavMode) { + case "grid": { + horizontal = true; + vertical = true; + stateful = true; + break; + } + + case "nav": { + horizontal = true; + wrap = true; + break; + } + + case "menu": { + vertical = true; + wrap = true; + break; + } + + case "list": { + vertical = true; + wrap = true; + stateful = true; + break; + } + } + + const onKeyDown_ = (e: KeyboardEvent) => { + onKeyDown(keyboardNavEl, itemSelector, horizontal, vertical, wrap, stateful, e); + }; + + const itemEls = [...keyboardNavEl.querySelectorAll(itemSelector)].filter((el) => + el.matches("a, button, input"), + ); + let focused = false; + + for (const itemEl of itemEls) { + itemEl.addEventListener("keydown", onKeyDown_); + if (!itemEl.classList.contains("is-selected")) { + itemEl.setAttribute("tabindex", "-1"); + } else { + focused = true; + } + } + + if (!focused) { + itemEls[0]?.removeAttribute("tabindex"); + } + } +} -- cgit v1.3.1