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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useEffect, useId, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../../lib/notifications";
|
||||
@@ -11,9 +11,36 @@ export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string;
|
||||
fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" },
|
||||
};
|
||||
|
||||
export default function Toaster() {
|
||||
// Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking
|
||||
// context (its `@container` implies layout containment) — so a full-screen player, portalled to
|
||||
// <body> at the same z-overlay, painted over every toast: a failure raised while watching was
|
||||
// drawn under an 80% black, blurred scrim, and a click on it closed the player. Such an overlay
|
||||
// mounts its OWN `<Toaster overlay />` inside itself instead, and exactly one host shows the
|
||||
// toasts: the most recently mounted overlay, else the app's. Same component, same look — and inside
|
||||
// the overlay's aria-modal dialog, where a screen reader can still reach it.
|
||||
let overlayHosts: string[] = [];
|
||||
const hostListeners = new Set<() => void>();
|
||||
const subscribeHosts = (l: () => void) => {
|
||||
hostListeners.add(l);
|
||||
return () => void hostListeners.delete(l);
|
||||
};
|
||||
const topHost = () => overlayHosts[overlayHosts.length - 1] ?? null;
|
||||
const setHosts = (next: string[]) => {
|
||||
overlayHosts = next;
|
||||
hostListeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
export default function Toaster({ overlay = false }: { overlay?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||
const id = useId();
|
||||
useEffect(() => {
|
||||
if (!overlay) return;
|
||||
setHosts([...overlayHosts, id]);
|
||||
return () => setHosts(overlayHosts.filter((h) => h !== id));
|
||||
}, [overlay, id]);
|
||||
const top = useSyncExternalStore(subscribeHosts, topHost, topHost);
|
||||
const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||
const toasts = top === (overlay ? id : null) ? active : [];
|
||||
|
||||
return (
|
||||
// Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a
|
||||
@@ -25,6 +52,8 @@ export default function Toaster() {
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"
|
||||
// Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
|
||||
onClick={overlay ? (e) => e.stopPropagation() : undefined}
|
||||
>
|
||||
{toasts.map((toast) => {
|
||||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||
|
||||
Reference in New Issue
Block a user