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:
Lukas
2026-03-11 09:51:21 +01:00
parent d101906776
commit 0747d044f3
17 changed files with 1364 additions and 32 deletions

View 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();
});
});