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:
2026-09-11 03:18:55 +02:00
parent 645d14f45d
commit 6de2cd950d
5 changed files with 118 additions and 3 deletions
@@ -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();
});
});