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
+85
View File
@@ -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]);
}