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.
57 lines
1.7 KiB
TypeScript
57 lines
1.7 KiB
TypeScript
// @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);
|
|
});
|
|
});
|