fix(ui): every full-screen overlay hosts its own toasts, above the players' controls, without re-announcing them
Deep-review round 3 of bug hunt 2026-09 (the round-2 overlay toast host, finished). - Inside an overlay the toast stack sits above the bottom band (bottom-24, the UpNextCard rung) and lets clicks between cards through: at the app's own spot it covered the Plex transport row and PlayerModal's docked bar and took their clicks while a toast was up (measured: play/pause, ±10, stop, prev/next, mute all hit the toast). - ui/Modal and the onboarding wizard mount an overlay host too, so a toast raised under any dialog is readable and a click on it no longer closes the dialog; the Modal's Tab trap spans the whole dialog, so a toast's action and dismiss are keyboard-reachable. A test now enforces the convention: a component that covers the viewport at z-overlay must mount <Toaster overlay />. - PlayerModal's host moves into the stage in true fullscreen (outside the element holding the lock it was painted under it). - A host that takes over shows the toasts it inherits silently (no second role=alert, its region aria-live=off until they are in), and each countdown bar starts as far in as its toast already is instead of restarting while the real dismiss timer does not. - The overlay-host registry uses lib/store instead of a hand-rolled listener triad.
This commit is contained in:
@@ -20,7 +20,7 @@ afterEach(() => {
|
||||
act(() => clearAll());
|
||||
});
|
||||
|
||||
const hosts = () => Array.from(document.querySelectorAll("[aria-live='polite']"));
|
||||
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", () => {
|
||||
@@ -41,6 +41,7 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
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);
|
||||
expect(hosts().map((h) => h.getAttribute("aria-live"))).toEqual(["off", "polite"]);
|
||||
|
||||
overlay.unmount();
|
||||
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
|
||||
@@ -59,3 +60,43 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
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 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user