fix(ui): every full-screen overlay hosts its own toasts, above the players' controls, without re-announcing them

Deep-review round 3 of bug hunt 2026-09 (the round-2 overlay toast host, finished).

- Inside an overlay the toast stack sits above the bottom band (bottom-24, the UpNextCard rung) and
  lets clicks between cards through: at the app's own spot it covered the Plex transport row and
  PlayerModal's docked bar and took their clicks while a toast was up (measured: play/pause, ±10,
  stop, prev/next, mute all hit the toast).
- ui/Modal and the onboarding wizard mount an overlay host too, so a toast raised under any dialog
  is readable and a click on it no longer closes the dialog; the Modal's Tab trap spans the whole
  dialog, so a toast's action and dismiss are keyboard-reachable. A test now enforces the
  convention: a component that covers the viewport at z-overlay must mount <Toaster overlay />.
- PlayerModal's host moves into the stage in true fullscreen (outside the element holding the lock it
  was painted under it).
- A host that takes over shows the toasts it inherits silently (no second role=alert, its region
  aria-live=off until they are in), and each countdown bar starts as far in as its toast already is
  instead of restarting while the real dismiss timer does not.
- The overlay-host registry uses lib/store instead of a hand-rolled listener triad.
This commit is contained in:
2026-09-11 18:04:54 +02:00
parent 5c5cabbe5a
commit 33fe895539
9 changed files with 228 additions and 41 deletions
+63 -35
View File
@@ -1,7 +1,8 @@
import { useEffect, useId, useSyncExternalStore } from "react";
import { useEffect, useId, useMemo, useState, 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";
import { createStore } from "../../lib/store";
export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string; bar: string }> = {
info: { icon: Info, color: "text-info", bar: "bg-info" },
@@ -12,22 +13,17 @@ export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string;
};
// 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());
// context (its `@container` implies layout containment) — so anything 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. So every full-screen overlay mounts its
// OWN `<Toaster overlay />` inside itself — ui/Modal does it for every dialog, the players for
// themselves — 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.
const overlayHosts = createStore<readonly string[]>([]);
const topHost = () => {
const hosts = overlayHosts.get();
return hosts[hosts.length - 1] ?? null;
};
export default function Toaster({ overlay = false }: { overlay?: boolean }) {
@@ -35,23 +31,43 @@ export default function Toaster({ overlay = false }: { overlay?: boolean }) {
const id = useId();
useEffect(() => {
if (!overlay) return;
setHosts([...overlayHosts, id]);
return () => setHosts(overlayHosts.filter((h) => h !== id));
overlayHosts.set((hosts) => [...hosts, id]);
return () => overlayHosts.set((hosts) => hosts.filter((h) => h !== id));
}, [overlay, id]);
const top = useSyncExternalStore(subscribeHosts, topHost, topHost);
const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost);
const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
const toasts = top === (overlay ? id : null) ? active : [];
const isTop = top === (overlay ? id : null);
const toasts = isTop ? active : [];
// A host that takes over (an overlay opening, or the app's own once it closes) receives the toasts
// that were already up as NEW nodes — and a screen reader announced every one of them again, an
// error assertively. So the ones it inherits are shown silently: no `role="alert"`, and the region
// stays `aria-live="off"` until they are in, turning polite (for the toasts that come after) once
// they have been rendered.
const inherited = useMemo(
() => new Set(isTop ? getActiveToasts().map((n) => n.id) : []),
[isTop],
);
const [armed, setArmed] = useState(false);
useEffect(() => setArmed(isTop), [isTop]);
return (
// Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a
// screen reader to announce content later inserted into it (a region created together with its
// first message is unreliably announced). `aria-live` (not role="status") on purpose:
// role="status" implies aria-atomic="true", which would re-read the WHOLE stack on every toast;
// aria-live defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts
// additionally carry role="alert", which announces assertively on insertion regardless.
// Stable, always-mounted live region — a polite region must PRE-EXIST in the DOM for a screen
// reader to announce content later inserted into it (a region created together with its first
// message is unreliably announced). `aria-live` (not role="status") on purpose: role="status"
// implies aria-atomic="true", which would re-read the WHOLE stack on every toast; aria-live
// defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts additionally
// carry role="alert", which announces assertively on insertion regardless.
//
// Inside an overlay it sits ABOVE the bottom band: that is where the players keep their control
// bars (the Plex transport row, PlayerModal's docked bar), and the toast stack — on top of them at
// z-overlay, and catching the click — took play/pause, ±10, stop, prev/next and mute for as long
// as a toast was up (the connection notice: until the server answered). The same rung UpNextCard
// uses for the same reason. Clicks between the cards go through to what is underneath.
<div
aria-live="polite"
className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"
aria-live={isTop && armed ? "polite" : "off"}
className={`absolute left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)] ${
overlay ? "bottom-24 pointer-events-none" : "bottom-4"
}`}
// Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
onClick={overlay ? (e) => e.stopPropagation() : undefined}
>
@@ -61,8 +77,8 @@ export default function Toaster({ overlay = false }: { overlay?: boolean }) {
return (
<div
key={toast.id}
role={urgent ? "alert" : undefined}
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
role={urgent && !inherited.has(toast.id) ? "alert" : undefined}
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
@@ -95,10 +111,8 @@ export default function Toaster({ overlay = false }: { overlay?: boolean }) {
<X className="w-4 h-4" />
</button>
{toast.duration && (
<div
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
style={{ animation: `toastbar ${toast.duration}ms linear forwards` }}
/>
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
<CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
)}
</div>
);
@@ -106,3 +120,17 @@ export default function Toaster({ overlay = false }: { overlay?: boolean }) {
</div>
);
}
/** The auto-dismiss countdown, started as far in as the toast already is. A toast handed to another
* host is a new node, and its bar used to start again from full while the dismiss timer — armed
* once, at notify time — did not: the toast vanished with the bar still two-thirds full. The offset
* is taken once, at mount; recomputed on a later render it would jump the running animation ahead. */
function CountdownBar({ since, duration, bar }: { since: number; duration: number; bar: string }) {
const [elapsed] = useState(() => Math.min(duration, Math.max(0, Date.now() - since)));
return (
<div
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
style={{ animation: `toastbar ${duration}ms linear -${elapsed}ms forwards` }}
/>
);
}