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:
@@ -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 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user