Files
siftlode/frontend/src/lib/useFocusKeeper.dom.test.tsx
T
peter 444f9b8166 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.
2026-09-19 08:10:03 +02:00

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