fix(ui): toasts raised while a full-screen player is open show inside it, not under its scrim
The app's Toaster lives in the content column, which is a stacking context (its `@container` implies layout containment), so both full-screen players — portalled to <body> at the same z-index — painted over every toast. Round 1's "a failed Remove is reported" fix was therefore still silent inside the player: its toast sat under an 80% black, blurred scrim (elementFromPoint returned the scrim), and a click on it closed the player. An overlay now mounts its own `<Toaster overlay />`, and exactly one host shows the toasts: the most recently mounted overlay, else the app's. Same component, same look — and inside the aria-modal dialog, where a screen reader can still reach it. A click on a toast inside an overlay stays there. Both players get one (the Plex player's also works in native fullscreen, where nothing outside the fullscreen element is drawn). Verified on localdev: the removal failure reads above the scrim, bottom-left, and clicking it leaves the player open.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
// @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";
|
||||
|
||||
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='polite']"));
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user