Implement the 032-inline-confirm-buttons feature that replaces single-click destructive actions with a reusable ConfirmButton component providing inline two-step confirmation (click to arm, click to execute), applied to the remove combatant and clear encounter buttons, with CSS scale pulse animation, 5-second auto-revert, click-outside/Escape/blur dismissal, full keyboard accessibility, and 13 unit tests via @testing-library/react
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import { ConditionPicker } from "./condition-picker";
|
||||
import { ConditionTags } from "./condition-tags";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
interface Combatant {
|
||||
@@ -543,19 +543,12 @@ export function CombatantRow({
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-hover-destructive opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(id);
|
||||
}}
|
||||
title="Remove combatant"
|
||||
aria-label="Remove combatant"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
icon={<X size={16} />}
|
||||
label="Remove combatant"
|
||||
onConfirm={() => onRemove(id)}
|
||||
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Encounter } from "@initiative/domain";
|
||||
import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
|
||||
interface TurnNavigationProps {
|
||||
encounter: Encounter;
|
||||
@@ -74,15 +75,13 @@ export function TurnNavigation({
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-hover-destructive"
|
||||
onClick={onClearEncounter}
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
107
apps/web/src/components/ui/confirm-button.tsx
Normal file
107
apps/web/src/components/ui/confirm-button.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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 className?: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
const REVERT_TIMEOUT_MS = 5_000;
|
||||
|
||||
export function ConfirmButton({
|
||||
onConfirm,
|
||||
icon,
|
||||
label,
|
||||
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 handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isConfirming, revert]);
|
||||
|
||||
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="icon"
|
||||
className={cn(
|
||||
className,
|
||||
isConfirming &&
|
||||
"bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onBlur={revert}
|
||||
disabled={disabled}
|
||||
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||
>
|
||||
{isConfirming ? <Check size={16} /> : icon}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user