Round 2 of the deep review: - A click on, or a key typed into, the focus keeper's stand-in fires no focusin, so a restore still polling for its list could take the focus from under the reader. A pointerdown or keydown now makes it the reader's own focus. - The Jump-to pill hid its "Ctrl K" from its name (the voice command "click Ctrl K" still matched nothing); the key cap's tooltip now equals its name, so it is not read twice as a description. - listNav's comment still cited DataTable's two layouts as the reason for its visibility filter.
106 lines
4.8 KiB
TypeScript
106 lines
4.8 KiB
TypeScript
import { useEffect, type RefObject } from "react";
|
|
|
|
// When the element holding the keyboard focus is REMOVED by what it just did — an invite row gone
|
|
// after Approve, a toolbar gone after "Clear all", a section gone once its last row is handled — the
|
|
// browser drops the focus to <body>, and the next Tab starts over at the top of the document. That is
|
|
// the same stranding FocusSafeButton fixes for a button that merely turns disabled, one step further.
|
|
//
|
|
// So the page keeps the focus in place: the first focusable thing now in the removed node's slot —
|
|
// in what followed it, else what preceded it, else the nearest ancestor still on the page. Scoped to
|
|
// one container (the page scroller), which is what keeps it off the layers with their own focus
|
|
// logic: a menu or a dialog is portaled outside it and returns the focus to its opener itself.
|
|
// Keyboard lists (`listNav`) are left alone as well — their `j` already goes on from the slot, and a
|
|
// row that merely scrolled out of a virtualized list must not pull the focus after it.
|
|
|
|
const FOCUSABLE =
|
|
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
|
|
function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null {
|
|
let n: Node | null = node;
|
|
while (n) {
|
|
if (n instanceof HTMLElement) {
|
|
if (n.matches(FOCUSABLE)) return n;
|
|
const all = n.querySelectorAll<HTMLElement>(FOCUSABLE);
|
|
const hit = fromEnd ? all[all.length - 1] : all[0];
|
|
if (hit) return hit;
|
|
}
|
|
n = fromEnd ? n.previousSibling : n.nextSibling;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The element the keeper last put the focus on, while it still holds it. A Back that swaps the page
|
|
// or a sub-view removes the held control too, and the keeper's stand-in then sat where
|
|
// `restoreNavPosition` looks for an untouched <body>: the restore gave up and Back left the focus on
|
|
// the first tile instead of the one the view was opened from (measured in Chrome). A stand-in is
|
|
// not the reader's choice, so the restore may take the focus from it.
|
|
let standIn: HTMLElement | null = null;
|
|
|
|
/** Whether `el` holds the focus only because the keeper put it there. */
|
|
export function isStandInFocus(el: Element | null): boolean {
|
|
return el !== null && el === standIn;
|
|
}
|
|
|
|
/** Where the focus goes once `lost` (removed with the nodes of `record`) has left the page — never
|
|
* outside `root`: a slot with nothing focusable left (a page swapped for its loading fallback) must
|
|
* not send the focus to the first control of the whole document. */
|
|
function slotFor(record: MutationRecord, lost: Node, root: HTMLElement): HTMLElement | null {
|
|
const removed = [...record.removedNodes].find((n) => n === lost || n.contains(lost));
|
|
if (!removed) return null;
|
|
const after = firstFocusable(record.nextSibling);
|
|
if (after) return after;
|
|
const before = firstFocusable(record.previousSibling, true);
|
|
if (before) return before;
|
|
let up: Node | null = record.target;
|
|
while (up && up instanceof HTMLElement && root.contains(up)) {
|
|
const inside = firstFocusable(up.firstChild);
|
|
if (inside) return inside;
|
|
up = up.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function useFocusKeeper(ref: RefObject<HTMLElement | null>): void {
|
|
useEffect(() => {
|
|
const root = ref.current;
|
|
if (!root) return;
|
|
let held: HTMLElement | null = null;
|
|
const onFocusIn = (e: FocusEvent) => {
|
|
const t = e.target as HTMLElement | null;
|
|
if (t !== standIn) standIn = null;
|
|
held = t && root.contains(t) && !t.closest("[data-nav-index]") ? t : null;
|
|
};
|
|
// Using the stand-in makes it the reader's own focus: a click on it or a key typed into it fires
|
|
// no focusin, and a restore still polling for its list would otherwise take the focus from
|
|
// under the reader's hands.
|
|
const onUse = () => {
|
|
standIn = null;
|
|
};
|
|
const watch = new MutationObserver((records) => {
|
|
const lost = held;
|
|
if (!lost || lost.isConnected) return;
|
|
held = null;
|
|
const active = document.activeElement;
|
|
if (active && active !== document.body) return; // something already took the focus
|
|
for (const r of records) {
|
|
const target = slotFor(r, lost, root);
|
|
if (target) {
|
|
standIn = target;
|
|
target.focus({ preventScroll: true });
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener("focusin", onFocusIn);
|
|
document.addEventListener("pointerdown", onUse, true);
|
|
document.addEventListener("keydown", onUse, true);
|
|
watch.observe(root, { childList: true, subtree: true });
|
|
return () => {
|
|
document.removeEventListener("focusin", onFocusIn);
|
|
document.removeEventListener("pointerdown", onUse, true);
|
|
document.removeEventListener("keydown", onUse, true);
|
|
watch.disconnect();
|
|
};
|
|
}, [ref]);
|
|
}
|