Deep review of the debt burn, each measured in Chrome: - Back from a sub-view whose focused control the swap removed: the focus keeper put the focus on a stand-in (the returning list's first item) and `restoreNavPosition`, finding the focus "already somewhere", gave up — Plex show → Back landed on tile 0, not the tile it was opened from. A stand-in the keeper placed now counts as untouched, like <body>. The keeper's walk up also stops at its container: a slot left with nothing focusable no longer sends the focus to the first control of the whole document. - DataTable enabled both layouts' keyboard lists with the same flag, and `useListNav` reads its ref only when the flag changes: after a resize across `md`, the layout that mounted was never registered (no j/k) and the one that left stayed registered. Each is enabled with its layout. - A Plex playlist nests blocks and each show's episodes in one DndContext; closestCenter and the keyboard coordinates weighed every droppable, so a movie landed "over" an episode — announced as a place in the other list, dropped somewhere else or nowhere. `sameListCollision` and `sameListKeyboardCoordinates` keep a drag among its siblings; onDragEnd's episode → block resolution is gone with the case. - dnd-kit reports the item over itself in the pick-up's own tick; announced, "moved to position 1" replaced "picked up … use the arrow keys" before a screen reader read it.
80 lines
2.5 KiB
TypeScript
80 lines
2.5 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"));
|
|
});
|
|
|
|
// A page swapped for its loading fallback leaves nothing focusable in the slot. The walk up used to
|
|
// run past the container and hand the focus to the first control of the whole document.
|
|
it("never puts the focus outside its own container", async () => {
|
|
function Swapped() {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useFocusKeeper(ref);
|
|
const [loaded, setLoaded] = useState(true);
|
|
return (
|
|
<>
|
|
<button>rail</button>
|
|
<div ref={ref}>
|
|
{loaded ? <button onClick={() => setLoaded(false)}>go</button> : <p>Loading…</p>}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
render(<Swapped />);
|
|
btn("go").focus();
|
|
act(() => btn("go").click());
|
|
await flush();
|
|
expect(document.activeElement).toBe(document.body);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|