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