|
@@ -43,10 +55,12 @@ interface GridCurrent { |
|
|
cells: HTMLElement[];
|
|
|
}
|
|
|
class GridModel implements GroupModel {
|
|
|
readonly #wraparound;
|
|
|
#current?: GridCurrent;
|
|
|
readonly type = GROUP_TYPE_GRID;
|
|
|
|
|
|
constructor(readonly container: HTMLElement) {
|
|
|
constructor(readonly container: HTMLElement, wraparound: boolean) {
|
|
|
this.#wraparound = wraparound;
|
|
|
}
|
|
|
|
|
|
setCurrent(focusableElement: HTMLElement): boolean {
|
|
@@ -154,13 +172,13 @@ class GridModel implements GroupModel { |
|
|
}
|
|
|
|
|
|
moveFocus(direction: GridDirection): boolean {
|
|
|
if (direction === "up") {
|
|
|
if (direction === MOVE_UP) {
|
|
|
return this.#tryMoveFocusUpDown(-1);
|
|
|
} else if (direction === "down") {
|
|
|
} else if (direction === MOVE_DOWN) {
|
|
|
return this.#tryMoveFocusUpDown(1);
|
|
|
} else if (direction === "left") {
|
|
|
} else if (direction === MOVE_LEFT) {
|
|
|
return this.#tryMoveFocusLeftRight(-1);
|
|
|
} else if (direction === "right") {
|
|
|
} else if (direction === MOVE_RIGHT) {
|
|
|
return this.#tryMoveFocusLeftRight(1);
|
|
|
}
|
|
|
|
|
@@ -174,11 +192,15 @@ interface SequenceCurrent { |
|
|
index: number;
|
|
|
}
|
|
|
class SequenceModel implements GroupModel {
|
|
|
readonly #wraparound;
|
|
|
#current?: SequenceCurrent;
|
|
|
constructor(
|
|
|
readonly type: typeof GROUP_TYPE_HORIZONTAL | typeof GROUP_TYPE_VERTICAL,
|
|
|
readonly container: HTMLElement,
|
|
|
) {}
|
|
|
wraparound: boolean,
|
|
|
) {
|
|
|
this.#wraparound = wraparound;
|
|
|
}
|
|
|
|
|
|
setCurrent(focusableElement: HTMLElement): boolean {
|
|
|
if (!isElementFocusable(focusableElement)) return false;
|
|
@@ -236,13 +262,13 @@ class SequenceModel implements GroupModel { |
|
|
moveFocus(direction: GridDirection): boolean {
|
|
|
const type = this.type;
|
|
|
if (
|
|
|
(direction === "left" && type == GROUP_TYPE_HORIZONTAL) ||
|
|
|
(direction === "up" && type === GROUP_TYPE_VERTICAL)
|
|
|
(direction === MOVE_LEFT && type == GROUP_TYPE_HORIZONTAL) ||
|
|
|
(direction === MOVE_UP && type === GROUP_TYPE_VERTICAL)
|
|
|
) {
|
|
|
return this.#tryMoveFocus(-1);
|
|
|
} else if (
|
|
|
(direction === "right" && type == GROUP_TYPE_HORIZONTAL) ||
|
|
|
(direction === "down" && type === GROUP_TYPE_VERTICAL)
|
|
|
(direction === MOVE_RIGHT && type == GROUP_TYPE_HORIZONTAL) ||
|
|
|
(direction === MOVE_DOWN && type === GROUP_TYPE_VERTICAL)
|
|
|
) {
|
|
|
return this.#tryMoveFocus(1);
|
|
|
}
|
|
@@ -253,19 +279,30 @@ class SequenceModel implements GroupModel { |
|
|
|
|
|
class DocumentModel {
|
|
|
readonly #groups = new WeakMap<Element, GroupModel>();
|
|
|
|
|
|
readonly #tabWraparound;
|
|
|
readonly #creators: Record<
|
|
|
string,
|
|
|
((container: HTMLElement) => GroupModel) | undefined
|
|
|
> = {
|
|
|
[GROUP_TYPE_GRID]: (x) => new GridModel(x),
|
|
|
[GROUP_TYPE_HORIZONTAL]: (x) => new SequenceModel(GROUP_TYPE_HORIZONTAL, x),
|
|
|
[GROUP_TYPE_VERTICAL]: (x) => new SequenceModel(GROUP_TYPE_VERTICAL, x),
|
|
|
};
|
|
|
>;
|
|
|
|
|
|
getGroupModel(element: EventTarget | null): GroupModel | undefined {
|
|
|
if (!isElement(element)) return;
|
|
|
constructor(
|
|
|
{ tabWraparound = false, arrowKeyWraparound = true }: KeyboardNavOptions =
|
|
|
{},
|
|
|
) {
|
|
|
this.#tabWraparound = tabWraparound;
|
|
|
|
|
|
this.#creators = {
|
|
|
[GROUP_TYPE_GRID]: (x) => new GridModel(x, arrowKeyWraparound),
|
|
|
|
|
|
[GROUP_TYPE_HORIZONTAL]: (x) =>
|
|
|
new SequenceModel(GROUP_TYPE_HORIZONTAL, x, arrowKeyWraparound),
|
|
|
|
|
|
[GROUP_TYPE_VERTICAL]: (x) =>
|
|
|
new SequenceModel(GROUP_TYPE_VERTICAL, x, arrowKeyWraparound),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
getGroupModel(element: HTMLElement): GroupModel | undefined {
|
|
|
const groupElement = element.closest(SEL_GROUP);
|
|
|
if (!isElement(groupElement)) return;
|
|
|
|
|
@@ -287,26 +324,45 @@ class DocumentModel { |
|
|
}
|
|
|
|
|
|
getAdjacentGroupModel(
|
|
|
element: EventTarget | null,
|
|
|
groupModel: GroupModel,
|
|
|
direction: SequenceDirection,
|
|
|
): GroupModel | undefined {
|
|
|
if (!isElement(element)) return;
|
|
|
|
|
|
const groupElement = element.closest(SEL_GROUP);
|
|
|
if (!isElement(groupElement)) return;
|
|
|
|
|
|
const groupElements = findAll(document.body, SEL_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;
|
|
|
const index = groupElements.indexOf(groupModel.container);
|
|
|
if (index === -1) return;
|
|
|
|
|
|
const dir = direction === MOVE_PREV ? -1 : 1;
|
|
|
const adjacentElement = arrayAt(
|
|
|
groupElements,
|
|
|
index + dir,
|
|
|
this.#tabWraparound,
|
|
|
);
|
|
|
|
|
|
return this.getGroupModel(groupElements[newIndexWrappedAround]);
|
|
|
return adjacentElement && this.getGroupModel(adjacentElement);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export interface KeyboardNavOptions {
|
|
|
/**
|
|
|
* Whether to
|
|
|
* Default: `false`
|
|
|
*/
|
|
|
tabWraparound?: boolean;
|
|
|
|
|
|
/**
|
|
|
* Whether
|
|
|
* Default: `true`
|
|
|
*/
|
|
|
arrowKeyWraparound?: boolean;
|
|
|
}
|
|
|
|
|
|
export class KeyboardNav {
|
|
|
readonly #documentModel;
|
|
|
|
|
|
constructor(options?: KeyboardNavOptions) {
|
|
|
this.#documentModel = new DocumentModel(options);
|
|
|
}
|
|
|
|
|
|
enable() {
|
|
|
document.addEventListener("keydown", this.#keydownListener);
|
|
|
document.addEventListener("focus", this.#focusListener, { capture: true });
|
|
@@ -315,10 +371,10 @@ export class KeyboardNav { |
|
|
disable() {
|
|
|
document.removeEventListener("keydown", this.#keydownListener);
|
|
|
document.removeEventListener("focus", this.#focusListener);
|
|
|
this.#subTreeObserver.valueIfInitialized?.disconnect();
|
|
|
this.#temporaryFocusableDiv.valueIfInitialized?.remove();
|
|
|
}
|
|
|
|
|
|
readonly #documentModel = new DocumentModel();
|
|
|
|
|
|
readonly #subTreeObserver = new Lazy(() =>
|
|
|
new MutationObserver(() => {
|
|
|
if (this.#lastFocused && !isElementFocusable(this.#lastFocused.element)) {
|
|
@@ -333,13 +389,27 @@ export class KeyboardNav { |
|
|
})
|
|
|
);
|
|
|
|
|
|
readonly #temporaryFocusableDiv = new Lazy(() => {
|
|
|
const div = document.createElement("div");
|
|
|
div.tabIndex = 0;
|
|
|
div.dataset[DATA_TEMPORARY_FOCUSABLE] = "";
|
|
|
Object.assign(div.style, {
|
|
|
position: "absolute",
|
|
|
width: 0,
|
|
|
height: 0,
|
|
|
overflow: "hidden",
|
|
|
});
|
|
|
div.onblur = () => div.remove();
|
|
|
return div;
|
|
|
});
|
|
|
|
|
|
#lastFocused?: {
|
|
|
group: GroupModel;
|
|
|
element: HTMLElement;
|
|
|
};
|
|
|
|
|
|
#focusListener = (e: FocusEvent) => {
|
|
|
this.#subTreeObserver.value.disconnect();
|
|
|
this.#subTreeObserver.valueIfInitialized?.disconnect();
|
|
|
|
|
|
const element = e.target;
|
|
|
if (element instanceof HTMLElement) {
|
|
@@ -370,14 +440,34 @@ export class KeyboardNav { |
|
|
|
|
|
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
|
|
|
|
if (!(e.target instanceof HTMLElement)) return;
|
|
|
|
|
|
let didFocus: boolean | undefined;
|
|
|
|
|
|
if (key === "Tab") {
|
|
|
didFocus = !!this.#documentModel.getAdjacentGroupModel(
|
|
|
e.target,
|
|
|
e.shiftKey ? "prev" : "next",
|
|
|
)
|
|
|
?.focusCurrent();
|
|
|
const groupModel = this.#documentModel.getGroupModel(e.target);
|
|
|
|
|
|
if (groupModel) {
|
|
|
const adjacentModel = this.#documentModel.getAdjacentGroupModel(
|
|
|
groupModel,
|
|
|
e.shiftKey ? MOVE_PREV : MOVE_NEXT,
|
|
|
);
|
|
|
|
|
|
if (adjacentModel) {
|
|
|
didFocus = !!adjacentModel.focusCurrent();
|
|
|
} else {
|
|
|
// We can't control focus fully, but we can ensure that tabbing twice
|
|
|
// will allow the user to tab out of the page.
|
|
|
const div = this.#temporaryFocusableDiv.value;
|
|
|
if (e.shiftKey) {
|
|
|
document.body.prepend(div);
|
|
|
} else {
|
|
|
document.body.append(div);
|
|
|
}
|
|
|
div.focus();
|
|
|
didFocus = true;
|
|
|
}
|
|
|
}
|
|
|
} else if (!e.shiftKey && KeyboardNav.#keyDirectionMap[key]) {
|
|
|
didFocus = this.#documentModel.getGroupModel(e.target)?.moveFocus(
|
|
|
KeyboardNav.#keyDirectionMap[key],
|
|
@@ -389,29 +479,3 @@ export class KeyboardNav { |
|
|
}
|
|
|
};
|
|
|
} |
|
|
|
|
|
export class SingletonKeyboardNav {
|
|
|
#enabledCounter = 0;
|
|
|
#keyboardNav = new KeyboardNav();
|
|
|
#warning;
|
|
|
|
|
|
constructor(warning: string) {
|
|
|
this.#warning = warning;
|
|
|
}
|
|
|
|
|
|
enable() {
|
|
|
if (this.#enabledCounter === 0) {
|
|
|
this.#keyboardNav.enable();
|
|
|
} else {
|
|
|
console.warn(this.#warning);
|
|
|
}
|
|
|
this.#enabledCounter++;
|
|
|
}
|
|
|
|
|
|
disable() {
|
|
|
this.#enabledCounter--;
|
|
|
if (this.#enabledCounter === 0) {
|
|
|
this.#keyboardNav.disable();
|
|
|
}
|
|
|
}
|
|
|
} |