17a5c7470d
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).
62 lines
2.3 KiB
TypeScript
62 lines
2.3 KiB
TypeScript
/**
|
|
* @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>
|
|
);
|
|
}
|