Implement the 022-fixed-layout-bars feature that pins turn navigation to the top and add-creature bar to the bottom of the encounter tracker with only the combatant list scrolling between them, and auto-scrolls to the active combatant on turn change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-09 11:36:59 +01:00
parent fa078be2f9
commit 11c4c0237e
10 changed files with 464 additions and 57 deletions

View File

@@ -1,5 +1,5 @@
import type { Creature } from "@initiative/domain";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row";
import { StatBlockPanel } from "./components/stat-block-panel";
@@ -64,6 +64,15 @@ export function App() {
[isLoaded, search],
);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
activeRowRef.current?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}, [encounter.activeIndex]);
// Auto-show stat block for the active combatant when turn changes,
// but only when the viewport is wide enough to show it alongside the tracker
useEffect(() => {
@@ -77,62 +86,68 @@ export function App() {
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
return (
<div className="h-screen overflow-y-auto">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-4 py-8">
{/* Header */}
<header className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
Initiative Tracker
</h1>
</header>
{/* Turn Navigation */}
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
/>
{/* Combatant List */}
<div className="flex flex-1 flex-col gap-1">
{encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started
</p>
) : (
encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
combatant={c}
isActive={i === encounter.activeIndex}
onRename={editCombatant}
onSetInitiative={setInitiative}
onRemove={removeCombatant}
onSetHp={setHp}
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
/>
))
)}
<div className="flex h-screen flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 min-h-0">
{/* Turn Navigation — fixed at top */}
<div className="shrink-0 pt-8">
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
/>
</div>
{/* Action Bar */}
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
suggestions={suggestions}
onSearchChange={handleSearchChange}
onShowStatBlock={handleShowStatBlock}
/>
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<header className="space-y-1 mb-6">
<h1 className="text-2xl font-bold tracking-tight">
Initiative Tracker
</h1>
</header>
<div className="flex flex-col gap-1 pb-2">
{encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started
</p>
) : (
encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null}
combatant={c}
isActive={i === encounter.activeIndex}
onRename={editCombatant}
onSetInitiative={setInitiative}
onRemove={removeCombatant}
onSetHp={setHp}
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
onShowStatBlock={
c.creatureId
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
/>
))
)}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div className="shrink-0 pb-8">
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
suggestions={suggestions}
onSearchChange={handleSearchChange}
onShowStatBlock={handleShowStatBlock}
/>
</div>
</div>
{/* Stat Block Panel */}

View File

@@ -4,7 +4,7 @@ import {
deriveHpStatus,
} from "@initiative/domain";
import { BookOpen, Brain, Shield, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
@@ -267,6 +267,7 @@ function AcDisplay({
}
export function CombatantRow({
ref,
combatant,
isActive,
onRename,
@@ -278,7 +279,7 @@ export function CombatantRow({
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
}: CombatantRowProps) {
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp);
const dimmed = status === "unconscious";
@@ -313,6 +314,7 @@ export function CombatantRow({
return (
<div
ref={ref}
className={cn(
"group rounded-md px-3 py-2 transition-colors",
isActive