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:
@@ -31,6 +31,7 @@ vi.mock("./DownloadButton", () => ({ default: () => null }));
|
|||||||
import PlayerModal from "./PlayerModal";
|
import PlayerModal from "./PlayerModal";
|
||||||
import { ConfirmProvider, useConfirm } from "./ConfirmProvider";
|
import { ConfirmProvider, useConfirm } from "./ConfirmProvider";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
|
import { clearAll, notify } from "../lib/notifications";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -249,3 +250,19 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
|
|||||||
await waitFor(() => expect(document.activeElement).toBe(trash("A")));
|
await waitFor(() => expect(document.activeElement).toBe(trash("A")));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PlayerModal — toasts raised while it is open", () => {
|
||||||
|
afterEach(() => act(() => clearAll()));
|
||||||
|
|
||||||
|
it("shows them inside the player, not under its scrim", async () => {
|
||||||
|
// The app's Toaster is in a stacking context below this body-level portal: a failed Remove
|
||||||
|
// reported by a toast was drawn under the 80% black scrim, i.e. still silent.
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
const A = vid("A", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
|
||||||
|
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { fullscreenElement } from "../lib/fullscreen";
|
|||||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||||
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
||||||
import ChannelNameLink from "./ChannelNameLink";
|
import ChannelNameLink from "./ChannelNameLink";
|
||||||
|
import Toaster from "./ui/Toaster";
|
||||||
import { afterRemoval } from "../lib/playerQueue";
|
import { afterRemoval } from "../lib/playerQueue";
|
||||||
import {
|
import {
|
||||||
useNativeControlsYield,
|
useNativeControlsYield,
|
||||||
@@ -960,6 +961,9 @@ export default function PlayerModal({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
|
||||||
|
Toaster is underneath this scrim. See ui/Toaster. */}
|
||||||
|
<Toaster overlay />
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
|||||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||||
import { createFragMeter } from "../lib/plexStats";
|
import { createFragMeter } from "../lib/plexStats";
|
||||||
import { UpNextCard } from "./UpNextCard";
|
import { UpNextCard } from "./UpNextCard";
|
||||||
|
import Toaster from "./ui/Toaster";
|
||||||
import {
|
import {
|
||||||
useAutoSkip,
|
useAutoSkip,
|
||||||
useControlsVisibility,
|
useControlsVisibility,
|
||||||
@@ -1379,6 +1380,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* The app's Toaster is underneath this full-screen player (and outside it in native
|
||||||
|
fullscreen), so toasts raised while watching show here instead. See ui/Toaster. */}
|
||||||
|
<Toaster overlay />
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||||
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../../lib/notifications";
|
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" },
|
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 { 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 (
|
return (
|
||||||
// Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a
|
// 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
|
<div
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"
|
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) => {
|
{toasts.map((toast) => {
|
||||||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||||
|
|||||||
Reference in New Issue
Block a user