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