feat(ui): shared toast, confirm, dialog, copy and IME-safe Enter primitives
One implementation each, for every page to use instead of its own copy:
- lib/toast.ts + components/Toaster.tsx: toast.success|error|info from
anywhere, rendered in polite/assertive live regions; an error stays until
dismissed, a success fades, a repeat collapses.
- lib/confirm.ts + components/ConfirmHost.tsx: await confirmDialog({...})
and confirmDiscard() through the themed ConfirmModal, replacing
window.confirm and the actions that asked nothing.
- hooks/useDialog.ts: the modal focus contract (focus in, Tab trapped,
Escape, focus restored), extracted from ConfirmModal; dialogs stack so
only the top one answers keys. ConfirmModal now uses it.
- hooks/useArmed.ts: two-click confirmation that disarms after 3 s.
- hooks/useUnsavedGuard.ts: beforeunload while a draft is dirty.
- lib/clipboard.ts copyText: Clipboard API with an execCommand fallback for
non-secure origins, outcome toasted.
- lib/keys.ts isSubmitEnter: ignores the Enter that confirms an IME
candidate (Vietnamese Telex, CJK).
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file toast.ts
|
||||
* @description App-wide toast queue: `toast.success(msg)` / `toast.error(msg)`
|
||||
* from anywhere, rendered once by `components/Toaster.tsx` inside live regions.
|
||||
* A success fades after a few seconds; an error stays until dismissed, because
|
||||
* a message that vanishes before it is read is no message at all (WCAG 2.2.1).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export type ToastKind = "success" | "error" | "info";
|
||||
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const SUCCESS_MS = 4000;
|
||||
let items: ToastItem[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<(items: ToastItem[]) => void>();
|
||||
|
||||
function emit() {
|
||||
for (const l of listeners) l(items);
|
||||
}
|
||||
|
||||
/** Remove one toast. */
|
||||
export function dismissToast(id: number): void {
|
||||
items = items.filter((t) => t.id !== id);
|
||||
emit();
|
||||
}
|
||||
|
||||
function push(kind: ToastKind, message: string): number {
|
||||
const id = nextId++;
|
||||
// Same text twice in a row (a double click, a retry) shows once.
|
||||
items = [...items.filter((t) => t.message !== message), { id, kind, message }].slice(-4);
|
||||
emit();
|
||||
if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Subscribe to the queue; returns the unsubscribe. */
|
||||
export function subscribeToasts(fn: (items: ToastItem[]) => void): () => void {
|
||||
listeners.add(fn);
|
||||
fn(items);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string) => push("success", message),
|
||||
info: (message: string) => push("info", message),
|
||||
error: (message: unknown) =>
|
||||
push("error", message instanceof Error ? message.message : String(message)),
|
||||
};
|
||||
Reference in New Issue
Block a user