af014d9829
A workspace could not be created on Windows: "cwd must be an absolute path" for D:\…, because createLane/canonicalCwd tested for a leading "/". - lanes.js: path.isAbsolute/path.resolve; canonicalCwd stops at any root (a missing drive looped forever); resolveLaneByCwd and /ensure accept "\" too - native-dialog.js: the Windows picker gets an invisible TopMost owner form, shown first — an ownerless dialog from the background server opened behind the browser, or hidden - windows-hide.js: every child process on Windows starts with windowsHide, required first by the server and the detached scripts (a console window flashed per git/npm call) - Confirm/update/clear-data modals and the quota panel scroll instead of overflowing a short window
147 lines
4.9 KiB
TypeScript
147 lines
4.9 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
|
|
* (the shared `hooks/useDialog.ts` contract).
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useId, useRef, type ReactNode } from "react";
|
|
import { useDialog } from "../hooks/useDialog";
|
|
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);
|
|
useDialog(open, panelRef, onCancel, cancelRef);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4"
|
|
onClick={onCancel}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
className="relative flex max-h-full w-full max-w-md flex-col 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 min-h-0 items-start gap-3 overflow-y-auto 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" aria-hidden />
|
|
</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="btn-icon -mt-1 -mr-1"
|
|
aria-label={cancelLabel}
|
|
>
|
|
<X className="w-4 h-4" aria-hidden />
|
|
</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>
|
|
);
|
|
}
|