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,55 @@
|
||||
/**
|
||||
* @file Tests for the shared UI primitives: the toast queue (errors stay,
|
||||
* successes fade, duplicates collapse), promise confirms (a second ask cancels
|
||||
* the first) and the IME-aware Enter check.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { dismissToast, subscribeToasts, toast, type ToastItem } from "../toast";
|
||||
import { confirmDialog, settleConfirm, subscribeConfirm } from "../confirm";
|
||||
import { isSubmitEnter } from "../keys";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("toast", () => {
|
||||
it("keeps an error, fades a success, collapses a repeat", () => {
|
||||
vi.useFakeTimers();
|
||||
let seen: ToastItem[] = [];
|
||||
const off = subscribeToasts((i) => (seen = i));
|
||||
const errId = toast.error(new Error("boom"));
|
||||
toast.success("saved");
|
||||
toast.success("saved");
|
||||
expect(seen.map((t) => t.message)).toEqual(["boom", "saved"]);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(seen.map((t) => t.message)).toEqual(["boom"]);
|
||||
dismissToast(errId);
|
||||
expect(seen).toEqual([]);
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmDialog", () => {
|
||||
it("resolves on settle and cancels an earlier pending ask", async () => {
|
||||
const off = subscribeConfirm(() => {});
|
||||
const first = confirmDialog({ title: "a" });
|
||||
const second = confirmDialog({ title: "b" });
|
||||
await expect(first).resolves.toBe(false);
|
||||
settleConfirm(true);
|
||||
await expect(second).resolves.toBe(true);
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSubmitEnter", () => {
|
||||
const ev = (o: Partial<KeyboardEvent>) =>
|
||||
({ key: "Enter", shiftKey: false, isComposing: false, keyCode: 13, ...o }) as KeyboardEvent;
|
||||
it("submits a plain Enter only", () => {
|
||||
expect(isSubmitEnter(ev({}))).toBe(true);
|
||||
expect(isSubmitEnter(ev({ shiftKey: true }))).toBe(false);
|
||||
expect(isSubmitEnter(ev({ isComposing: true }))).toBe(false);
|
||||
expect(isSubmitEnter(ev({ keyCode: 229 }))).toBe(false);
|
||||
expect(isSubmitEnter(ev({ key: "a" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file clipboard.ts
|
||||
* @description `copyText(text)` — the one copy path for every "Copy …" action.
|
||||
* Uses the async Clipboard API when it exists and falls back to a hidden
|
||||
* textarea + `execCommand("copy")`, because `navigator.clipboard` is undefined
|
||||
* on a non-secure origin such as `http://<lan-ip>:4820`. Reports the outcome as
|
||||
* a toast, so a copy is never silent.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import i18n from "../i18n";
|
||||
import { toast } from "./toast";
|
||||
|
||||
function fallbackCopy(text: string): boolean {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
ta.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard and toast the result.
|
||||
* @param quiet Skip the success toast (the caller shows its own feedback).
|
||||
* @returns Whether the copy succeeded.
|
||||
*/
|
||||
export async function copyText(text: string, quiet = false): Promise<boolean> {
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the textarea path */
|
||||
}
|
||||
if (!ok) ok = fallbackCopy(text);
|
||||
if (!ok) toast.error(i18n.t("common:copyFailed"));
|
||||
else if (!quiet) toast.success(i18n.t("common:copied"));
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file confirm.ts
|
||||
* @description Promise-based confirmation: `await confirmDialog({...})` resolves
|
||||
* true on confirm, false on cancel. Rendered by `components/ConfirmHost.tsx`
|
||||
* (mounted once in `App.tsx`) through the themed `ConfirmModal`, so call sites
|
||||
* that used `window.confirm` — or nothing — get the same dialog everywhere.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import i18n from "../i18n";
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string;
|
||||
message?: string;
|
||||
/** Defaults to common:confirm. */
|
||||
confirmLabel?: string;
|
||||
/** Defaults to common:cancel. */
|
||||
cancelLabel?: string;
|
||||
/** Red confirm button (default true). */
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingConfirm extends ConfirmOptions {
|
||||
resolve: (ok: boolean) => void;
|
||||
}
|
||||
|
||||
let current: PendingConfirm | null = null;
|
||||
const listeners = new Set<(p: PendingConfirm | null) => void>();
|
||||
|
||||
function set(p: PendingConfirm | null) {
|
||||
current = p;
|
||||
for (const l of listeners) l(p);
|
||||
}
|
||||
|
||||
/** Subscribe to the pending confirmation; returns the unsubscribe. */
|
||||
export function subscribeConfirm(fn: (p: PendingConfirm | null) => void): () => void {
|
||||
listeners.add(fn);
|
||||
fn(current);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/** Settle the pending confirmation (used by the host). */
|
||||
export function settleConfirm(ok: boolean): void {
|
||||
const p = current;
|
||||
set(null);
|
||||
p?.resolve(ok);
|
||||
}
|
||||
|
||||
/** Ask the user; resolves true only on an explicit confirm. */
|
||||
export function confirmDialog(opts: ConfirmOptions): Promise<boolean> {
|
||||
// A second ask while one is open cancels the first rather than stacking.
|
||||
if (current) settleConfirm(false);
|
||||
return new Promise((resolve) => set({ ...opts, resolve }));
|
||||
}
|
||||
|
||||
/** The standard "discard unsaved changes?" question. */
|
||||
export function confirmDiscard(): Promise<boolean> {
|
||||
return confirmDialog({
|
||||
title: i18n.t("common:unsaved.title"),
|
||||
message: i18n.t("common:unsaved.message"),
|
||||
confirmLabel: i18n.t("common:unsaved.discard"),
|
||||
cancelLabel: i18n.t("common:unsaved.keep"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @file keys.ts
|
||||
* @description Keyboard helpers shared by every single-line submit field.
|
||||
* `isSubmitEnter` ignores the Enter that confirms an IME candidate (Vietnamese
|
||||
* Telex, CJK): without it a rename or commit message is committed half-typed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
|
||||
/** True for a plain Enter that should submit — not Shift+Enter, not mid-IME. */
|
||||
export function isSubmitEnter(e: ReactKeyboardEvent | KeyboardEvent): boolean {
|
||||
const native = "nativeEvent" in e ? e.nativeEvent : e;
|
||||
return e.key === "Enter" && !e.shiftKey && !native.isComposing && native.keyCode !== 229;
|
||||
}
|
||||
@@ -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