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,33 @@
|
|||||||
|
/**
|
||||||
|
* @file ConfirmHost.tsx
|
||||||
|
* @description Renders the one pending `confirmDialog()` from `lib/confirm.ts`
|
||||||
|
* through `ConfirmModal`. Mounted once in `App.tsx`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ConfirmModal } from "./ConfirmModal";
|
||||||
|
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../lib/confirm";
|
||||||
|
|
||||||
|
const cancel = () => settleConfirm(false);
|
||||||
|
const confirm = () => settleConfirm(true);
|
||||||
|
|
||||||
|
/** The single host for `confirmDialog()`. */
|
||||||
|
export function ConfirmHost() {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const [p, setP] = useState<PendingConfirm | null>(null);
|
||||||
|
useEffect(() => subscribeConfirm(setP), []);
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open={p !== null}
|
||||||
|
title={p?.title ?? ""}
|
||||||
|
message={p?.message}
|
||||||
|
confirmLabel={p?.confirmLabel ?? t("confirm")}
|
||||||
|
cancelLabel={p?.cancelLabel ?? t("cancel")}
|
||||||
|
destructive={p?.destructive ?? true}
|
||||||
|
onConfirm={confirm}
|
||||||
|
onCancel={cancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,12 +11,14 @@
|
|||||||
*
|
*
|
||||||
* ## Accessibility
|
* ## Accessibility
|
||||||
* Focus moves to Cancel on open (safer default), Tab cycles within the dialog,
|
* Focus moves to Cancel on open (safer default), Tab cycles within the dialog,
|
||||||
* Escape cancels, and focus restores to the previously focused element on close.
|
* Escape cancels, and focus restores to the previously focused element on close
|
||||||
|
* (the shared `hooks/useDialog.ts` contract).
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
import { useId, useRef, type ReactNode } from "react";
|
||||||
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import { AlertTriangle, X } from "lucide-react";
|
import { AlertTriangle, X } from "lucide-react";
|
||||||
|
|
||||||
/** Props for {@link ConfirmModal}. */
|
/** Props for {@link ConfirmModal}. */
|
||||||
@@ -71,54 +73,13 @@ export function ConfirmModal({
|
|||||||
const messageId = useId();
|
const messageId = useId();
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
useDialog(open, panelRef, onCancel, cancelRef);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
previouslyFocused.current =
|
|
||||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
||||||
// Prefer Cancel so Enter/activation doesn't immediately destroy data.
|
|
||||||
const focusTimer = window.setTimeout(() => cancelRef.current?.focus(), 0);
|
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
onCancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key !== "Tab" || !panelRef.current) return;
|
|
||||||
|
|
||||||
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
|
|
||||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
||||||
);
|
|
||||||
if (focusable.length === 0) return;
|
|
||||||
const first = focusable.item(0);
|
|
||||||
const last = focusable.item(focusable.length - 1);
|
|
||||||
if (!first || !last) return;
|
|
||||||
if (e.shiftKey && document.activeElement === first) {
|
|
||||||
e.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!e.shiftKey && document.activeElement === last) {
|
|
||||||
e.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(focusTimer);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
previouslyFocused.current?.focus?.();
|
|
||||||
previouslyFocused.current = null;
|
|
||||||
};
|
|
||||||
}, [open, onCancel]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
@@ -134,7 +95,7 @@ export function ConfirmModal({
|
|||||||
<div className="flex items-start gap-3 p-5">
|
<div className="flex items-start gap-3 p-5">
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
||||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" />
|
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -151,10 +112,10 @@ export function ConfirmModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-fg-muted hover:text-fg-secondary p-1 -mt-1 -mr-1"
|
className="btn-icon -mt-1 -mr-1"
|
||||||
aria-label={cancelLabel}
|
aria-label={cancelLabel}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 px-5 pb-5">
|
<div className="flex items-center justify-end gap-2 px-5 pb-5">
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* @file Toaster.tsx
|
||||||
|
* @description Renders the app-wide toast queue from `lib/toast.ts`, mounted
|
||||||
|
* once in `App.tsx`. Two live regions — `status` for success/info, `alert` for
|
||||||
|
* errors — so a screen reader announces each message at the right urgency.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertCircle, Check, Info, X } from "lucide-react";
|
||||||
|
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
|
||||||
|
|
||||||
|
const TONE: Record<ToastItem["kind"], string> = {
|
||||||
|
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
||||||
|
info: "border-border bg-surface-2 text-fg-primary",
|
||||||
|
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Item({ item }: { item: ToastItem }) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
|
||||||
|
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismissToast(item.id)}
|
||||||
|
className="opacity-70 hover:opacity-100 p-1 -m-0.5"
|
||||||
|
aria-label={t("dismiss")}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single toast renderer. */
|
||||||
|
export function Toaster() {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([]);
|
||||||
|
useEffect(() => subscribeToasts(setItems), []);
|
||||||
|
const errors = items.filter((i) => i.kind === "error");
|
||||||
|
const rest = items.filter((i) => i.kind !== "error");
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||||
|
<div role="status" aria-live="polite" className="flex flex-col gap-2">
|
||||||
|
{rest.map((i) => (
|
||||||
|
<Item key={i.id} item={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div role="alert" aria-live="assertive" className="flex flex-col gap-2">
|
||||||
|
{errors.map((i) => (
|
||||||
|
<Item key={i.id} item={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @file useDialog.test.tsx
|
||||||
|
* @description Stacked dialogs: only the most recently opened one answers
|
||||||
|
* Escape, so a confirm over the sidebar drawer closes alone.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { fireEvent, render } from "@testing-library/react";
|
||||||
|
import { useDialog } from "../useDialog";
|
||||||
|
|
||||||
|
function Dialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useDialog(open, ref, onClose);
|
||||||
|
return open ? (
|
||||||
|
<div ref={ref} role="dialog">
|
||||||
|
<button>x</button>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useDialog stacking", () => {
|
||||||
|
it("Escape closes only the top dialog", () => {
|
||||||
|
const outer = vi.fn();
|
||||||
|
const inner = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<>
|
||||||
|
<Dialog open onClose={outer} />
|
||||||
|
<Dialog open onClose={inner} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(inner).toHaveBeenCalledTimes(1);
|
||||||
|
expect(outer).not.toHaveBeenCalled();
|
||||||
|
rerender(
|
||||||
|
<>
|
||||||
|
<Dialog open onClose={outer} />
|
||||||
|
<Dialog open={false} onClose={inner} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(outer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* @file useArmed.ts
|
||||||
|
* @description Two-click confirmation that DISARMS on its own. The first click
|
||||||
|
* arms a key; a second click on the same key within `ms` runs the action; after
|
||||||
|
* `ms` (default 3 s) the control returns to its safe state, so a stray click
|
||||||
|
* minutes later can never complete an irreversible action.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns `armed` (the armed key or null), `arm(key)`, `disarm()`, and
|
||||||
|
* `isArmed(key)`.
|
||||||
|
*/
|
||||||
|
export function useArmed(ms = 3000) {
|
||||||
|
const [armed, setArmed] = useState<string | null>(null);
|
||||||
|
const timer = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const disarm = useCallback(() => {
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
setArmed(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const arm = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
setArmed(key);
|
||||||
|
timer.current = window.setTimeout(() => setArmed(null), ms);
|
||||||
|
},
|
||||||
|
[ms]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||||
|
|
||||||
|
const isArmed = useCallback((key: string) => armed === key, [armed]);
|
||||||
|
return { armed, arm, disarm, isArmed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @file useDialog.ts
|
||||||
|
* @description The one modal focus contract, shared by every dialog: focus
|
||||||
|
* moves in on open (the `initialFocus` element, else the panel's first
|
||||||
|
* focusable), Tab and Shift+Tab cycle inside the panel, Escape calls `onClose`,
|
||||||
|
* and focus returns to whatever held it before the dialog opened. Extracted
|
||||||
|
* from `ConfirmModal` so the other modals stop re-implementing (or skipping) it.
|
||||||
|
* Dialogs STACK: only the most recently opened one answers Escape and Tab, so
|
||||||
|
* a confirm opened over the sidebar drawer closes alone and traps focus alone.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
|
|
||||||
|
/** Open dialogs, oldest first; only the last one handles keys. */
|
||||||
|
const stack: object[] = [];
|
||||||
|
|
||||||
|
const FOCUSABLE =
|
||||||
|
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire focus-in, a Tab trap, Escape and focus restore to a dialog panel.
|
||||||
|
* @param open Whether the dialog is shown.
|
||||||
|
* @param panelRef The dialog panel (the element carrying `role="dialog"`).
|
||||||
|
* @param onClose Called on Escape. Pass `undefined` for a dialog Escape must not close.
|
||||||
|
* @param initialFocus Element to focus on open; defaults to the first focusable.
|
||||||
|
*/
|
||||||
|
export function useDialog(
|
||||||
|
open: boolean,
|
||||||
|
panelRef: RefObject<HTMLElement>,
|
||||||
|
onClose?: () => void,
|
||||||
|
initialFocus?: RefObject<HTMLElement>
|
||||||
|
): void {
|
||||||
|
// Read through a ref so an inline `onClose` does not re-run the effect, which
|
||||||
|
// would steal focus back to the first control on every parent render.
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
closeRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const token = {};
|
||||||
|
stack.push(token);
|
||||||
|
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!panel || panel.contains(document.activeElement)) return;
|
||||||
|
(initialFocus?.current ?? panel.querySelector<HTMLElement>(FOCUSABLE) ?? panel).focus();
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!panel || stack[stack.length - 1] !== token) return;
|
||||||
|
if (e.key === "Escape" && closeRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closeRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
const items = panel.querySelectorAll<HTMLElement>(FOCUSABLE);
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const first = items[0]!;
|
||||||
|
const last = items[items.length - 1]!;
|
||||||
|
if (
|
||||||
|
e.shiftKey &&
|
||||||
|
(document.activeElement === first || !panel.contains(document.activeElement))
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
stack.splice(stack.indexOf(token), 1);
|
||||||
|
previous?.focus?.();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable
|
||||||
|
}, [open]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @file useUnsavedGuard.ts
|
||||||
|
* @description While `dirty`, closing or reloading the tab asks the browser's
|
||||||
|
* own "leave site?" question. In-app exits (a Back, another file, a tab switch,
|
||||||
|
* Escape) ask through `confirmDiscard()` from `lib/confirm.ts`.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/** Register a `beforeunload` prompt for as long as `dirty` is true. */
|
||||||
|
export function useUnsavedGuard(dirty: boolean): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return;
|
||||||
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = "";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", onBeforeUnload);
|
||||||
|
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
|
}, [dirty]);
|
||||||
|
}
|
||||||
@@ -188,5 +188,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"examples": "Examples",
|
"examples": "Examples",
|
||||||
"help": "Help"
|
"help": "Help",
|
||||||
|
"copied": "Copied",
|
||||||
|
"copyFailed": "Could not copy to the clipboard",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"unsaved": {
|
||||||
|
"title": "Discard unsaved changes?",
|
||||||
|
"message": "Your edits have not been saved and will be lost.",
|
||||||
|
"discard": "Discard",
|
||||||
|
"keep": "Keep editing"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,5 +188,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"examples": "Ví dụ",
|
"examples": "Ví dụ",
|
||||||
"help": "Trợ giúp"
|
"help": "Trợ giúp",
|
||||||
|
"copied": "Đã sao chép",
|
||||||
|
"copyFailed": "Không sao chép được vào bộ nhớ tạm",
|
||||||
|
"dismiss": "Đóng",
|
||||||
|
"confirm": "Xác nhận",
|
||||||
|
"unsaved": {
|
||||||
|
"title": "Bỏ các thay đổi chưa lưu?",
|
||||||
|
"message": "Các chỉnh sửa của bạn chưa được lưu và sẽ bị mất.",
|
||||||
|
"discard": "Bỏ thay đổi",
|
||||||
|
"keep": "Tiếp tục sửa"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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