Pattern gallery

Confirm-and-report action

The dialog closes the instant you confirm; a promise toast is what actually reports the outcome.

What this solves

Interaction feedback: a confirm dialog cannot stay open until a network call resolves without freezing the screen behind it, so it closes the moment the user confirms. Between that close and the request settling there is a real gap where nothing on screen says the action is running. The promise toast covers it, walking pending to success or error on its own.
Use it when
  • A destructive or state-changing action on a row: remove, disable, resetwalmart-mvp Settings.tsx removeMember, speedway team.tsx and accounts.tsx removeConfirmed
  • Any confirm gate reused for more than one action on the same kind of rowspeedway accounts.tsx: the same ConfirmModal<MemberRow> shape wired to reset-password and toggle-access, not just remove

Rendered

Fixture data; check both themes.
  • JAJamie Liujamie@versable.ai
  • ALAlex Chenalex@versable.ai
patterns/confirm-and-report/example.tsxCopy this into a fresh route and it renders as above.
"use client";
import { Button, Card, ConfirmModal, IdentityRow, pushAlert, useModal } from "@versable-git/ui";
// Fixture members. shouldFail stands in for whatever a real endpoint would
// reject on (a last-owner guard, a network error). It lets this one page
// reach both toast outcomes on demand.
interface Member {
id: string;
name: string;
email: string;
shouldFail?: boolean;
}
const MEMBERS: Member[] = [
{ id: "m1", name: "Jamie Liu", email: "jamie@versable.ai" },
{ id: "m2", name: "Alex Chen", email: "alex@versable.ai", shouldFail: true },
];
const REMOVE_MEMBER_MODAL = "confirm-and-report-remove-member";
// Fixture request standing in for the real API call. Real callers await a
// route action or an API client here; this page only needs something async.
function removeMember(member: Member): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (member.shouldFail) reject(new Error(`${member.name} is the last owner. Remove another owner first.`));
else resolve();
}, 900);
});
}
export function ConfirmAndReport() {
const { openModal } = useModal<Member>();
return (
<div className="flex flex-col gap-3">
<Card noBorder noAnimate bodyClassName="px-0">
<ul className="flex flex-col gap-1">
{MEMBERS.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 px-1 py-1.5">
<IdentityRow avatar={m.name.slice(0, 2).toUpperCase()} title={m.name} subtitle={m.email} size="sm" />
<Button
size="sm"
variant="outline"
color="error"
Icon="Trash"
content="Remove"
onClick={() => openModal(REMOVE_MEMBER_MODAL, null, m)}
/>
</li>
))}
</ul>
</Card>
{/* After speedway/app/routes/admin/team.tsx:507-521: the row's own
identity carries into the modal's message, typed through Args. */}
<ConfirmModal<Member>
elementId={REMOVE_MEMBER_MODAL}
title="Remove member"
preset="delete"
message={(m) => (
<span>
Remove <b>{m?.name}</b>? They lose access to this workspace immediately.
</span>
)}
onConfirm={(m, close) => {
// After walmart-mvp/frontend/src/pages/Settings.tsx:359-360: "The
// confirm modal is gone before the request settles; the promise
// toast is the only affordance covering the gap." Close first,
// then fire the request, never the other way round.
close();
if (!m) return;
void pushAlert.promise(removeMember(m), {
pending: `Removing ${m.name}`,
success: `${m.name} removed from the workspace.`,
error: (err) => (err instanceof Error ? err.message : "Something went wrong"),
});
}}
/>
</div>
);
}

Where it ships

  • walmart-mvp/frontend/src/pages/Settings.tsx:352-368removeConfirmed: pushAlert.promise(authApi.removeMember(...), ...) with the in-code comment naming the gap the toast covers
  • speedway/app/routes/admin/team.tsx:507-529ConfirmModal<MemberRow> for a password reset: onConfirm submits the route action, then closes
  • speedway/app/routes/account/accounts.tsx:888-910the same ConfirmModal<MemberRow> shape on a second admin surface, near-identical wiring

App-specific: Real callers key the confirm on a typed row (MemberRow) and hand the request to a route action via submit(...) or an API client, with per-action toast copy (reset, remove, disable). This page swaps the request for a fixture promise so the close-then-toast order is the only thing on display.

@versable-git/ui · composites proven in the apps