Deep-review round 5, over round 4's own fixes. - Round 4 gave `ui/Modal` a persistent portal host so a fullscreen transition would stop tearing the dialog down. The host was attached in a `useLayoutEffect` — one commit phase AFTER React runs `autoFocus` in `commitMount`, and `focus()` on a disconnected node is a no-op. Every autoFocus in every dialog was dead: Mod+K opened the command palette with the focus on the ✕, so typing searched nothing and Enter closed it; the same for the download rename field and every non-danger confirm. The host is attached in a `useInsertionEffect` now, a whole phase earlier, and the whole re-parent — including the focus save/restore across the move, which `appendChild` needs because it is remove-then-insert — lives in one `useFullscreenPortalHost`. `ui/AnchoredPopover` had a second copy of that re-parent WITHOUT the focus restore, and a comment claiming focus survived it; both now come from the hook. - `ui/Toaster` classified a toast raised in the SAME commit as a host takeover among the ones it inherits, because it read the store live at the takeover render. A dialog that notifies and closes in one handler (UploadDialog) therefore put its toast up with no role, into a region still `aria-live="off"` — announced by neither path. "Inherited" is now what the outgoing host last had on screen, and a toast that is NOT inherited is announced even inside the unarmed window. - The onboarding wizard renders its own shell, and Escape ordering was all of the dialog contract it had: no role, no label, no initial focus, no trap, no restore. A keyboard user tabbed out of the first-login wizard into the app behind the scrim. The focus half of the contract is now one shared `ui/dialogFocus` that both `ui/Modal` and the wizard use. Every fix has a regression test, each mutation-checked (6 mutations, all red on the assertion they are named after).
181 lines
7.8 KiB
TypeScript
181 lines
7.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Which Toaster shows the toasts. The app's own sits in the content column, which is a stacking
|
|
// context — so a full-screen player portalled to <body> at the same z painted over every toast: a
|
|
// failed Remove raised while watching was drawn under the player's scrim, and clicking it closed
|
|
// the player. An overlay mounts its own host, and exactly one host shows the toasts.
|
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
|
import { useState } from "react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
|
});
|
|
|
|
import Toaster from "./Toaster";
|
|
import { clearAll, notify } from "../../lib/notifications";
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
act(() => clearAll());
|
|
});
|
|
|
|
const hosts = () => Array.from(document.querySelectorAll("[aria-live]"));
|
|
|
|
describe("Toaster — one host shows the toasts", () => {
|
|
it("moves the toasts into an open overlay, and back when it closes", () => {
|
|
const app = render(
|
|
<div data-testid="app">
|
|
<Toaster />
|
|
</div>,
|
|
);
|
|
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
|
|
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
|
|
|
|
const overlay = render(
|
|
<div data-testid="overlay">
|
|
<Toaster overlay />
|
|
</div>,
|
|
);
|
|
expect(screen.getByTestId("overlay").textContent).toContain("Couldn't remove the video.");
|
|
expect(screen.getByTestId("app").textContent).not.toContain("Couldn't remove the video.");
|
|
// Both live regions stay mounted — a polite region has to pre-exist to be announced.
|
|
expect(hosts()).toHaveLength(2);
|
|
// The taking-over host arms itself a task later (see the "inherits" test below).
|
|
expect(hosts().map((h) => h.getAttribute("aria-live"))).toEqual(["off", "off"]);
|
|
|
|
overlay.unmount();
|
|
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
|
|
app.unmount();
|
|
});
|
|
|
|
it("does not let a click on a toast reach the overlay's backdrop", () => {
|
|
const backdrop = vi.fn();
|
|
render(
|
|
<div onClick={backdrop}>
|
|
<Toaster overlay />
|
|
</div>,
|
|
);
|
|
act(() => void notify({ level: "error", message: "boom" }));
|
|
fireEvent.click(screen.getByText("boom"));
|
|
expect(backdrop).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Toaster — handing the toasts to another host", () => {
|
|
it("does not announce the toasts it inherits again, only the ones that come after", () => {
|
|
// The new host gets them as NEW nodes: a screen reader read every one out again, an error
|
|
// assertively, each time a player (or any dialog) opened or closed.
|
|
render(<Toaster />);
|
|
act(() => void notify({ level: "error", message: "old news" }));
|
|
render(<Toaster overlay />);
|
|
const old = screen.getByText("old news").closest(".toast-card")!;
|
|
expect(old.getAttribute("role")).toBeNull();
|
|
act(() => void notify({ level: "error", message: "fresh" }));
|
|
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
|
|
});
|
|
|
|
it("keeps its live region off while the inherited toasts go in, and arms it a task later", async () => {
|
|
// The other half of "silently": a region that is already polite when the inherited nodes are
|
|
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
|
|
// arming has to wait for the next one — a screen reader reads the region's state after the task.
|
|
render(<Toaster />);
|
|
act(() => void notify({ level: "info", message: "saved" }));
|
|
const overlay = render(<Toaster overlay />);
|
|
const region = overlay.container.firstElementChild!;
|
|
expect(region.textContent).toContain("saved"); // it took the toast over…
|
|
expect(region.getAttribute("aria-live")).toBe("off"); // …while still off
|
|
await act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
expect(region.getAttribute("aria-live")).toBe("polite"); // and polite for what comes next
|
|
});
|
|
|
|
it("announces a toast RAISED in the same commit as the takeover, and still not the inherited ones", () => {
|
|
// A dialog that notifies and closes in ONE handler (UploadDialog: "added to the queue" + close)
|
|
// puts its toast into the very batch that hands the app host the top spot. Classifying it from
|
|
// the store live at the takeover render counted it among the inherited, so it went in with no
|
|
// role into a region that was still `aria-live="off"` — announced by neither path (round 5).
|
|
function Stage() {
|
|
const [open, setOpen] = useState(true);
|
|
return (
|
|
<>
|
|
<Toaster />
|
|
{open && (
|
|
<>
|
|
<Toaster overlay />
|
|
<button
|
|
onClick={() => {
|
|
notify({ level: "success", message: "upload queued" });
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
upload
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
render(<Stage />);
|
|
act(() => void notify({ level: "error", message: "old news" }));
|
|
fireEvent.click(screen.getByText("upload"));
|
|
|
|
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
|
expect(card("upload queued").getAttribute("role")).toBe("status");
|
|
expect(card("old news").getAttribute("role")).toBeNull();
|
|
});
|
|
|
|
it("ranks hosts by when their OVERLAY opened, not by when the host mounted", async () => {
|
|
// PlayerModal's host moves into the fullscreen stage and back: remounted, it used to jump over a
|
|
// dialog opened above the player, and the toasts went back under that dialog's scrim.
|
|
const player = render(
|
|
<div data-testid="player">
|
|
<Toaster overlay order={1} />
|
|
</div>,
|
|
);
|
|
render(
|
|
<div data-testid="dialog">
|
|
<Toaster overlay order={2} />
|
|
</div>,
|
|
);
|
|
act(() => void notify({ level: "error", message: "boom" }));
|
|
expect(screen.getByTestId("dialog").textContent).toContain("boom");
|
|
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
|
|
player.rerender(
|
|
<div data-testid="player">
|
|
<span />
|
|
<Toaster overlay order={1} />
|
|
</div>,
|
|
);
|
|
await act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
expect(screen.getByTestId("dialog").textContent).toContain("boom");
|
|
expect(screen.getByTestId("player").textContent).not.toContain("boom");
|
|
});
|
|
|
|
it("keeps the countdown where it was, instead of starting it again from full", () => {
|
|
// The bar is a CSS animation on the node; the dismiss timer was armed once, at notify time. A
|
|
// new node restarted the bar, and the toast vanished with it still two-thirds full.
|
|
const t0 = Date.now();
|
|
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
|
|
render(<Toaster />);
|
|
act(() => void notify({ level: "info", message: "saved" }));
|
|
now.mockReturnValue(t0 + 4000);
|
|
render(<Toaster overlay />);
|
|
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
|
|
expect(bar.style.animation).toContain("-4000ms");
|
|
});
|
|
|
|
it("keeps the players' control band free inside an overlay", () => {
|
|
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
|
|
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
|
|
render(<Toaster overlay />);
|
|
act(() => void notify({ level: "error", message: "boom" }));
|
|
const host = hosts()[0]!;
|
|
expect(host.className).toContain("bottom-24");
|
|
expect(host.className).toContain("pointer-events-none");
|
|
expect(screen.getByText("boom").closest(".toast-card")!.className).toContain(
|
|
"pointer-events-auto",
|
|
);
|
|
});
|
|
});
|