Examples
Confirm-delete flow
A menu that opens a confirmation, then a toast with Undo, with the dialog kept next to the menu so it survives it closing.
View as Markdown for coding assistants, or see llms.txt.
Deleting is the flow that most needs care. This one asks, says what will be lost, does it and offers to take it back.
Files
- Quarterly report.pdf
- Team photo.png
- Brand guidelines.pdf
- Budget 2027.xlsx
"use client";
import { Button, IconButton } from "@azmr/ui/button";
import { EmptyState } from "@azmr/ui/display";
import { Icon } from "@azmr/ui/icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@azmr/ui/menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@azmr/ui/modal";
import { toast } from "@azmr/ui/toast";
import { FileText, FolderOpen, MoreHorizontal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
const INITIAL = [
"Quarterly report.pdf",
"Team photo.png",
"Brand guidelines.pdf",
"Budget 2027.xlsx",
];
// Deleting is the flow that most needs care: a menu opens a confirmation, the confirmation says
// what will be lost, and the result is a toast that can undo it. Each step is its own component
// doing one job, and they share one piece of state, the list.
export default function ConfirmDeleteFlow() {
const [files, setFiles] = useState(INITIAL);
// The file waiting for an answer. Setting it opens the dialog; clearing it closes it.
const [pending, setPending] = useState<string | null>(null);
// The last delete. A new object each time, so deleting the same file twice still counts.
const [removed, setRemoved] = useState<{ name: string } | null>(null);
const list = useRef<HTMLUListElement>(null);
// The row that opened the menu is gone after a delete, so say where focus goes next: the list.
useEffect(() => {
if (removed) list.current?.focus();
}, [removed]);
function remove(name: string) {
const index = files.indexOf(name);
setFiles((all) => all.filter((file) => file !== name));
setRemoved({ name });
setPending(null);
toast(`Deleted “${name}”`, {
// A stable id means a second delete of the same file replaces the toast instead of stacking.
id: `deleted-${name}`,
action: {
label: "Undo",
onClick: () =>
setFiles((all) => {
if (all.includes(name)) return all;
const next = [...all];
next.splice(Math.min(index, next.length), 0, name);
return next;
}),
},
});
}
return (
<div className="flex max-w-xl flex-col gap-3">
<h3 id="files-heading" className="m-0 text-base font-semibold">
Files
</h3>
{files.length === 0 ? (
<EmptyState
icon={<Icon icon={FolderOpen} size="xl" />}
title="No files"
description="Everything has been deleted. Undo from the notification, or reset the list."
action={
<Button size="sm" variant="outline" onClick={() => setFiles(INITIAL)}>
Reset the list
</Button>
}
/>
) : (
<ul
ref={list}
tabIndex={-1}
aria-labelledby="files-heading"
className="m-0 flex list-none flex-col divide-y divide-border rounded-lg border border-border p-0 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{files.map((name) => (
<li key={name} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<Icon icon={FileText} className="text-muted-foreground" />
{name}
</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<IconButton
icon={<Icon icon={MoreHorizontal} />}
label={`Actions for ${name}`}
variant="ghost"
size="sm"
/>
}
/>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => toast(`Downloading “${name}”`)}>
Download
</DropdownMenuItem>
{/* The dialog is a sibling of the menu, so it outlives the menu closing. */}
<DropdownMenuItem variant="destructive" onClick={() => setPending(name)}>
Delete…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
)}
<AlertDialog open={pending !== null} onOpenChange={(open) => !open && setPending(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete “{pending}”?</AlertDialogTitle>
<AlertDialogDescription>
It is removed for everyone with access. You can undo this for a few seconds
afterwards.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
{/* Focus starts on Cancel, so pressing Enter by reflex cannot delete anything. */}
<AlertDialogCancel>Keep it</AlertDialogCancel>
<AlertDialogAction onClick={() => pending && remove(pending)}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}What it shows
- A menu item that opens a dialog. The dialog is controlled and sits next to the menu, not inside it. Opening it from the item's
onClickmeans it is still there when the menu closes. This is the same pattern as the recipe. - Safe focus. An alert dialog starts focus on its cancel button ("Keep it"), so pressing Enter by reflex cannot delete anything. Escape cancels.
- Say what is being deleted. The title names the file, because "Delete this item?" is meaningless to a screen reader user who has lost track of which row they were on.
- An undo that does not stack. The toast has an Undo action and a stable
id, so deleting the same file twice replaces the toast. Undo puts the file back where it was. - Focus after the row is gone. The row that opened the menu no longer exists, so the example moves focus to the list. Without that, focus would fall back to the page.
Try it
Open the menu on a file with the keyboard (Tab to its button, then Enter), choose Delete…, confirm, and note where focus is. Then use Undo in the toast.
Limits
Undo works while the toast is visible. A real app should also delay the server call until then, or support restoring a deleted item, because a toast is easy to miss.