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
+32 -3
View File
@@ -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];