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"); } } }