fix(ui): a control removed by its own action leaves the focus in its slot
The second half of the pending-button sweep: where the pressed control does not merely disable but LEAVES the page — an invite row gone after Approve, the inbox's Clear all gone with the last notification, a section gone once its last row is handled — the browser dropped the focus to <body> and the next Tab started over at the top. useFocusKeeper, on the page scroller: when the element holding the focus is removed and the focus fell to <body>, the first focusable thing in its slot takes it (what followed, else what preceded, else the nearest ancestor still on the page). Portaled layers (menus, dialogs) sit outside the scroller and return focus themselves; listNav's keyboard lists are left alone — their `j` already goes on from the slot, and a row scrolled out of a virtualized list must not drag the focus after it.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { useFocusKeeper } from "../../lib/useFocusKeeper";
|
||||
import { StickyTopProvider } from "./stickyTop";
|
||||
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
|
||||
|
||||
@@ -105,6 +106,10 @@ export default function PageScroller({
|
||||
[fadeRef],
|
||||
);
|
||||
|
||||
// A focused control removed by its own action (a row gone after Approve, a toolbar after Clear
|
||||
// all) leaves the focus in its slot rather than on <body> — see useFocusKeeper.
|
||||
useFocusKeeper(elRef);
|
||||
|
||||
// Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
|
||||
const scrollKeyRef = useRef(scrollKey);
|
||||
scrollKeyRef.current = scrollKey;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useFocusKeeper } from "./useFocusKeeper";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const flush = () => act(() => new Promise((r) => setTimeout(r, 0)));
|
||||
|
||||
function Rows({ nav = false }: { nav?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useFocusKeeper(ref);
|
||||
const [rows, setRows] = useState(["a", "b", "c"]);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<h2>Invites</h2>
|
||||
{rows.map((r, i) => (
|
||||
<div key={r} {...(nav ? { "data-nav-index": i } : {})}>
|
||||
<button onClick={() => setRows((xs) => xs.filter((x) => x !== r))}>approve {r}</button>
|
||||
</div>
|
||||
))}
|
||||
<button>after the list</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btn = (name: string) =>
|
||||
[...document.querySelectorAll("button")].find((b) => b.textContent === name)!;
|
||||
|
||||
describe("useFocusKeeper", () => {
|
||||
it("keeps the focus in the removed row's slot — the next row takes it", async () => {
|
||||
render(<Rows />);
|
||||
btn("approve b").focus();
|
||||
act(() => btn("approve b").click());
|
||||
await flush();
|
||||
expect(document.activeElement).toBe(btn("approve c"));
|
||||
});
|
||||
|
||||
it("falls back to what followed the list once the last row is gone", async () => {
|
||||
render(<Rows />);
|
||||
btn("approve c").focus();
|
||||
act(() => btn("approve c").click());
|
||||
await flush();
|
||||
expect(document.activeElement).toBe(btn("after the list"));
|
||||
});
|
||||
|
||||
it("leaves a keyboard list's rows to listNav", async () => {
|
||||
render(<Rows nav />);
|
||||
btn("approve b").focus();
|
||||
act(() => btn("approve b").click());
|
||||
await flush();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** Where the focus goes once `lost` (removed with the nodes of `record`) has left the page. */
|
||||
function slotFor(record: MutationRecord, lost: Node): 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) {
|
||||
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;
|
||||
held = t && root.contains(t) && !t.closest("[data-nav-index]") ? t : 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);
|
||||
if (target) {
|
||||
target.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener("focusin", onFocusIn);
|
||||
watch.observe(root, { childList: true, subtree: true });
|
||||
return () => {
|
||||
document.removeEventListener("focusin", onFocusIn);
|
||||
watch.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
}
|
||||
Reference in New Issue
Block a user