diff --git a/client/src/components/ConfirmHost.tsx b/client/src/components/ConfirmHost.tsx new file mode 100644 index 00000000..c4f3a427 --- /dev/null +++ b/client/src/components/ConfirmHost.tsx @@ -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ĩ + */ + +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(null); + useEffect(() => subscribeConfirm(setP), []); + return ( + + ); +} diff --git a/client/src/components/ConfirmModal.tsx b/client/src/components/ConfirmModal.tsx index b2a82a7b..5f3e833b 100644 --- a/client/src/components/ConfirmModal.tsx +++ b/client/src/components/ConfirmModal.tsx @@ -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ĩ */ -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(null); const cancelRef = useRef(null); - const previouslyFocused = useRef(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( - '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 (
@@ -134,7 +95,7 @@ export function ConfirmModal({
{destructive && (
- +
)}
@@ -151,10 +112,10 @@ export function ConfirmModal({
diff --git a/client/src/components/Toaster.tsx b/client/src/components/Toaster.tsx new file mode 100644 index 00000000..4952beab --- /dev/null +++ b/client/src/components/Toaster.tsx @@ -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ĩ + */ + +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 = { + 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 ( +
+ + {item.message} + +
+ ); +} + +/** The single toast renderer. */ +export function Toaster() { + const [items, setItems] = useState([]); + useEffect(() => subscribeToasts(setItems), []); + const errors = items.filter((i) => i.kind === "error"); + const rest = items.filter((i) => i.kind !== "error"); + return ( +
+
+ {rest.map((i) => ( + + ))} +
+
+ {errors.map((i) => ( + + ))} +
+
+ ); +} diff --git a/client/src/hooks/__tests__/useDialog.test.tsx b/client/src/hooks/__tests__/useDialog.test.tsx new file mode 100644 index 00000000..636eba85 --- /dev/null +++ b/client/src/hooks/__tests__/useDialog.test.tsx @@ -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ĩ + */ + +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(null); + useDialog(open, ref, onClose); + return open ? ( +
+ +
+ ) : null; +} + +describe("useDialog stacking", () => { + it("Escape closes only the top dialog", () => { + const outer = vi.fn(); + const inner = vi.fn(); + const { rerender } = render( + <> + + + + ); + fireEvent.keyDown(document, { key: "Escape" }); + expect(inner).toHaveBeenCalledTimes(1); + expect(outer).not.toHaveBeenCalled(); + rerender( + <> + + + + ); + fireEvent.keyDown(document, { key: "Escape" }); + expect(outer).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/hooks/useArmed.ts b/client/src/hooks/useArmed.ts new file mode 100644 index 00000000..6d1fefc1 --- /dev/null +++ b/client/src/hooks/useArmed.ts @@ -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ĩ + */ + +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(null); + const timer = useRef(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 }; +} diff --git a/client/src/hooks/useDialog.ts b/client/src/hooks/useDialog.ts new file mode 100644 index 00000000..1914d883 --- /dev/null +++ b/client/src/hooks/useDialog.ts @@ -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ĩ + */ + +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, + onClose?: () => void, + initialFocus?: RefObject +): 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(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(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]); +} diff --git a/client/src/hooks/useUnsavedGuard.ts b/client/src/hooks/useUnsavedGuard.ts new file mode 100644 index 00000000..4759b26a --- /dev/null +++ b/client/src/hooks/useUnsavedGuard.ts @@ -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ĩ + */ + +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]); +} diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json index 91e73373..1ec7c842 100644 --- a/client/src/i18n/locales/en/common.json +++ b/client/src/i18n/locales/en/common.json @@ -188,5 +188,15 @@ } }, "examples": "Examples", - "help": "Help" + "help": "Help", + "copied": "Copied", + "copyFailed": "Could not copy to the clipboard", + "dismiss": "Dismiss", + "confirm": "Confirm", + "unsaved": { + "title": "Discard unsaved changes?", + "message": "Your edits have not been saved and will be lost.", + "discard": "Discard", + "keep": "Keep editing" + } } diff --git a/client/src/i18n/locales/vi/common.json b/client/src/i18n/locales/vi/common.json index b49d42bc..1afb5690 100644 --- a/client/src/i18n/locales/vi/common.json +++ b/client/src/i18n/locales/vi/common.json @@ -188,5 +188,15 @@ } }, "examples": "Ví dụ", - "help": "Trợ giúp" + "help": "Trợ giúp", + "copied": "Đã sao chép", + "copyFailed": "Không sao chép được vào bộ nhớ tạm", + "dismiss": "Đóng", + "confirm": "Xác nhận", + "unsaved": { + "title": "Bỏ các thay đổi chưa lưu?", + "message": "Các chỉnh sửa của bạn chưa được lưu và sẽ bị mất.", + "discard": "Bỏ thay đổi", + "keep": "Tiếp tục sửa" + } } diff --git a/client/src/lib/__tests__/uiPrimitives.test.ts b/client/src/lib/__tests__/uiPrimitives.test.ts new file mode 100644 index 00000000..28541460 --- /dev/null +++ b/client/src/lib/__tests__/uiPrimitives.test.ts @@ -0,0 +1,55 @@ +/** + * @file Tests for the shared UI primitives: the toast queue (errors stay, + * successes fade, duplicates collapse), promise confirms (a second ask cancels + * the first) and the IME-aware Enter check. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { dismissToast, subscribeToasts, toast, type ToastItem } from "../toast"; +import { confirmDialog, settleConfirm, subscribeConfirm } from "../confirm"; +import { isSubmitEnter } from "../keys"; + +afterEach(() => vi.useRealTimers()); + +describe("toast", () => { + it("keeps an error, fades a success, collapses a repeat", () => { + vi.useFakeTimers(); + let seen: ToastItem[] = []; + const off = subscribeToasts((i) => (seen = i)); + const errId = toast.error(new Error("boom")); + toast.success("saved"); + toast.success("saved"); + expect(seen.map((t) => t.message)).toEqual(["boom", "saved"]); + vi.advanceTimersByTime(5000); + expect(seen.map((t) => t.message)).toEqual(["boom"]); + dismissToast(errId); + expect(seen).toEqual([]); + off(); + }); +}); + +describe("confirmDialog", () => { + it("resolves on settle and cancels an earlier pending ask", async () => { + const off = subscribeConfirm(() => {}); + const first = confirmDialog({ title: "a" }); + const second = confirmDialog({ title: "b" }); + await expect(first).resolves.toBe(false); + settleConfirm(true); + await expect(second).resolves.toBe(true); + off(); + }); +}); + +describe("isSubmitEnter", () => { + const ev = (o: Partial) => + ({ key: "Enter", shiftKey: false, isComposing: false, keyCode: 13, ...o }) as KeyboardEvent; + it("submits a plain Enter only", () => { + expect(isSubmitEnter(ev({}))).toBe(true); + expect(isSubmitEnter(ev({ shiftKey: true }))).toBe(false); + expect(isSubmitEnter(ev({ isComposing: true }))).toBe(false); + expect(isSubmitEnter(ev({ keyCode: 229 }))).toBe(false); + expect(isSubmitEnter(ev({ key: "a" }))).toBe(false); + }); +}); diff --git a/client/src/lib/clipboard.ts b/client/src/lib/clipboard.ts new file mode 100644 index 00000000..425658fe --- /dev/null +++ b/client/src/lib/clipboard.ts @@ -0,0 +1,50 @@ +/** + * @file clipboard.ts + * @description `copyText(text)` — the one copy path for every "Copy …" action. + * Uses the async Clipboard API when it exists and falls back to a hidden + * textarea + `execCommand("copy")`, because `navigator.clipboard` is undefined + * on a non-secure origin such as `http://:4820`. Reports the outcome as + * a toast, so a copy is never silent. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import i18n from "../i18n"; +import { toast } from "./toast"; + +function fallbackCopy(text: string): boolean { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + ta.remove(); + } +} + +/** + * Copy text to the clipboard and toast the result. + * @param quiet Skip the success toast (the caller shows its own feedback). + * @returns Whether the copy succeeded. + */ +export async function copyText(text: string, quiet = false): Promise { + let ok = false; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + ok = true; + } + } catch { + /* fall through to the textarea path */ + } + if (!ok) ok = fallbackCopy(text); + if (!ok) toast.error(i18n.t("common:copyFailed")); + else if (!quiet) toast.success(i18n.t("common:copied")); + return ok; +} diff --git a/client/src/lib/confirm.ts b/client/src/lib/confirm.ts new file mode 100644 index 00000000..0528ad7e --- /dev/null +++ b/client/src/lib/confirm.ts @@ -0,0 +1,66 @@ +/** + * @file confirm.ts + * @description Promise-based confirmation: `await confirmDialog({...})` resolves + * true on confirm, false on cancel. Rendered by `components/ConfirmHost.tsx` + * (mounted once in `App.tsx`) through the themed `ConfirmModal`, so call sites + * that used `window.confirm` — or nothing — get the same dialog everywhere. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import i18n from "../i18n"; + +export interface ConfirmOptions { + title: string; + message?: string; + /** Defaults to common:confirm. */ + confirmLabel?: string; + /** Defaults to common:cancel. */ + cancelLabel?: string; + /** Red confirm button (default true). */ + destructive?: boolean; +} + +export interface PendingConfirm extends ConfirmOptions { + resolve: (ok: boolean) => void; +} + +let current: PendingConfirm | null = null; +const listeners = new Set<(p: PendingConfirm | null) => void>(); + +function set(p: PendingConfirm | null) { + current = p; + for (const l of listeners) l(p); +} + +/** Subscribe to the pending confirmation; returns the unsubscribe. */ +export function subscribeConfirm(fn: (p: PendingConfirm | null) => void): () => void { + listeners.add(fn); + fn(current); + return () => { + listeners.delete(fn); + }; +} + +/** Settle the pending confirmation (used by the host). */ +export function settleConfirm(ok: boolean): void { + const p = current; + set(null); + p?.resolve(ok); +} + +/** Ask the user; resolves true only on an explicit confirm. */ +export function confirmDialog(opts: ConfirmOptions): Promise { + // A second ask while one is open cancels the first rather than stacking. + if (current) settleConfirm(false); + return new Promise((resolve) => set({ ...opts, resolve })); +} + +/** The standard "discard unsaved changes?" question. */ +export function confirmDiscard(): Promise { + return confirmDialog({ + title: i18n.t("common:unsaved.title"), + message: i18n.t("common:unsaved.message"), + confirmLabel: i18n.t("common:unsaved.discard"), + cancelLabel: i18n.t("common:unsaved.keep"), + }); +} diff --git a/client/src/lib/keys.ts b/client/src/lib/keys.ts new file mode 100644 index 00000000..82568010 --- /dev/null +++ b/client/src/lib/keys.ts @@ -0,0 +1,15 @@ +/** + * @file keys.ts + * @description Keyboard helpers shared by every single-line submit field. + * `isSubmitEnter` ignores the Enter that confirms an IME candidate (Vietnamese + * Telex, CJK): without it a rename or commit message is committed half-typed. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; + +/** True for a plain Enter that should submit — not Shift+Enter, not mid-IME. */ +export function isSubmitEnter(e: ReactKeyboardEvent | KeyboardEvent): boolean { + const native = "nativeEvent" in e ? e.nativeEvent : e; + return e.key === "Enter" && !e.shiftKey && !native.isComposing && native.keyCode !== 229; +} diff --git a/client/src/lib/toast.ts b/client/src/lib/toast.ts new file mode 100644 index 00000000..29846277 --- /dev/null +++ b/client/src/lib/toast.ts @@ -0,0 +1,56 @@ +/** + * @file toast.ts + * @description App-wide toast queue: `toast.success(msg)` / `toast.error(msg)` + * from anywhere, rendered once by `components/Toaster.tsx` inside live regions. + * A success fades after a few seconds; an error stays until dismissed, because + * a message that vanishes before it is read is no message at all (WCAG 2.2.1). + * @author Nguyễn Ngọc Trí Vĩ + */ + +export type ToastKind = "success" | "error" | "info"; + +export interface ToastItem { + id: number; + kind: ToastKind; + message: string; +} + +const SUCCESS_MS = 4000; +let items: ToastItem[] = []; +let nextId = 1; +const listeners = new Set<(items: ToastItem[]) => void>(); + +function emit() { + for (const l of listeners) l(items); +} + +/** Remove one toast. */ +export function dismissToast(id: number): void { + items = items.filter((t) => t.id !== id); + emit(); +} + +function push(kind: ToastKind, message: string): number { + const id = nextId++; + // Same text twice in a row (a double click, a retry) shows once. + items = [...items.filter((t) => t.message !== message), { id, kind, message }].slice(-4); + emit(); + if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS); + return id; +} + +/** Subscribe to the queue; returns the unsubscribe. */ +export function subscribeToasts(fn: (items: ToastItem[]) => void): () => void { + listeners.add(fn); + fn(items); + return () => { + listeners.delete(fn); + }; +} + +export const toast = { + success: (message: string) => push("success", message), + info: (message: string) => push("info", message), + error: (message: unknown) => + push("error", message instanceof Error ? message.message : String(message)), +};