Implement stat block panel fold/unfold and pin-to-second-panel

Replace the close button and heading with fold/unfold controls that
collapse the panel to a slim right-edge tab showing the creature name
vertically, and add a pin button (xl+ viewports with creature loaded)
that opens the creature in a second left-side panel for simultaneous
reference. Fold state is respected on turn change. 19 acceptance tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-11 14:18:15 +01:00
parent 95cb2edc23
commit 460c65bf49
12 changed files with 1165 additions and 57 deletions

View File

@@ -53,11 +53,29 @@ export function App() {
useState<CreatureId | null>(null);
const [bulkImportMode, setBulkImportMode] = useState(false);
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null,
);
const [isWideDesktop, setIsWideDesktop] = useState(
() => window.matchMedia("(min-width: 1280px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1280px)");
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const selectedCreature: Creature | null = selectedCreatureId
? (getCreature(selectedCreatureId) ?? null)
: null;
const pinnedCreature: Creature | null = pinnedCreatureId
? (getCreature(pinnedCreatureId) ?? null)
: null;
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
addFromBestiary(result);
@@ -75,6 +93,7 @@ export function App() {
const handleCombatantStatBlock = useCallback((creatureId: string) => {
setSelectedCreatureId(creatureId as CreatureId);
setIsRightPanelFolded(false);
}, []);
const handleRollInitiative = useCallback(
@@ -110,6 +129,25 @@ export function App() {
bulkImport.reset();
}, [bulkImport.reset]);
const handleDismissBrowsePanel = useCallback(() => {
setSelectedCreatureId(null);
setBulkImportMode(false);
}, []);
const handleToggleFold = useCallback(() => {
setIsRightPanelFolded((f) => !f);
}, []);
const handlePin = useCallback(() => {
if (selectedCreatureId) {
setPinnedCreatureId(selectedCreatureId);
}
}, [selectedCreatureId]);
const handleUnpin = useCallback(() => {
setPinnedCreatureId(null);
}, []);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -202,7 +240,27 @@ export function App() {
</div>
</div>
{/* Stat Block Panel */}
{/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && (
<StatBlockPanel
creatureId={pinnedCreatureId}
creature={pinnedCreature}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
panelRole="pinned"
isFolded={false}
onToggleFold={() => {}}
onPin={() => {}}
onUnpin={handleUnpin}
showPinButton={false}
side="left"
onDismiss={() => {}}
/>
)}
{/* Browse Stat Block Panel (right) */}
<StatBlockPanel
creatureId={selectedCreatureId}
creature={selectedCreature}
@@ -210,10 +268,14 @@ export function App() {
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
onClose={() => {
setSelectedCreatureId(null);
setBulkImportMode(false);
}}
panelRole="browse"
isFolded={isRightPanelFolded}
onToggleFold={handleToggleFold}
onPin={handlePin}
onUnpin={() => {}}
showPinButton={isWideDesktop && !!selectedCreature}
side="right"
onDismiss={handleDismissBrowsePanel}
bulkImportMode={bulkImportMode}
bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport}

View File

@@ -0,0 +1,263 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Creature, CreatureId } from "@initiative/domain";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StatBlockPanel } from "../components/stat-block-panel";
const CREATURE_ID = "srd:goblin" as CreatureId;
const CREATURE: Creature = {
id: CREATURE_ID,
name: "Goblin",
source: "SRD",
sourceDisplayName: "SRD",
size: "Small",
type: "humanoid",
alignment: "neutral evil",
ac: 15,
hp: { average: 7, formula: "2d6" },
speed: "30 ft.",
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
cr: "1/4",
initiativeProficiency: 0,
proficiencyBonus: 2,
passive: 9,
};
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
interface PanelProps {
creatureId?: CreatureId | null;
creature?: Creature | null;
panelRole?: "browse" | "pinned";
isFolded?: boolean;
onToggleFold?: () => void;
onPin?: () => void;
onUnpin?: () => void;
showPinButton?: boolean;
side?: "left" | "right";
onDismiss?: () => void;
bulkImportMode?: boolean;
}
function renderPanel(overrides: PanelProps = {}) {
const props = {
creatureId: CREATURE_ID,
creature: CREATURE,
isSourceCached: vi.fn().mockResolvedValue(true),
fetchAndCacheSource: vi.fn(),
uploadAndCacheSource: vi.fn(),
refreshCache: vi.fn(),
panelRole: "browse" as const,
isFolded: false,
onToggleFold: vi.fn(),
onPin: vi.fn(),
onUnpin: vi.fn(),
showPinButton: false,
side: "right" as const,
onDismiss: vi.fn(),
...overrides,
};
render(<StatBlockPanel {...props} />);
return props;
}
describe("Stat Block Panel Fold/Unfold and Pin", () => {
beforeEach(() => {
mockMatchMedia(true); // desktop by default
});
afterEach(cleanup);
describe("US1: Fold and Unfold", () => {
it("shows fold button instead of close button on desktop", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Fold stat block panel" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /close/i }),
).not.toBeInTheDocument();
});
it("does not show 'Stat Block' heading", () => {
renderPanel();
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
});
it("renders folded tab with creature name when isFolded is true", () => {
renderPanel({ isFolded: true });
expect(screen.getByText("Goblin")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Unfold stat block panel" }),
).toBeInTheDocument();
});
it("calls onToggleFold when fold button is clicked", () => {
const props = renderPanel();
fireEvent.click(
screen.getByRole("button", { name: "Fold stat block panel" }),
);
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
});
it("calls onToggleFold when folded tab is clicked", () => {
const props = renderPanel({ isFolded: true });
fireEvent.click(
screen.getByRole("button", { name: "Unfold stat block panel" }),
);
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
});
it("applies translate-x class when folded (right side)", () => {
renderPanel({ isFolded: true, side: "right" });
const panel = screen
.getByRole("button", { name: "Unfold stat block panel" })
.closest("div");
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
});
it("applies translate-x-0 when expanded", () => {
renderPanel({ isFolded: false });
const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
describe("US1: Mobile behavior", () => {
beforeEach(() => {
mockMatchMedia(false); // mobile
});
it("shows fold button instead of X close button on mobile drawer", () => {
renderPanel();
expect(
screen.getByRole("button", { name: "Fold stat block panel" }),
).toBeInTheDocument();
// No X close icon button — only backdrop dismiss and fold toggle
const buttons = screen.getAllByRole("button");
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
expect(buttonLabels).not.toContain("Close");
});
it("calls onDismiss when backdrop is clicked on mobile", () => {
const props = renderPanel();
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
expect(props.onDismiss).toHaveBeenCalledTimes(1);
});
it("does not render pinned panel on mobile", () => {
const { container } = render(
<StatBlockPanel
creatureId={CREATURE_ID}
creature={CREATURE}
isSourceCached={vi.fn().mockResolvedValue(true)}
fetchAndCacheSource={vi.fn()}
uploadAndCacheSource={vi.fn()}
refreshCache={vi.fn()}
panelRole="pinned"
isFolded={false}
onToggleFold={vi.fn()}
onPin={vi.fn()}
onUnpin={vi.fn()}
showPinButton={false}
side="left"
onDismiss={vi.fn()}
/>,
);
expect(container.innerHTML).toBe("");
});
});
describe("US2: Pin and Unpin", () => {
it("shows pin button when showPinButton is true on desktop", () => {
renderPanel({ showPinButton: true });
expect(
screen.getByRole("button", { name: "Pin creature" }),
).toBeInTheDocument();
});
it("hides pin button when showPinButton is false", () => {
renderPanel({ showPinButton: false });
expect(
screen.queryByRole("button", { name: "Pin creature" }),
).not.toBeInTheDocument();
});
it("calls onPin when pin button is clicked", () => {
const props = renderPanel({ showPinButton: true });
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
expect(props.onPin).toHaveBeenCalledTimes(1);
});
it("shows unpin button for pinned role", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.getByRole("button", { name: "Unpin creature" }),
).toBeInTheDocument();
});
it("calls onUnpin when unpin button is clicked", () => {
const props = renderPanel({ panelRole: "pinned", side: "left" });
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
expect(props.onUnpin).toHaveBeenCalledTimes(1);
});
it("positions pinned panel on the left side", () => {
renderPanel({ panelRole: "pinned", side: "left" });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("left-0");
expect(panel?.className).toContain("border-r");
});
it("positions browse panel on the right side", () => {
renderPanel({ panelRole: "browse", side: "right" });
const foldBtn = screen.getByRole("button", {
name: "Fold stat block panel",
});
const panel = foldBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("right-0");
expect(panel?.className).toContain("border-l");
});
});
describe("US3: Fold independence with pinned panel", () => {
it("pinned panel has no fold button", () => {
renderPanel({ panelRole: "pinned", side: "left" });
expect(
screen.queryByRole("button", { name: /fold/i }),
).not.toBeInTheDocument();
});
it("pinned panel is always expanded (no translate offset)", () => {
renderPanel({ panelRole: "pinned", side: "left", isFolded: false });
const unpinBtn = screen.getByRole("button", {
name: "Unpin creature",
});
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
expect(panel?.className).toContain("translate-x-0");
});
});
});

View File

@@ -1,5 +1,6 @@
import type { Creature, CreatureId } from "@initiative/domain";
import { X } from "lucide-react";
import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
import type { BulkImportState } from "../hooks/use-bulk-import.js";
@@ -17,7 +18,14 @@ interface StatBlockPanelProps {
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
onClose: () => void;
panelRole: "browse" | "pinned";
isFolded: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
showPinButton: boolean;
side: "left" | "right";
onDismiss: () => void;
bulkImportMode?: boolean;
bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void;
@@ -30,6 +38,171 @@ function extractSourceCode(cId: CreatureId): string {
return cId.slice(0, colonIndex).toUpperCase();
}
function FoldedTab({
creatureName,
side,
onToggleFold,
}: {
creatureName: string;
side: "left" | "right";
onToggleFold: () => void;
}) {
return (
<button
type="button"
onClick={onToggleFold}
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
side === "right" ? "self-start" : "self-end"
}`}
aria-label="Unfold stat block panel"
>
<span className="writing-vertical-rl text-sm font-medium">
{creatureName}
</span>
</button>
);
}
function PanelHeader({
panelRole,
showPinButton,
onToggleFold,
onPin,
onUnpin,
}: {
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
}) {
return (
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-1">
{panelRole === "browse" && (
<button
type="button"
onClick={onToggleFold}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
)}
</div>
<div className="flex items-center gap-1">
{panelRole === "browse" && showPinButton && (
<button
type="button"
onClick={onPin}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Pin creature"
>
<Pin className="h-4 w-4" />
</button>
)}
{panelRole === "pinned" && (
<button
type="button"
onClick={onUnpin}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Unpin creature"
>
<PinOff className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}
function DesktopPanel({
isFolded,
side,
creatureName,
panelRole,
showPinButton,
onToggleFold,
onPin,
onUnpin,
children,
}: {
isFolded: boolean;
side: "left" | "right";
creatureName: string;
panelRole: "browse" | "pinned";
showPinButton: boolean;
onToggleFold: () => void;
onPin: () => void;
onUnpin: () => void;
children: ReactNode;
}) {
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
const foldedTranslate =
side === "right"
? "translate-x-[calc(100%-40px)]"
: "translate-x-[calc(-100%+40px)]";
return (
<div
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
>
{isFolded ? (
<FoldedTab
creatureName={creatureName}
side={side}
onToggleFold={onToggleFold}
/>
) : (
<>
<PanelHeader
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onPin={onPin}
onUnpin={onUnpin}
/>
<div className="flex-1 overflow-y-auto p-4">{children}</div>
</>
)}
</div>
);
}
function MobileDrawer({
onDismiss,
children,
}: {
onDismiss: () => void;
children: ReactNode;
}) {
return (
<div className="fixed inset-0 z-50">
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
onClick={onDismiss}
aria-label="Close stat block"
/>
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<button
type="button"
onClick={onDismiss}
className="text-muted-foreground hover:text-hover-neutral"
aria-label="Fold stat block panel"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{children}
</div>
</div>
</div>
);
}
export function StatBlockPanel({
creatureId,
creature,
@@ -37,7 +210,14 @@ export function StatBlockPanel({
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
onClose,
panelRole,
isFolded,
onToggleFold,
onPin,
onUnpin,
showPinButton,
side,
onDismiss,
bulkImportMode,
bulkImportState,
onStartBulkImport,
@@ -56,7 +236,6 @@ export function StatBlockPanel({
return () => mq.removeEventListener("change", handler);
}, []);
// When creatureId changes, check if we need to show the fetch prompt
useEffect(() => {
if (!creatureId || creature) {
setNeedsFetch(false);
@@ -71,8 +250,6 @@ export function StatBlockPanel({
setCheckingCache(true);
isSourceCached(sourceCode).then((cached) => {
// If source is cached but creature not found, it's an edge case
// If source is not cached, show fetch prompt
setNeedsFetch(!cached);
setCheckingCache(false);
});
@@ -132,56 +309,27 @@ export function StatBlockPanel({
);
};
const panelTitle = bulkImportMode ? "Bulk Import" : "Stat Block";
const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
if (isDesktop) {
return (
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
{panelTitle}
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">{renderContent()}</div>
</div>
<DesktopPanel
isFolded={isFolded}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleFold={onToggleFold}
onPin={onPin}
onUnpin={onUnpin}
>
{renderContent()}
</DesktopPanel>
);
}
// Mobile drawer
return (
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
onClick={onClose}
aria-label="Close stat block"
/>
{/* Drawer */}
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
{panelTitle}
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
{renderContent()}
</div>
</div>
</div>
);
if (panelRole === "pinned") return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
}

View File

@@ -84,6 +84,14 @@
animation: confirm-pulse 300ms ease-out;
}
@utility transition-slide-panel {
transition: translate 200ms ease-out;
}
@utility writing-vertical-rl {
writing-mode: vertical-rl;
}
@utility animate-concentration-pulse {
animation:
concentration-shake 450ms ease-out,