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:
@@ -21,9 +21,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
|
||||
198
apps/web/src/__tests__/confirm-button.test.tsx
Normal file
198
apps/web/src/__tests__/confirm-button.test.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConfirmButton } from "../components/ui/confirm-button";
|
||||
|
||||
function XIcon() {
|
||||
return <span data-testid="x-icon">X</span>;
|
||||
}
|
||||
|
||||
function renderButton(
|
||||
props: Partial<Parameters<typeof ConfirmButton>[0]> = {},
|
||||
) {
|
||||
const onConfirm = props.onConfirm ?? vi.fn();
|
||||
render(
|
||||
<ConfirmButton
|
||||
icon={<XIcon />}
|
||||
label="Remove combatant"
|
||||
onConfirm={onConfirm}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onConfirm };
|
||||
}
|
||||
|
||||
describe("ConfirmButton", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the default icon in idle state", () => {
|
||||
renderButton();
|
||||
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||
expect(screen.getByRole("button")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Remove combatant",
|
||||
);
|
||||
});
|
||||
|
||||
it("transitions to confirm state with Check icon on first click", () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||
expect(screen.getByRole("button")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Confirm remove combatant",
|
||||
);
|
||||
expect(screen.getByRole("button").className).toContain("bg-destructive");
|
||||
});
|
||||
|
||||
it("calls onConfirm on second click in confirm state", () => {
|
||||
const { onConfirm } = renderButton();
|
||||
const button = screen.getByRole("button");
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("auto-reverts after 5 seconds", () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reverts on Escape key", () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reverts on click outside", () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
|
||||
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not enter confirm state when disabled", () => {
|
||||
renderButton({ disabled: true });
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cleans up timer on unmount", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("manages independent instances separately", () => {
|
||||
const onConfirm1 = vi.fn();
|
||||
const onConfirm2 = vi.fn();
|
||||
|
||||
render(
|
||||
<>
|
||||
<ConfirmButton
|
||||
icon={<span data-testid="icon-1">1</span>}
|
||||
label="Remove first"
|
||||
onConfirm={onConfirm1}
|
||||
/>
|
||||
<ConfirmButton
|
||||
icon={<span data-testid="icon-2">2</span>}
|
||||
label="Remove second"
|
||||
onConfirm={onConfirm2}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
fireEvent.click(buttons[0]);
|
||||
|
||||
// First is confirming, second is still idle
|
||||
expect(screen.queryByTestId("icon-1")).toBeNull();
|
||||
expect(screen.getByTestId("icon-2")).toBeTruthy();
|
||||
});
|
||||
|
||||
// T008: Keyboard-specific tests
|
||||
it("Enter key triggers confirm state", () => {
|
||||
renderButton();
|
||||
const button = screen.getByRole("button");
|
||||
|
||||
// Native button handles Enter via click event
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||
expect(button).toHaveAttribute("aria-label", "Confirm remove combatant");
|
||||
});
|
||||
|
||||
it("Enter in confirm state calls onConfirm", () => {
|
||||
const { onConfirm } = renderButton();
|
||||
const button = screen.getByRole("button");
|
||||
|
||||
fireEvent.click(button); // enter confirm state
|
||||
fireEvent.click(button); // confirm
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Escape in confirm state reverts", () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||
expect(screen.getByRole("button")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Remove combatant",
|
||||
);
|
||||
});
|
||||
|
||||
it("blur event reverts confirm state", () => {
|
||||
renderButton();
|
||||
const button = screen.getByRole("button");
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(screen.queryByTestId("x-icon")).toBeNull();
|
||||
|
||||
fireEvent.blur(button);
|
||||
expect(screen.getByTestId("x-icon")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -226,10 +226,6 @@ export function useEncounter() {
|
||||
);
|
||||
|
||||
const clearEncounter = useCallback(() => {
|
||||
if (!window.confirm("Clear the entire encounter? This cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = clearEncounterUseCase(makeStore());
|
||||
|
||||
if (isDomainError(result)) {
|
||||
|
||||
@@ -68,6 +68,22 @@
|
||||
animation: slide-in-right 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes confirm-pulse {
|
||||
0% {
|
||||
scale: 1;
|
||||
}
|
||||
50% {
|
||||
scale: 1.15;
|
||||
}
|
||||
100% {
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-confirm-pulse {
|
||||
animation: confirm-pulse 300ms ease-out;
|
||||
}
|
||||
|
||||
@utility animate-concentration-pulse {
|
||||
animation:
|
||||
concentration-shake 450ms ease-out,
|
||||
|
||||
Reference in New Issue
Block a user