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