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

@@ -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>;
}