molecule-core/canvas/src/components/ConfirmDialog.tsx
Hongming Wang b5dea3c5df fix(canvas): ConfirmDialog hover + focus polish
Three issues on a high-stakes surface (revoke token, delete workspace,
cascade delete):

1. **Cancel hover was a no-op.** `bg-surface-card hover:bg-surface-card`
   gave zero visual feedback on hover. Now hovers to surface-elevated
   with a softened border so the button visibly lifts.

2. **Confirm hovers went LIGHTER, dropping white-text contrast.**
   `bg-red-600 hover:bg-red-500` made the destructive button less
   readable on hover. Same for warning (amber) and primary (accent).
   Reversed to hover-darker so contrast holds in both themes.

3. **No focus-visible rings on either button.** Keyboard users had no
   indication of focus position (WCAG 2.4.7 fail). Added
   `focus-visible:ring-2 focus-visible:ring-accent/40` on Cancel and
   `focus-visible:ring-2 focus-visible:ring-offset-2 ...accent/60` on
   Confirm so the focused destructive action is unambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:28:24 -07:00

147 lines
5.2 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface Props {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: "danger" | "primary" | "warning";
onConfirm: () => void;
onCancel: () => void;
// Hide the Cancel button for single-action info toasts.
// onCancel is still invoked on Esc / backdrop-click, so when using this
// dialog as a simple info toast the caller should pass the SAME handler
// for both `onConfirm` and `onCancel` — otherwise dismissing via Esc /
// backdrop click will run different logic than clicking the OK button,
// which is almost never what you want for an info dialog.
singleButton?: boolean;
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = "Confirm",
confirmVariant = "primary",
onConfirm,
onCancel,
singleButton = false,
}: Props) {
const dialogRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
// Refs avoid re-binding the keydown handler on every parent render
const onConfirmRef = useRef(onConfirm);
const onCancelRef = useRef(onCancel);
onConfirmRef.current = onConfirm;
onCancelRef.current = onCancel;
useEffect(() => {
setMounted(true);
}, []);
// Move focus into the dialog when it opens (WCAG 2.1 SC 2.4.3 / 3.2.2)
useEffect(() => {
if (!open || !mounted) return;
const raf = requestAnimationFrame(() => {
dialogRef.current?.querySelector<HTMLElement>("button")?.focus();
});
return () => cancelAnimationFrame(raf);
}, [open, mounted]);
// Keyboard: Escape cancels, Enter confirms, Tab is trapped within the dialog
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onCancelRef.current();
return;
}
if (e.key === "Enter") {
onConfirmRef.current();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => !el.hasAttribute("disabled"));
if (focusable.length === 0) { e.preventDefault(); return; }
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open]);
if (!open || !mounted) return null;
// Hover goes DARKER, not lighter — lighter shades on white text drop
// contrast below AA on the accent and red ramps. Darker hovers stay
// readable in both light and dark themes.
const confirmColors =
confirmVariant === "danger"
? "bg-red-600 hover:bg-red-700 text-white"
: confirmVariant === "warning"
? "bg-amber-600 hover:bg-amber-700 text-white"
: "bg-accent hover:bg-accent-strong text-white";
// Render via Portal so the fixed-position dialog escapes any containing block
// (e.g. parents with transform, filter, will-change that break position:fixed).
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
{/* Dialog — role="dialog" + aria-modal prevent interaction with background */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
>
<div className="px-5 py-4">
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-ink mb-2">{title}</h3>
<p className="text-[13px] text-ink-mid leading-relaxed">{message}</p>
</div>
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
{!singleButton && (
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
Cancel
</button>
)}
<button
type="button"
onClick={onConfirm}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60 ${confirmColors}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body
);
}