// @vitest-environment jsdom // // Back from a sub-view the focus keeper ALSO sees: the control that held the focus in the sub-view // is removed by the swap, the keeper puts the focus on a stand-in in its slot — the first item of // the returning list — and `restoreNavPosition` then found the focus "already somewhere" and gave // up. Back landed on the first tile instead of the one the view was opened from (measured in Chrome, // deep review of the debt burn). Its own file: listNav keeps module-level state. import { afterEach, describe, expect, it } from "vitest"; import { act, cleanup, render } from "@testing-library/react"; import { useRef, useState } from "react"; import { navItemAttrs, navPosition, restoreNavPosition, useListNav } from "./listNav"; import { useFocusKeeper } from "./useFocusKeeper"; afterEach(cleanup); function Grid() { const ref = useRef(null); useListNav(ref, { enabled: true }); return (
{["a", "b", "c"].map((t, i) => (
))}
); } let show: (v: "grid" | "detail") => void = () => {}; function Page() { const ref = useRef(null); useFocusKeeper(ref); const [view, setView] = useState<"grid" | "detail">("grid"); // eslint-disable-next-line react-hooks/globals -- a TEST probe: the test drives the view switch from outside the tree, because useFocusKeeper's whole subject is what happens to focus across that switch — triggering it through a button would put focus on the button first and measure nothing. show = setView; return
{view === "grid" ? : }
; } const button = (name: string) => [...document.querySelectorAll("button")].find((b) => b.textContent === name)!; const wait = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms))); describe("Back from a sub-view whose held control the swap removed", () => { it("returns the focus to the item the view was opened from, not the keeper's stand-in", async () => { render(); button("c").focus(); const pos = navPosition()!; expect(pos.index).toBe(2); act(() => show("detail")); button("Back to library").focus(); // a control the keeper holds act(() => show("grid")); // popstate's view swap… const cancel = restoreNavPosition(pos); // …and its restore await wait(120); cancel(); expect( document.activeElement?.closest("[data-nav-index]")?.getAttribute("data-nav-index"), ).toBe("2"); }); it("leaves the focus alone once the reader has used the stand-in", async () => { render(); button("c").focus(); const pos = navPosition()!; act(() => show("detail")); button("Back to library").focus(); act(() => show("grid")); await act(() => Promise.resolve()); // the keeper places its stand-in const standIn = document.activeElement; expect(standIn?.textContent).toBe("a"); // A key typed into it fires no focusin — but it is the reader's focus now. act(() => { standIn!.dispatchEvent(new KeyboardEvent("keydown", { key: "x", bubbles: true })); }); const cancel = restoreNavPosition(pos); await wait(120); cancel(); expect(document.activeElement).toBe(standIn); }); });