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:
@@ -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