fix(web): toasts survive an open dialog; empty-state actions can be gated

Both from the final review, and the first one overturns a call I got wrong.

The aria-hidden sweep that makes aria-modal true also swallowed the toasts.
ToastProvider renders inside the app root, and every modal-raised confirmation
is toasted *before* its dialog closes — "Saved …", "Deleted …", "Removed …" —
so each one was inserted into a hidden subtree and never announced. Un-hiding
a live region afterwards does not replay what it missed. The toast layer is
portalled to the body carrying the dialog-layer attribute, which exempts it
from the sweep, and sits above the dialog: a toast explaining why a dialog's
action failed is no use behind it.

EmptyState's action was narrower than the call site it replaced. The old
first-workflow button carried loading={isPending}; the new one carried
nothing, so a double click created two workflows. The action is a union now —
a link takes no pending state, a handler takes loading and disabled.
This commit is contained in:
2026-08-10 10:01:47 +01:00
parent fe1dfe472a
commit e434beec7a
4 changed files with 54 additions and 11 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ export default function WorkflowsPage() {
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
action={{ label: "Create your first workflow", onClick: () => create() }}
action={{ label: "Create your first workflow", onClick: () => create(), loading: isPending }}
/>
}
>
+17 -2
View File
@@ -63,7 +63,15 @@ export function EmptyState({
title: string;
description?: string;
icon?: React.ReactNode;
action?: { label: string; href?: string; onClick?: () => void };
/*
* `loading` matters rather than being decoration: the empty-state button is
* usually the one that creates the first of something, and without it a
* double click creates two. A link action takes neither — there is no
* pending state to show for a navigation.
*/
action?:
| { label: string; href: string; onClick?: never; loading?: never; disabled?: never }
| { label: string; href?: never; onClick: () => void; loading?: boolean; disabled?: boolean };
}) {
return (
<div className="px-6 py-16 text-center">
@@ -80,7 +88,14 @@ export function EmptyState({
{action.label}
</Button>
) : (
<Button variant="primary" size="sm" className="mt-4" onClick={action.onClick}>
<Button
variant="primary"
size="sm"
className="mt-4"
onClick={action.onClick}
loading={action.loading}
disabled={action.disabled}
>
{action.label}
</Button>
))}
+10 -1
View File
@@ -22,7 +22,16 @@ let lockedOverflow = "";
let lockedPadding = "";
let hidden: HTMLElement[] = [];
const PORTAL_ATTR = "data-vantage-dialog";
/*
* Marks a body child as belonging to the dialog layer rather than the page, so
* the aria-hidden sweep below skips it. Exported because the toast layer needs
* the same exemption: a confirmation raised by a dialog is raised *before* that
* dialog closes, so a toast rendered inside the app tree would be inserted into
* a hidden subtree and never announced — and un-hiding a live region later does
* not replay what it missed.
*/
export const DIALOG_LAYER_ATTR = "data-vantage-dialog";
const PORTAL_ATTR = DIALOG_LAYER_ATTR;
function lockScroll() {
const { body } = document;
+26 -7
View File
@@ -1,8 +1,10 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { clsx } from "clsx";
import { friendlyMessage } from "./Async";
import { DIALOG_LAYER_ATTR } from "./Modal";
/*
* Mutations succeeded silently. Copying an install one-liner, generating a key,
@@ -59,6 +61,10 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
const nextId = useRef(1);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
// Portals need a DOM, which SSR has not got.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) {
@@ -95,6 +101,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
<ToastContext.Provider value={api}>
{children}
{/*
* Portalled to the body and marked as dialog layer, so an open
* modal's aria-hidden sweep leaves it alone. Every modal-raised
* confirmation ("Saved …", "Deleted …", "Removed …") is toasted
* before the dialog closes, and inside the app tree all of them
* would land in a hidden subtree and go unannounced.
*
* Two regions, not one polite container holding role="alert"
* children: live-region politeness is taken from the nearest
* ancestor that declares it, so a single polite wrapper demotes the
@@ -102,13 +114,20 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
* the operator asked for did not happen; a confirmation can wait
* for a pause in speech.
*
* The wrapper is a plain flex column so both regions stack as one
* visual list.
* z-index sits above the dialog layer: a toast reporting why a
* dialog's action failed is no use behind it.
*/}
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[60] flex flex-col items-center gap-2 p-4 sm:items-end">
<ToastRegion toasts={toasts.filter((t) => t.kind !== "error")} politeness="polite" onDismiss={dismiss} />
<ToastRegion toasts={toasts.filter((t) => t.kind === "error")} politeness="assertive" onDismiss={dismiss} />
</div>
{mounted &&
createPortal(
<div
{...{ [DIALOG_LAYER_ATTR]: "" }}
className="pointer-events-none fixed inset-x-0 bottom-0 z-[70] flex flex-col items-center gap-2 p-4 sm:items-end"
>
<ToastRegion toasts={toasts.filter((t) => t.kind !== "error")} politeness="polite" onDismiss={dismiss} />
<ToastRegion toasts={toasts.filter((t) => t.kind === "error")} politeness="assertive" onDismiss={dismiss} />
</div>,
document.body,
)}
</ToastContext.Provider>
);
}