Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
101 lines
4.2 KiB
JavaScript
101 lines
4.2 KiB
JavaScript
// Keyboard reachability: a trap for overlays, and roving tabindex for groups.
|
|
|
|
const FOCUSABLE = [
|
|
"a[href]", "button:not([disabled])", "input:not([disabled])",
|
|
"select:not([disabled])", "textarea:not([disabled])",
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
].join(",");
|
|
|
|
// Hiding is inherited: a control inside a hidden or aria-hidden subtree is
|
|
// unreachable however focusable it looks on its own, and trapping focus onto one
|
|
// strands the keyboard on something invisible.
|
|
const HIDDEN = '[hidden], [aria-hidden="true"]';
|
|
|
|
export function focusables(container) {
|
|
return [...container.querySelectorAll(FOCUSABLE)].filter((n) => {
|
|
const hidden = n.closest(HIDDEN);
|
|
return !hidden || !container.contains(hidden);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Keep Tab inside `container` until released, and put focus back where it was.
|
|
*
|
|
* Restoring matters as much as trapping: closing a dialog and dropping focus to
|
|
* <body> silently ends keyboard navigation, and the only way back is the mouse.
|
|
*/
|
|
const modals = []; // modal traps, bottom to top: only the top one may pull focus
|
|
|
|
export function trap(container, { initial, modal = false } = {}) {
|
|
const previous = document.activeElement;
|
|
|
|
const onKeyDown = (event) => {
|
|
if (event.key !== "Tab") return;
|
|
if (modal && modals[modals.length - 1] !== container) return;
|
|
if (modal && document.activeElement && document.activeElement.closest(".cx-menu")) return; // a menu opened over the card keeps its own Tab
|
|
const items = focusables(container);
|
|
if (!items.length) { event.preventDefault(); return; }
|
|
const first = items[0];
|
|
const last = items[items.length - 1];
|
|
const active = document.activeElement;
|
|
const inside = container.contains(active) && active !== container; // the card itself (a click on its text) is not a stop inside it
|
|
|
|
// Wrapping is also the fix for focus having escaped the container entirely
|
|
// (a click outside, a removed node): either edge pulls it back in.
|
|
if (event.shiftKey && (active === first || !inside)) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && (active === last || !inside)) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
};
|
|
|
|
// A modal listens on the document: focus that left the card (a click on plain text) must still be pulled back.
|
|
const scope = modal ? document : container;
|
|
if (modal) modals.push(container);
|
|
scope.addEventListener("keydown", onKeyDown, true);
|
|
const target = initial || focusables(container)[0] || container;
|
|
if (target && target.focus) target.focus();
|
|
|
|
return function release({ restore = true } = {}) {
|
|
scope.removeEventListener("keydown", onKeyDown, true);
|
|
if (modal) modals.splice(modals.indexOf(container), 1);
|
|
if (restore && previous && previous.isConnected && previous.focus) previous.focus();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One tab stop for a group, arrows to move within it -- what a segmented
|
|
* control, toggle group or menu needs so Tab does not walk every option.
|
|
*/
|
|
export function roving(container, { orientation = "horizontal", onSelect } = {}) {
|
|
const NEXT = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
|
|
const PREV = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
|
|
|
|
const items = () => focusables(container);
|
|
|
|
const setIndex = (i) => {
|
|
const list = items();
|
|
if (!list.length) return;
|
|
const clamped = (i + list.length) % list.length;
|
|
list.forEach((n, j) => n.setAttribute("tabindex", j === clamped ? "0" : "-1"));
|
|
list[clamped].focus();
|
|
if (onSelect) onSelect(list[clamped], clamped);
|
|
};
|
|
|
|
const onKeyDown = (event) => {
|
|
const list = items();
|
|
const current = list.indexOf(document.activeElement);
|
|
if (current === -1) return;
|
|
if (event.key === NEXT) { event.preventDefault(); setIndex(current + 1); }
|
|
else if (event.key === PREV) { event.preventDefault(); setIndex(current - 1); }
|
|
else if (event.key === "Home") { event.preventDefault(); setIndex(0); }
|
|
else if (event.key === "End") { event.preventDefault(); setIndex(list.length - 1); }
|
|
};
|
|
|
|
items().forEach((n, i) => n.setAttribute("tabindex", i === 0 ? "0" : "-1"));
|
|
container.addEventListener("keydown", onKeyDown);
|
|
return () => container.removeEventListener("keydown", onKeyDown);
|
|
}
|