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