feat(ui): a control can dock under the toasts and ride every toast host
The toast stack already has a host in the page and in every overlay (dialogs, both players, the fullscreen player), the topmost one shows it, and inside an overlay it sits above the player's control band. lib/toastDock lets one control use exactly that placement; it renders outside the live region, so a ticking control is not read out every second.
This commit is contained in:
@@ -83,7 +83,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
const overlay = render(<Toaster overlay />);
|
||||
const region = overlay.container.firstElementChild!;
|
||||
const region = overlay.container.querySelector("[aria-live]")!;
|
||||
expect(region.textContent).toContain("saved"); // it took the toast over…
|
||||
expect(region.getAttribute("aria-live")).toBe("off"); // …while still off
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
||||
@@ -170,7 +170,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
|
||||
render(<Toaster overlay />);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
const host = hosts()[0]!;
|
||||
const host = hosts()[0]!.closest("[data-toast-region]")!;
|
||||
expect(host.className).toContain("bottom-24");
|
||||
expect(host.className).toContain("pointer-events-none");
|
||||
expect(screen.getByText("boom").closest(".toast-card")!.className).toContain(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
|
||||
import { createStore } from "../../lib/store";
|
||||
import { useToastDock } from "../../lib/toastDock";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
import { LEVEL_STYLE } from "./toastLevels";
|
||||
|
||||
@@ -51,6 +52,7 @@ export default function Toaster({
|
||||
}, [overlay, id, rank]);
|
||||
const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost);
|
||||
const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||
const Docked = useToastDock();
|
||||
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
|
||||
@@ -113,68 +115,87 @@ export default function Toaster({
|
||||
// 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).
|
||||
//
|
||||
// Under the stack sits whatever is docked (lib/toastDock — the debug recorder's pill), in the top
|
||||
// host only, and OUTSIDE the live region: a ticking timer inside it would be read out every second.
|
||||
<div
|
||||
// lib/listNav: focus on a toast's Undo is not the reader leaving the list the toast is about.
|
||||
// lib/listNav: focus on a toast's Undo — or on the docked control — 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)] ${
|
||||
className={`absolute left-4 z-overlay flex flex-col gap-2 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
|
||||
aria-live={isTop && armed ? "polite" : "off"}
|
||||
className="flex flex-col gap-2 w-80 max-w-full"
|
||||
>
|
||||
{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>
|
||||
{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>
|
||||
<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>
|
||||
<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>
|
||||
{isTop && Docked && (
|
||||
<div className="pointer-events-auto w-max max-w-full">
|
||||
<Docked />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user