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:
2026-10-05 13:17:09 +07:00
parent 215cae7393
commit 17a5c7470d
14 changed files with 557 additions and 50 deletions
@@ -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);
});
});