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
+33
View File
@@ -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}
/>
);
}
+9 -48
View File
@@ -11,12 +11,14 @@
*
* ## Accessibility
* 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>
*/
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";
/** Props for {@link ConfirmModal}. */
@@ -71,54 +73,13 @@ export function ConfirmModal({
const messageId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
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]);
useDialog(open, panelRef, onCancel, cancelRef);
if (!open) return null;
return (
<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}
role="presentation"
>
@@ -134,7 +95,7 @@ export function ConfirmModal({
<div className="flex items-start gap-3 p-5">
{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">
<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 className="min-w-0 flex-1">
@@ -151,10 +112,10 @@ export function ConfirmModal({
<button
type="button"
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}
>
<X className="w-4 h-4" />
<X className="w-4 h-4" aria-hidden />
</button>
</div>
<div className="flex items-center justify-end gap-2 px-5 pb-5">
+61
View File
@@ -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>
);
}