Toast action results and confirm destructive actions in one dialog

Inline banners and per-row status text reported action results all over
the app; they are replaced by a small toast stack (bottom-right, Level 3
floating well styling) that only speaks for actions: successes fade,
errors stay until dismissed, and form-field validation stays inline.

Destructive actions no longer use bespoke inline confirm steps (the
library detail's Confirm delete button) or fire immediately (duplicate
copies/items, delete page selections, temp cleanup, upload discard, job
cancellation): they all go through one promise-based confirm dialog
(confirmAction) with a danger variant, Escape/backdrop to cancel.
This commit is contained in:
2026-09-17 22:19:54 -05:00
parent 1053e3ee55
commit e97c3b9da0
21 changed files with 430 additions and 287 deletions
+40
View File
@@ -0,0 +1,40 @@
import { create } from "zustand";
export interface ConfirmOptions {
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
}
interface ConfirmRequest extends ConfirmOptions {
resolve: (confirmed: boolean) => void;
}
interface ConfirmState {
request: ConfirmRequest | null;
open: (options: ConfirmOptions) => Promise<boolean>;
settle: (confirmed: boolean) => void;
}
export const useConfirm = create<ConfirmState>((set, get) => ({
request: null,
open: (options) =>
new Promise<boolean>((resolve) => {
set({ request: { ...options, resolve } });
}),
settle: (confirmed) => {
get().request?.resolve(confirmed);
set({ request: null });
},
}));
/**
* Ask for confirmation before a destructive action and await the answer:
*
* if (!(await confirmAction({ title: "Delete J-42?", danger: true }))) return;
*/
export function confirmAction(options: ConfirmOptions): Promise<boolean> {
return useConfirm.getState().open(options);
}
+60
View File
@@ -0,0 +1,60 @@
import { create } from "zustand";
export type ToastKind = "ok" | "error" | "info";
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface Toast {
id: number;
kind: ToastKind;
text: string;
action?: ToastAction;
}
interface ToastState {
toasts: Toast[];
push: (kind: ToastKind, text: string, action?: ToastAction) => number;
dismiss: (id: number) => void;
}
/** Errors stay until dismissed; successes and info fade on their own. */
const DURATIONS: Record<ToastKind, number | null> = {
ok: 4_000,
info: 5_000,
error: null,
};
let nextId = 1;
export const useToasts = create<ToastState>((set, get) => ({
toasts: [],
push: (kind, text, action) => {
const existing = get().toasts.find(
(toast) => toast.kind === kind && toast.text === text,
);
if (existing) return existing.id;
const id = nextId++;
set((state) => ({ toasts: [...state.toasts, { id, kind, text, action }] }));
const duration = DURATIONS[kind];
if (duration !== null) {
window.setTimeout(() => get().dismiss(id), duration);
}
return id;
},
dismiss: (id) =>
set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })),
}));
/** Fire-and-forget helper usable outside React components. */
export const toast = {
ok: (text: string, action?: ToastAction) =>
useToasts.getState().push("ok", text, action),
error: (text: string, action?: ToastAction) =>
useToasts.getState().push("error", text, action),
info: (text: string, action?: ToastAction) =>
useToasts.getState().push("info", text, action),
};