The Feed's chips + Sort + View row did not fit one line at 375px and pushed the page 21px wide; the toast stack capped its width by the viewport while it sits right of the sidebar. The toolbar rows (Feed, Plex browse) wrap, and the toasts are capped by their host.
195 lines
11 KiB
TypeScript
195 lines
11 KiB
TypeScript
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { X } from "lucide-react";
|
||
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
|
||
import { createStore } from "../../lib/store";
|
||
import { useOverlayOrder } from "./overlayOrder";
|
||
import { LEVEL_STYLE } from "./toastLevels";
|
||
|
||
// 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 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 order={…} />` inside itself — ui/Modal does it for every dialog, the players
|
||
// for themselves — and exactly one host shows the toasts: the one of the overlay OPENED last, else
|
||
// the app's. Same component, same look — and inside the overlay's aria-modal dialog, where a screen
|
||
// reader can still reach it.
|
||
//
|
||
// "Opened last", not "host mounted last": a host can remount while its overlay stays open —
|
||
// PlayerModal's moves into the fullscreen stage and back — and ranked by mount time it came back on
|
||
// top of a dialog opened over the player (the download dialog after leaving true fullscreen), so the
|
||
// toasts were drawn under that dialog's scrim again (deep-review round 4). So the OVERLAY takes its
|
||
// rank once, when it opens (`useOverlayOrder`, in overlayOrder.ts), and hands it to every host it renders.
|
||
type Host = { id: string; order: number };
|
||
const overlayHosts = createStore<readonly Host[]>([]);
|
||
const topHost = () => {
|
||
let top: Host | null = null;
|
||
for (const h of overlayHosts.get()) if (!top || h.order >= top.order) top = h;
|
||
return top?.id ?? null;
|
||
};
|
||
|
||
// What the host that is currently top last rendered, for the next host to inherit. See `inherited`.
|
||
let lastTopIds: number[] = [];
|
||
const EMPTY_IDS: ReadonlySet<number> = new Set<number>();
|
||
|
||
export default function Toaster({
|
||
overlay = false,
|
||
order,
|
||
}: {
|
||
overlay?: boolean;
|
||
/** The owning overlay's `useOverlayOrder()`. Without one, the host ranks by its own mount. */
|
||
order?: number;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const id = useId();
|
||
const ownOrder = useOverlayOrder();
|
||
const rank = order ?? ownOrder;
|
||
useEffect(() => {
|
||
if (!overlay) return;
|
||
overlayHosts.set((hosts) => [...hosts, { id, order: rank }]);
|
||
return () => overlayHosts.set((hosts) => hosts.filter((h) => h.id !== id));
|
||
}, [overlay, id, rank]);
|
||
const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost);
|
||
const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||
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) in a
|
||
// LATER task. Not in an effect: React flushes those in the same task as the render that inserted the
|
||
// toasts, and a screen reader reads the region's state after the task — by which time it was polite
|
||
// again, i.e. the arming did nothing.
|
||
//
|
||
// "Inherited" is what the PREVIOUS top host last had on screen — not whatever is in the store at
|
||
// the takeover render. Reading the store live also silenced a toast RAISED in the same commit as
|
||
// the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success
|
||
// toast up in the very batch that handed the app host the top spot, so it was classified as
|
||
// inherited and announced by neither path (deep-review round 5). Written during render on purpose
|
||
// — the handover must be readable by the taking-over host in the SAME commit, and an effect
|
||
// cleanup runs one phase too late; only the host that IS top writes, so the value is the losing
|
||
// host's last screen either way.
|
||
//
|
||
// Snapshotted through a ref keyed on `isTop`, not a `useMemo`: React re-invokes a component body
|
||
// — and every `useMemo` factory in it — on a StrictMode double render and on a restarted one, and
|
||
// the LAST invocation is what commits. With a memo the second pass re-read `lastTopIds` after the
|
||
// first pass had already overwritten it on the line below, so the takeover host classified its
|
||
// OWN new toast as inherited and the dev build behaved exactly like the code this replaced
|
||
// (measured in jsdom: role "status" without StrictMode, null with it — round 6). The ref only
|
||
// recomputes when `isTop` actually flips, so a repeated invocation of one render is a no-op.
|
||
const handover = useRef<{ top: boolean; ids: ReadonlySet<number> }>({
|
||
top: false,
|
||
ids: EMPTY_IDS,
|
||
});
|
||
if (handover.current.top !== isTop)
|
||
handover.current = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
|
||
const inherited = handover.current.ids;
|
||
// AFTER the snapshot above has read the outgoing host's screen — this host's own first render as
|
||
// top would otherwise overwrite the handover with the toasts it is about to classify. Writing the
|
||
// same value twice is harmless; that is the point of the ref.
|
||
if (isTop) lastTopIds = toasts.map((n) => n.id);
|
||
const [armed, setArmed] = useState(false);
|
||
useEffect(() => {
|
||
if (!isTop) {
|
||
setArmed(false);
|
||
return;
|
||
}
|
||
const timer = window.setTimeout(() => setArmed(true), 0);
|
||
return () => window.clearTimeout(timer);
|
||
}, [isTop]);
|
||
|
||
return (
|
||
// 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.
|
||
// Its width is capped by its HOST (100%), not the viewport: it sits in the content column, right
|
||
// of the sidebar, and a 100vw cap pushed it past the right edge of a phone (prod UAT 2026-09-19).
|
||
<div
|
||
// lib/listNav: focus on a toast's Undo is not the reader leaving the list the toast is about.
|
||
data-toast-region=""
|
||
aria-live={isTop && armed ? "polite" : "off"}
|
||
className={`absolute left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100%-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}
|
||
>
|
||
{toasts.map((toast) => {
|
||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||
const urgent = toast.level === "error" || toast.level === "fatal";
|
||
return (
|
||
<div
|
||
key={toast.id}
|
||
// A toast this host did not inherit must be announced even in the unarmed window, where
|
||
// the region is still `off`: urgent ones assertively, the rest with their own polite
|
||
// `role="status"` (single-element, so its implied atomic re-read is just this toast).
|
||
// Once armed, the region itself does the announcing and the role comes back off.
|
||
role={
|
||
inherited.has(toast.id) ? undefined : urgent ? "alert" : armed ? undefined : "status"
|
||
}
|
||
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">
|
||
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
||
<div className="text-sm wrap-break-word">
|
||
{toast.message}
|
||
{toast.repeat > 1 && (
|
||
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
|
||
×{toast.repeat}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{toast.action && (
|
||
<button
|
||
onClick={() => {
|
||
toast.action!.onClick();
|
||
dismiss(toast.id);
|
||
}}
|
||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||
>
|
||
{toast.action.label}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => dismiss(toast.id)}
|
||
className="shrink-0 text-muted hover:text-fg"
|
||
title={t("notifications.dismiss")}
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
{toast.duration && (
|
||
// 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>
|
||
);
|
||
})}
|
||
</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` }}
|
||
/>
|
||
);
|
||
}
|