Files
siftlode/frontend/src/components/ui/Toaster.dom.test.tsx
T
peter 2d9595b0e5 feat(ui): a control can dock under the toasts and ride every toast host
The toast stack already has a host in the page and in every overlay (dialogs,
both players, the fullscreen player), the topmost one shows it, and inside an
overlay it sits above the player's control band. lib/toastDock lets one control
use exactly that placement; it renders outside the live region, so a ticking
control is not read out every second.
2026-09-19 22:08:10 +02:00

181 lines
7.9 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.querySelector("[aria-live]")!;
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]!.closest("[data-toast-region]")!;
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",
);
});
});