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:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user