|
|
function isElement(x: unknown): x is HTMLElement | SVGElement | MathMLElement {
|
|
|
return x instanceof HTMLElement ||
|
|
|
x instanceof SVGElement ||
|
|
|
x instanceof MathMLElement;
|
|
|
import {
|
|
|
findAll,
|
|
|
findAllFocusable,
|
|
|
findFocusableByIndexOrLast,
|
|
|
isElement,
|
|
|
isElementFocusable,
|
|
|
Lazy,
|
|
|
} from "./util.ts";
|
|
|
|
|
|
type GridDirection = "up" | "down" | "left" | "right";
|
|
|
type SequenceDirection = "prev" | "next";
|
|
|
|
|
|
class GroupModel {
|
|
|
readonly #groupElement;
|
|
|
#cursorElement?: HTMLElement | SVGElement;
|
|
|
#cursor?: number | [number, number];
|
|
|
|
|
|
constructor(groupElement: HTMLElement | SVGElement) {
|
|
|
this.#groupElement = groupElement;
|
|
|
}
|
|
|
|
|
|
function findVisibleByIndexOrLast(
|
|
|
container: Element | undefined,
|
|
|
selector: string,
|
|
|
index: number,
|
|
|
): Element | undefined {
|
|
|
if (!container) return;
|
|
|
const elements = findAllVisible(container, selector);
|
|
|
return elements[index] ?? elements[elements.length - 1];
|
|
|
get #groupType() {
|
|
|
const type = this.#groupElement.dataset.kbdGroup;
|
|
|
if (type === "vertical" || type === "horizontal" || type === "grid") {
|
|
|
return type;
|
|
|
}
|
|
|
|
|
|
function findAllVisible(container: Element, selector: string): Element[] {
|
|
|
return [...container.querySelectorAll(selector)].filter(isElementFocusable);
|
|
|
return undefined;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Determine whether an element is visible (or can scroll to) and whether it is focusable.
|
|
|
*/
|
|
|
function isElementFocusable(element: Element) {
|
|
|
if (!isElement(element)) return false;
|
|
|
setCursor(focusableElement: EventTarget | null) {
|
|
|
// Already handled, nothing to do.
|
|
|
if (this.#cursorElement === focusableElement) return;
|
|
|
|
|
|
if (element instanceof HTMLElement && !element.offsetParent) return false;
|
|
|
if (!isElement(focusableElement)) return;
|
|
|
|
|
|
if ("disabled" in element && element.disabled) return false;
|
|
|
if (!focusableElement.matches("[data-kbd-focusable]")) return;
|
|
|
|
|
|
// Check if the element has a non-zero width and height.
|
|
|
const rect = element.getBoundingClientRect();
|
|
|
if (rect.width === 0 || rect.height === 0) return false;
|
|
|
const groupElement = this.#groupElement;
|
|
|
if (!groupElement.contains(focusableElement)) return;
|
|
|
|
|
|
// Check if the element is not hidden by CSS.
|
|
|
const style = getComputedStyle(element);
|
|
|
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
|
const groupType = this.#groupType;
|
|
|
if (groupType === "vertical" || groupType === "horizontal") {
|
|
|
const focusables = findAllFocusable(groupElement, "[data-kbd-focusable]");
|
|
|
const index = focusables.indexOf(focusableElement);
|
|
|
if (index === -1) return;
|
|
|
|
|
|
this.#cursor = index;
|
|
|
} else if (groupType === "grid") {
|
|
|
const row = focusableElement.closest("[data-kbd-grid-row]");
|
|
|
if (!isElement(row)) return;
|
|
|
|
|
|
const rows = findAll(groupElement, "[data-kbd-grid-row]");
|
|
|
const rowFocusables = findAllFocusable(row, "[data-kbd-focusable]");
|
|
|
const rowIndex = rows.indexOf(row);
|
|
|
const colIndex = rowFocusables.indexOf(focusableElement);
|
|
|
|
|
|
if (rowIndex === -1 || colIndex === -1) return;
|
|
|
|
|
|
this.#cursor = [colIndex, rowIndex];
|
|
|
}
|
|
|
|
|
|
this.#cursorElement = focusableElement;
|
|
|
}
|
|
|
|
|
|
#focus(
|
|
|
focusable: Element | undefined,
|
|
|
cursor: number | [number, number],
|
|
|
): boolean {
|
|
|
if (!isElement(focusable)) return false;
|
|
|
|
|
|
focusable.focus();
|
|
|
this.#cursor = cursor;
|
|
|
this.#cursorElement = focusable;
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
#attemptToFocusSequence(direction: -1 | 1): boolean {
|
|
|
const groupType = this.#groupType;
|
|
|
const cursor = this.#cursor;
|
|
|
if (groupType !== "vertical" && groupType !== "horizontal") return false;
|
|
|
if (typeof cursor !== "number") return false;
|
|
|
|
|
|
const focusables = findAllFocusable(
|
|
|
this.#groupElement,
|
|
|
"[data-kbd-focusable]",
|
|
|
);
|
|
|
|
|
|
return this.#focus(focusables[cursor + direction], cursor + direction);
|
|
|
}
|
|
|
|
|
|
#attemptToFocusUpDown(direction: -1 | 1): boolean {
|
|
|
const cursor = this.#cursor;
|
|
|
if (this.#groupType !== "grid") return false;
|
|
|
if (!Array.isArray(cursor)) return false;
|
|
|
|
|
|
const [cursorX, cursorY] = cursor;
|
|
|
|
|
|
const rows = findAll(this.#groupElement, "[data-kbd-grid-row]");
|
|
|
const row = rows[cursorY + direction];
|
|
|
const focusable = findFocusableByIndexOrLast(
|
|
|
row,
|
|
|
"[data-kbd-focusable]",
|
|
|
cursorX,
|
|
|
);
|
|
|
return this.#focus(focusable, [cursorX, cursorY + direction]);
|
|
|
}
|
|
|
|
|
|
#attemptToFocusLeftRight(direction: -1 | 1): boolean {
|
|
|
const cursor = this.#cursor;
|
|
|
if (this.#groupType !== "grid") return false;
|
|
|
if (!this.#cursorElement) return false;
|
|
|
if (!Array.isArray(cursor)) return false;
|
|
|
|
|
|
const [, cursorY] = cursor;
|
|
|
|
|
|
const rows = findAll(this.#groupElement, "[data-kbd-grid-row]");
|
|
|
const row = rows[cursorY];
|
|
|
const focusables = findAllFocusable(row, "[data-kbd-focusable]");
|
|
|
const cursorX = focusables.indexOf(this.#cursorElement);
|
|
|
const focusable = focusables[cursorX + direction];
|
|
|
return this.#focus(focusable, [cursorX + direction, cursorY]);
|
|
|
}
|
|
|
|
|
|
focusCurrent(): boolean {
|
|
|
if (this.#cursorElement) {
|
|
|
this.#cursorElement.focus();
|
|
|
return true;
|
|
|
} else {
|
|
|
const firstElement = this.#groupElement.querySelector(
|
|
|
"[data-kbd-focusable]",
|
|
|
);
|
|
|
|
|
|
if (isElement(firstElement)) {
|
|
|
this.setCursor(firstElement);
|
|
|
firstElement.focus();
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
moveFocus(direction: GridDirection): boolean {
|
|
|
const groupType = this.#groupType;
|
|
|
|
|
|
if (
|
|
|
(groupType === "vertical" && direction === "up") ||
|
|
|
(groupType === "horizontal" && direction === "left")
|
|
|
) {
|
|
|
return this.#attemptToFocusSequence(-1);
|
|
|
} else if (
|
|
|
(groupType === "vertical" && direction === "down") ||
|
|
|
(groupType === "horizontal" && direction === "right")
|
|
|
) {
|
|
|
return this.#attemptToFocusSequence(1);
|
|
|
} else if (groupType === "grid") {
|
|
|
if (direction === "up") {
|
|
|
return this.#attemptToFocusUpDown(-1);
|
|
|
} else if (direction === "down") {
|
|
|
return this.#attemptToFocusUpDown(1);
|
|
|
} else if (direction === "left") {
|
|
|
return this.#attemptToFocusLeftRight(-1);
|
|
|
} else if (direction === "right") {
|
|
|
return this.#attemptToFocusLeftRight(1);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
class DocumentModel {
|
|
|
readonly #groupModels = new WeakMap<Element, GroupModel>();
|
|
|
|
|
|
getGroupModel(element: EventTarget | null): GroupModel | undefined {
|
|
|
if (!isElement(element)) return;
|
|
|
|
|
|
const groupElement = element.closest("[data-kbd-group]");
|
|
|
if (!isElement(groupElement)) return;
|
|
|
|
|
|
let groupData = this.#groupModels.get(groupElement);
|
|
|
if (!groupData) {
|
|
|
groupData = new GroupModel(groupElement);
|
|
|
this.#groupModels.set(groupElement, groupData);
|
|
|
}
|
|
|
|
|
|
return groupData;
|
|
|
}
|
|
|
|
|
|
getAdjacentGroupModel(
|
|
|
element: EventTarget | null,
|
|
|
direction: SequenceDirection,
|
|
|
): GroupModel | undefined {
|
|
|
if (!isElement(element)) return;
|
|
|
|
|
|
const groupElement = element.closest("[data-kbd-group]");
|
|
|
if (!isElement(groupElement)) return;
|
|
|
|
|
|
const groupElements = findAll(document.body, "[data-kbd-group]");
|
|
|
const length = groupElements.length;
|
|
|
const index = groupElements.indexOf(groupElement);
|
|
|
const dir = direction === "prev" ? -1 : 1;
|
|
|
const newIndex = index + dir;
|
|
|
const newIndexWrappedAround = (newIndex + length) % length;
|
|
|
|
|
|
return this.getGroupModel(groupElements[newIndexWrappedAround]);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export class KeyboardNav {
|
|
|
enable() {
|
|
|
document.addEventListener("keydown", this.#listener);
|
|
|
document.addEventListener("keydown", this.#keydownListener);
|
|
|
document.addEventListener("focus", this.#focusListener, { capture: true });
|
|
|
}
|
|
|
|
|
|
disable() {
|
|
|
document.removeEventListener("keydown", this.#listener);
|
|
|
document.removeEventListener("keydown", this.#keydownListener);
|
|
|
document.removeEventListener("focus", this.#focusListener);
|
|
|
}
|
|
|
|
|
|
#subTreeObserver?: MutationObserver;
|
|
|
readonly #documentModel = new DocumentModel();
|
|
|
|
|
|
readonly #subTreeObserver = new Lazy(() =>
|
|
|
new MutationObserver(() => {
|
|
|
if (!this.#lastFocused) return;
|
|
|
const { element, getReplacement, gridTargetX } = this.#lastFocused;
|
|
|
if (isElementFocusable(element)) return;
|
|
|
|
|
|
const replacement = getReplacement();
|
|
|
if (!replacement || !document.contains(replacement)) return;
|
|
|
this.#focusElement({ element: replacement, getReplacement, gridTargetX });
|
|
|
})
|
|
|
);
|
|
|
|
|
|
#lastFocused?: {
|
|
|
element: Element;
|
|
@@ -70,150 +253,82 @@ export class KeyboardNav { |
|
|
this.#lastFocused = { element, getReplacement, gridTargetX };
|
|
|
element.focus();
|
|
|
|
|
|
this.#subTreeObserver ??= new MutationObserver(() => {
|
|
|
if (!this.#lastFocused) return;
|
|
|
const { element, getReplacement, gridTargetX } = this.#lastFocused;
|
|
|
if (isElementFocusable(element)) return;
|
|
|
|
|
|
const replacement = getReplacement();
|
|
|
if (!replacement || !document.contains(replacement)) return;
|
|
|
this.#focusElement({ element: replacement, getReplacement, gridTargetX });
|
|
|
});
|
|
|
const subTreeObserver = this.#subTreeObserver;
|
|
|
subTreeObserver.observe(document, {
|
|
|
this.#subTreeObserver.value.observe(document, {
|
|
|
childList: true,
|
|
|
subtree: true,
|
|
|
attributes: true,
|
|
|
});
|
|
|
|
|
|
(element as HTMLElement).addEventListener(
|
|
|
element.addEventListener(
|
|
|
"blur",
|
|
|
() => {
|
|
|
if (this.#lastFocused?.element === element) {
|
|
|
this.#lastFocused = undefined;
|
|
|
subTreeObserver.disconnect();
|
|
|
this.#subTreeObserver.value.disconnect();
|
|
|
}
|
|
|
},
|
|
|
{ once: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
#listener = (e: KeyboardEvent) => {
|
|
|
const key = e.key;
|
|
|
if (
|
|
|
key !== "ArrowDown" && key !== "ArrowUp" &&
|
|
|
key !== "ArrowLeft" && key !== "ArrowRight"
|
|
|
) return;
|
|
|
|
|
|
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
|
|
|
|
if (!isElement(e.target)) return;
|
|
|
#focusListener = (e: FocusEvent) => {
|
|
|
this.#documentModel.getGroupModel(e.target)?.setCursor(e.target);
|
|
|
};
|
|
|
|
|
|
const focusable = e.target.closest("[data-kbd-focusable]");
|
|
|
if (!focusable) return;
|
|
|
static #keyDirectionMap: Record<string, GridDirection> = {
|
|
|
ArrowDown: "down",
|
|
|
ArrowUp: "up",
|
|
|
ArrowLeft: "left",
|
|
|
ArrowRight: "right",
|
|
|
};
|
|
|
|
|
|
const group = focusable.closest("[data-kbd-group]");
|
|
|
if (!isElement(group)) return;
|
|
|
#keydownListener = (e: KeyboardEvent) => {
|
|
|
const key = e.key;
|
|
|
|
|
|
const groupType = group.dataset.kbdGroup;
|
|
|
if (groupType === "vertical" || groupType === "horizontal") {
|
|
|
const focusables = findAllVisible(group, "[data-kbd-focusable]");
|
|
|
const index = focusables.indexOf(focusable);
|
|
|
let targetIndex = index;
|
|
|
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
|
|
|
|
if (
|
|
|
(groupType === "vertical" && key === "ArrowUp") ||
|
|
|
(groupType === "horizontal" && key === "ArrowLeft")
|
|
|
) {
|
|
|
targetIndex--;
|
|
|
} else if (
|
|
|
(groupType === "vertical" && key === "ArrowDown") ||
|
|
|
(groupType === "horizontal" && key === "ArrowRight")
|
|
|
) {
|
|
|
targetIndex++;
|
|
|
} else {
|
|
|
return;
|
|
|
}
|
|
|
let didFocus: boolean | undefined;
|
|
|
|
|
|
this.#focusElement({
|
|
|
event: e,
|
|
|
element: focusables[targetIndex],
|
|
|
getReplacement: () =>
|
|
|
findVisibleByIndexOrLast(
|
|
|
group,
|
|
|
"[data-kbd-focusable]",
|
|
|
targetIndex,
|
|
|
),
|
|
|
gridTargetX: undefined,
|
|
|
});
|
|
|
} else if (groupType === "grid") {
|
|
|
const row = focusable.closest("[data-kbd-grid-row]");
|
|
|
if (!isElement(row)) return;
|
|
|
|
|
|
const rows = findAllVisible(group, "[data-kbd-grid-row]");
|
|
|
const rowFocusables = findAllVisible(row, "[data-kbd-focusable]");
|
|
|
const rowIndex = rows.indexOf(row);
|
|
|
const colIndex = rowFocusables.indexOf(focusable);
|
|
|
|
|
|
let targetX = this.#lastFocused?.gridTargetX ?? colIndex;
|
|
|
let targetY = rowIndex;
|
|
|
|
|
|
if (key === "ArrowUp") {
|
|
|
targetY--;
|
|
|
} else if (key === "ArrowLeft") {
|
|
|
targetX = colIndex - 1;
|
|
|
} else if (key === "ArrowDown") {
|
|
|
targetY++;
|
|
|
} else if (key === "ArrowRight") {
|
|
|
targetX = colIndex + 1;
|
|
|
} else {
|
|
|
return;
|
|
|
if (key === "Tab") {
|
|
|
didFocus = this.#documentModel.getAdjacentGroupModel(
|
|
|
e.target,
|
|
|
e.shiftKey ? "prev" : "next",
|
|
|
)
|
|
|
?.focusCurrent();
|
|
|
} else if (!e.shiftKey && KeyboardNav.#keyDirectionMap[key]) {
|
|
|
didFocus = this.#documentModel.getGroupModel(e.target)?.moveFocus(
|
|
|
KeyboardNav.#keyDirectionMap[key],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
this.#focusElement({
|
|
|
event: e,
|
|
|
element: targetY === rowIndex
|
|
|
? rowFocusables[targetX]
|
|
|
: findVisibleByIndexOrLast(
|
|
|
rows[targetY],
|
|
|
"[data-kbd-focusable]",
|
|
|
targetX,
|
|
|
),
|
|
|
getReplacement: () => {
|
|
|
const row = findVisibleByIndexOrLast(
|
|
|
group,
|
|
|
"[data-kbd-grid-row]",
|
|
|
targetY,
|
|
|
);
|
|
|
return findVisibleByIndexOrLast(row, "[data-kbd-focusable]", targetX);
|
|
|
},
|
|
|
gridTargetX: targetX,
|
|
|
});
|
|
|
if (didFocus) {
|
|
|
e.preventDefault();
|
|
|
}
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export class SingletonKeyboardNav {
|
|
|
#count = 0;
|
|
|
#kbd = new KeyboardNav();
|
|
|
#enabledCounter = 0;
|
|
|
#keyboardNav = new KeyboardNav();
|
|
|
#warning;
|
|
|
|
|
|
constructor(warning: string) {
|
|
|
this.#warning = warning;
|
|
|
}
|
|
|
|
|
|
enable() {
|
|
|
if (this.#count === 0) {
|
|
|
this.#kbd.enable();
|
|
|
if (this.#enabledCounter === 0) {
|
|
|
this.#keyboardNav.enable();
|
|
|
} else {
|
|
|
console.warn(this.#warning);
|
|
|
}
|
|
|
this.#count++;
|
|
|
this.#enabledCounter++;
|
|
|
}
|
|
|
|
|
|
disable() {
|
|
|
this.#count--;
|
|
|
if (this.#count === 0) {
|
|
|
this.#kbd.disable();
|
|
|
this.#enabledCounter--;
|
|
|
if (this.#enabledCounter === 0) {
|
|
|
this.#keyboardNav.disable();
|
|
|
}
|
|
|
}
|
|
|
} |