Independent code review of #2555 caught two contrast regressions left by the bulk perl pass: 1. text-white → text-ink mass-substitution silently broke destructive and primary buttons. text-ink resolves to #15181c (warm-paper near-black) in light mode — dark text on bg-red-600 / bg-amber-600 / bg-emerald-600 / bg-blue-600 / bg-accent / bg-accent-strong / bg-good / bg-bad fails WCAG contrast and looks broken. Per-line pass flips text-ink → text-white only when a saturated bg utility is present; tinted-state pills (bg-red-950/50 etc.) keep their intentionally-retained text-* literals. 2. Original mapping table was missing bg-zinc-600 (most-used hover-state literal for cancel buttons — caused them to JUMP from warm cream resting state to dark zinc on hover in light mode) and text-zinc-700/800/900 (separator dots and decorative dim text invisible on warm-paper light bg). Extended mapping fills these gaps with bg-surface-card / text-ink-soft. Also: drop stale tailwind.config.ts reference from components.json (file deleted by the v3→v4 migration); switch baseColor zinc → neutral and enable cssVariables since v4 uses CSS-driven tokens. Future shadcn-cli invocations would have failed or written malformed components without this. 27 sites in 27 files affected by #1, ~20 sites in 20 files by #2. 1214/1214 unit tests still pass; build still clean. Findings courtesy of multi-model review per code-review-and-quality skill — different blind spots catch different bugs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
6.2 KiB
TypeScript
169 lines
6.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
interface Child {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface Props {
|
|
name: string;
|
|
children: Child[];
|
|
checked: boolean;
|
|
onCheckedChange: (v: boolean) => void;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
/**
|
|
* Cascade-delete confirmation dialog.
|
|
*
|
|
* When a workspace has children, the operator must explicitly tick
|
|
* "I understand this will cascade" before Delete All activates. This
|
|
* prevents accidental mass-deletion when ?confirm=true is always sent.
|
|
*
|
|
* Per WCAG 2.1 SC 2.4.3: focus moves to dialog on open.
|
|
* Per WCAG 2.1 SC 3.3.2: labels associated with inputs.
|
|
*/
|
|
export function DeleteCascadeConfirmDialog({
|
|
name,
|
|
children,
|
|
checked,
|
|
onCheckedChange,
|
|
onConfirm,
|
|
onCancel,
|
|
}: Props) {
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// Focus first interactive element when dialog opens (WCAG 2.4.3)
|
|
useEffect(() => {
|
|
if (!mounted) return;
|
|
const raf = requestAnimationFrame(() => {
|
|
dialogRef.current?.querySelector<HTMLElement>("button")?.focus();
|
|
});
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [mounted]);
|
|
|
|
// Keyboard: Escape cancels, Enter confirms (only when enabled), Tab trapped
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") { onCancel(); return; }
|
|
if (e.key === "Enter" && checked) { onConfirm(); 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);
|
|
}, [onCancel, onConfirm, checked]);
|
|
|
|
if (!mounted) return null;
|
|
|
|
return createPortal(
|
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
|
{/* Backdrop */}
|
|
<div aria-hidden="true" className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
|
|
|
{/* Dialog */}
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="cascade-dialog-title"
|
|
className="relative bg-surface-sunken border border-red-800/60 rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
|
|
>
|
|
<div className="px-5 py-4 border-b border-line">
|
|
<h3 id="cascade-dialog-title" className="text-sm font-semibold text-bad">
|
|
Delete Workspace and Children
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="px-5 py-4">
|
|
{/* Warning */}
|
|
<div className="flex gap-3 mb-4">
|
|
<div className="mt-0.5 shrink-0 w-8 h-8 rounded-full bg-red-900/30 flex items-center justify-center">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-bad" aria-hidden="true">
|
|
<path d="M8 3L14 13H2L8 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/>
|
|
<path d="M8 7v3M8 11.5v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
|
</svg>
|
|
</div>
|
|
<p className="text-[13px] text-ink-mid leading-relaxed">
|
|
<span className="font-medium text-bad">"{name}"</span> has{" "}
|
|
<strong className="text-ink">{children.length}</strong> child{" "}
|
|
{children.length === 1 ? "workspace" : "workspaces"}:
|
|
</p>
|
|
</div>
|
|
|
|
{/* Child list */}
|
|
<ul className="space-y-1.5 mb-4 ml-4 list-disc list-inside text-[12px] text-ink-mid max-h-32 overflow-y-auto">
|
|
{children.map((c) => (
|
|
<li key={c.id} className="truncate" title={c.name}>{c.name}</li>
|
|
))}
|
|
</ul>
|
|
|
|
{/* Cascade warning */}
|
|
<div className="rounded border border-red-900/40 bg-red-950/20 px-3 py-2.5 mb-4">
|
|
<p className="text-[12px] text-bad/80 leading-relaxed">
|
|
Deleting will cascade — <strong className="text-red-200">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Checkbox guard */}
|
|
<label className="flex items-start gap-2.5 cursor-pointer group select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(e) => onCheckedChange(e.target.checked)}
|
|
className="mt-0.5 w-4 h-4 rounded border-line bg-surface-card text-bad focus:ring-red-500 focus:ring-offset-0 focus:ring-offset-zinc-900 cursor-pointer"
|
|
/>
|
|
<span className="text-[12px] text-ink-mid group-hover:text-ink-mid leading-relaxed">
|
|
I understand this will permanently delete all listed workspaces and their data
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
|
|
<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-card border border-line rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
disabled={!checked}
|
|
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
|
|
${checked
|
|
? "bg-red-600 hover:bg-red-500 text-white cursor-pointer"
|
|
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
Delete All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
} |