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:
2026-09-19 22:08:10 +02:00
parent a7d7456279
commit 2d9595b0e5
3 changed files with 101 additions and 53 deletions
@@ -83,7 +83,7 @@ describe("Toaster — handing the toasts to another host", () => {
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" })); act(() => void notify({ level: "info", message: "saved" }));
const overlay = render(<Toaster overlay />); 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.textContent).toContain("saved"); // it took the toast over…
expect(region.getAttribute("aria-live")).toBe("off"); // …while still off expect(region.getAttribute("aria-live")).toBe("off"); // …while still off
await act(async () => void (await new Promise((r) => setTimeout(r, 0)))); 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. // docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
render(<Toaster overlay />); render(<Toaster overlay />);
act(() => void notify({ level: "error", message: "boom" })); 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("bottom-24");
expect(host.className).toContain("pointer-events-none"); expect(host.className).toContain("pointer-events-none");
expect(screen.getByText("boom").closest(".toast-card")!.className).toContain( expect(screen.getByText("boom").closest(".toast-card")!.className).toContain(
+72 -51
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications"; import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
import { createStore } from "../../lib/store"; import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
import { LEVEL_STYLE } from "./toastLevels"; import { LEVEL_STYLE } from "./toastLevels";
@@ -51,6 +52,7 @@ export default function Toaster({
}, [overlay, id, rank]); }, [overlay, id, rank]);
const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost); const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost);
const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts); const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
const Docked = useToastDock();
const isTop = top === (overlay ? id : null); const isTop = top === (overlay ? id : null);
const toasts = isTop ? active : []; const toasts = isTop ? active : [];
// A host that takes over (an overlay opening, or the app's own once it closes) receives the toasts // 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. // 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 // 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). // 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 <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="" data-toast-region=""
aria-live={isTop && armed ? "polite" : "off"} className={`absolute left-4 z-overlay flex flex-col gap-2 max-w-[calc(100%-2rem)] ${
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" 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. // Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
onClick={overlay ? (e) => e.stopPropagation() : undefined} onClick={overlay ? (e) => e.stopPropagation() : undefined}
> >
{toasts.map((toast) => { <div
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level]; aria-live={isTop && armed ? "polite" : "off"}
const urgent = toast.level === "error" || toast.level === "fatal"; className="flex flex-col gap-2 w-80 max-w-full"
return ( >
<div {toasts.map((toast) => {
key={toast.id} const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
// A toast this host did not inherit must be announced even in the unarmed window, where const urgent = toast.level === "error" || toast.level === "fatal";
// the region is still `off`: urgent ones assertively, the rest with their own polite return (
// `role="status"` (single-element, so its implied atomic re-read is just this toast). <div
// Once armed, the region itself does the announcing and the role comes back off. key={toast.id}
role={ // A toast this host did not inherit must be announced even in the unarmed window, where
inherited.has(toast.id) ? undefined : urgent ? "alert" : armed ? undefined : "status" // 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).
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]" // Once armed, the region itself does the announcing and the role comes back off.
> role={
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} /> inherited.has(toast.id)
<div className="min-w-0 flex-1"> ? undefined
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>} : urgent
<div className="text-sm wrap-break-word"> ? "alert"
{toast.message} : armed
{toast.repeat > 1 && ( ? undefined
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums"> : "status"
×{toast.repeat} }
</span> 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> </div>
{toast.action && ( <button
<button onClick={() => dismiss(toast.id)}
onClick={() => { className="shrink-0 text-muted hover:text-fg"
toast.action!.onClick(); title={t("notifications.dismiss")}
dismiss(toast.id); >
}} <X className="w-4 h-4" />
className="mt-1 text-accent text-sm font-semibold hover:underline" </button>
> {toast.duration && (
{toast.action.label} // Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
</button> <CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
)} )}
</div> </div>
<button );
onClick={() => dismiss(toast.id)} })}
className="shrink-0 text-muted hover:text-fg" </div>
title={t("notifications.dismiss")} {isTop && Docked && (
> <div className="pointer-events-auto w-max max-w-full">
<X className="w-4 h-4" /> <Docked />
</button> </div>
{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> </div>
); );
} }
+27
View File
@@ -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();