Files
initiative/apps/web/src/components/ui/confirm-button.tsx
Lukas 36768d3aa1 Upgrade Biome to 2.4.7 and enable 54 additional lint rules
Add rules covering bug prevention (noLeakedRender, noFloatingPromises,
noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert),
performance (noAwaitInLoops, useTopLevelRegex), and code style
(noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses,
plus ~40 more). Fix all violations: extract top-level regex constants,
guard React && renders with boolean coercion, refactor nested ternaries,
replace window with globalThis, sort Tailwind classes, and introduce
expectDomainError test helper to eliminate conditional expects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:25:09 +01:00

119 lines
2.7 KiB
TypeScript

import { Check } from "lucide-react";
import {
type ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
interface ConfirmButtonProps {
readonly onConfirm: () => void;
readonly icon: ReactElement;
readonly label: string;
readonly size?: "icon" | "icon-sm";
readonly className?: string;
readonly disabled?: boolean;
}
const REVERT_TIMEOUT_MS = 5_000;
export function ConfirmButton({
onConfirm,
icon,
label,
size = "icon",
className,
disabled,
}: ConfirmButtonProps) {
const [isConfirming, setIsConfirming] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const wrapperRef = useRef<HTMLDivElement>(null);
const revert = useCallback(() => {
setIsConfirming(false);
clearTimeout(timerRef.current);
}, []);
// Cleanup timer on unmount
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
// Click-outside listener when confirming
useEffect(() => {
if (!isConfirming) return;
function handleMouseDown(e: MouseEvent) {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
revert();
}
}
function handleEscapeKey(e: KeyboardEvent) {
if (e.key === "Escape") {
revert();
}
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleEscapeKey);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleEscapeKey);
};
}, [isConfirming, revert]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (disabled) return;
if (isConfirming) {
revert();
onConfirm();
} else {
setIsConfirming(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(revert, REVERT_TIMEOUT_MS);
}
},
[isConfirming, disabled, onConfirm, revert],
);
return (
<div ref={wrapperRef} className="inline-flex">
<Button
variant="ghost"
size={size}
className={cn(
className,
isConfirming
? "animate-confirm-pulse rounded-md bg-destructive text-primary-foreground hover:bg-destructive hover:text-primary-foreground"
: "hover:text-hover-destructive",
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
onBlur={revert}
disabled={disabled}
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
>
{isConfirming ? <Check size={16} /> : null}
{!isConfirming && icon}
</Button>
</div>
);
}