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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { createStore } from "./store";
|
||||
|
||||
// A control that lives where the toasts live, under them (E16: the debug recorder's pill).
|
||||
//
|
||||
// The toast stack already solves the hard part of "always visible, never in the way": it has a host
|
||||
// in the page and one inside every overlay — the dialogs, both players, the fullscreen player — the
|
||||
// topmost host shows it, and inside an overlay it sits above the player's control band. A control
|
||||
// that must stay reachable while the user reproduces a bug anywhere needs exactly that, so it rides
|
||||
// the same hosts instead of rebuilding them: `ui/Toaster` renders whatever is docked here below its
|
||||
// stack, in the host that is on top.
|
||||
//
|
||||
// One slot, not a list: one surface uses it, and a second would have to decide how they share the
|
||||
// corner before this grows.
|
||||
const docked = createStore<ComponentType | null>(null);
|
||||
|
||||
/** Dock `component` under the toasts until the returned function undocks it. */
|
||||
export function dockUnderToasts(component: ComponentType): () => void {
|
||||
// Always the updater form: a component IS a function, so `set(component)` would be taken for one
|
||||
// and called with the previous value instead of stored.
|
||||
docked.set(() => component);
|
||||
return () =>
|
||||
docked.set((current: ComponentType | null) => (current === component ? null : current));
|
||||
}
|
||||
|
||||
/** What is docked (the Toaster reads it). */
|
||||
export const useToastDock = (): ComponentType | null => docked.use();
|
||||
Reference in New Issue
Block a user