// @vitest-environment jsdom import { afterEach, describe, expect, it } from "vitest"; import { act, cleanup, render } from "@testing-library/react"; import { useRef, useState } from "react"; import { focusNeighbour, useFocusKeeper } from "./useFocusKeeper"; afterEach(cleanup); const flush = () => act(() => new Promise((r) => setTimeout(r, 0))); function Rows({ nav = false }: { nav?: boolean }) { const ref = useRef(null); useFocusKeeper(ref); const [rows, setRows] = useState(["a", "b", "c"]); return (

Invites

{rows.map((r, i) => (
))}
); } 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(); 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(); 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(null); useFocusKeeper(ref); const [loaded, setLoaded] = useState(true); return ( <>
{loaded ? :

Loading…

}
); } render(); 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(); btn("approve b").focus(); act(() => btn("approve b").click()); await flush(); expect(document.activeElement).toBe(document.body); }); }); describe("focusNeighbour", () => { // The neighbour of the hidden-filter cue's chip is the table's first header button — the // hidden column's own, `display: none`, which refuses focus(): the focus fell to . jsdom paints // nothing, so `checkVisibility` is stood in for, answering for the one hidden button. it("passes over a focusable the CSS has taken off screen", () => { render(
, ); Object.defineProperty(HTMLElement.prototype, "checkVisibility", { configurable: true, value(this: HTMLElement) { return !this.hasAttribute("data-off"); }, }); btn("chip").focus(); expect(focusNeighbour(btn("chip"))).toBe(true); expect(document.activeElement).toBe(btn("shown header")); delete (HTMLElement.prototype as Partial).checkVisibility; }); });