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,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]);
|
||||
}
|
||||
Reference in New Issue
Block a user