Files
Claude-Code-Monitor/client/src/components/ConfirmModal.tsx
T
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.

- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
  tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
  fg.primary/secondary/muted, status.success/danger/warning. One class flip
  on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
  prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
  scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
  every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
  client/src onto the new tokens, so every badge/button/component pulls the
  same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
  constants — slate/blue/green/red/amber steps 1-12 — adopted after three
  rounds of hand-picked values that kept overshooting (flat, then too dark,
  then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
  dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
  visual language (border + text + translucent wash of the same status
  color); `current` alone stays a solid accent fill, the one state that
  gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
  Workspace lane-detail header already showing them.

Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
2026-07-31 10:54:31 +07:00

186 lines
6.2 KiB
TypeScript

/**
* @file ConfirmModal.tsx
* @description Centered confirmation dialog for destructive or irreversible
* actions (delete webhook, remove alert rule, etc.). Replaces `window.confirm`
* with themed UI that matches the dashboard and distinguishes a loading (`busy`)
* confirm button from one refused outright (`disabled`).
*
* ## Dismissal
* Clicking the backdrop, pressing Escape, or clicking the X cancels. The confirm
* button can be styled non-destructive for neutral confirmations.
*
* ## 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.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useRef, type ReactNode } from "react";
import { AlertTriangle, X } from "lucide-react";
/** Props for {@link ConfirmModal}. */
export interface ConfirmModalProps {
/** When false, nothing is rendered. */
open: boolean;
/** Dialog heading. */
title: string;
/** Optional supporting message below the title. */
message?: string;
/** Primary action label (e.g. "Delete"). */
confirmLabel: string;
/** Secondary cancel label. */
cancelLabel: string;
/** When true (default), confirm button uses red destructive styling. */
destructive?: boolean;
/** Disables confirm while an async delete is in flight. */
busy?: boolean;
/**
* Disables confirm because the action is not permitted right now (a blocker, an
* unmet checkbox, facts that failed to load). Distinct from `busy`: nothing is
* in flight, so callers must not conflate the two — passing a refusal as `busy`
* makes a blocked action read as perpetually loading.
*/
disabled?: boolean;
/** Optional action-specific facts shown before the confirmation controls. */
children?: ReactNode;
/** Called when the user confirms. */
onConfirm: () => void;
/** Called on cancel, backdrop click, Escape, or X. */
onCancel: () => void;
}
/**
* Modal confirmation overlay.
* @param props See {@link ConfirmModalProps}.
*/
export function ConfirmModal({
open,
title,
message,
confirmLabel,
cancelLabel,
destructive = true,
busy = false,
disabled = false,
children,
onConfirm,
onCancel,
}: ConfirmModalProps) {
const titleId = useId();
const messageId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(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<HTMLElement>(
'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]);
if (!open) return null;
return (
<div
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onClick={onCancel}
role="presentation"
>
<div
ref={panelRef}
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={message ? messageId : undefined}
>
<div className="flex items-start gap-3 p-5">
{destructive && (
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" />
</div>
)}
<div className="min-w-0 flex-1">
<h3 id={titleId} className="text-sm font-semibold text-fg-primary">
{title}
</h3>
{message && (
<p id={messageId} className="text-xs text-fg-secondary mt-1 leading-relaxed">
{message}
</p>
)}
{children}
</div>
<button
type="button"
onClick={onCancel}
className="text-fg-muted hover:text-fg-secondary p-1 -mt-1 -mr-1"
aria-label={cancelLabel}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex items-center justify-end gap-2 px-5 pb-5">
<button
ref={cancelRef}
type="button"
onClick={onCancel}
className="btn-ghost border border-border text-xs"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={busy || disabled}
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${
destructive
? "text-status-danger bg-status-danger/15 border border-status-danger/30 hover:bg-status-danger/25"
: "btn-primary"
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}