Files
siftlode/frontend/src/lib/useFocusKeeper.dom.test.tsx
T
peter fa4f823fc8 fix(ui): Back finds the item again past the focus keeper; nested drags stay in their list
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.
2026-09-19 19:19:43 +02:00

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);
});
});