Hide top bar in empty state and animate it in with first combatant
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s

The turn navigation bar is now hidden when no combatants exist, keeping
the empty state clean. It slides down from above when the first
combatant is added, synchronized with the action bar settling animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-13 14:34:02 +01:00
parent 72d4f30e60
commit 75778884bd
2 changed files with 83 additions and 29 deletions

View File

@@ -3,7 +3,13 @@ import {
rollInitiativeUseCase, rollInitiativeUseCase,
} from "@initiative/application"; } from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react"; import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionBar } from "./components/action-bar"; import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row"; import { CombatantRow } from "./components/combatant-row";
import { CreatePlayerModal } from "./components/create-player-modal"; import { CreatePlayerModal } from "./components/create-player-modal";
@@ -25,22 +31,37 @@ function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0); const wasEmptyRef = useRef(combatantCount === 0);
const [settling, setSettling] = useState(false); const [settling, setSettling] = useState(false);
const [rising, setRising] = useState(false); const [rising, setRising] = useState(false);
const [topBarExiting, setTopBarExiting] = useState(false);
useEffect(() => { useLayoutEffect(() => {
const nowEmpty = combatantCount === 0; const nowEmpty = combatantCount === 0;
if (wasEmptyRef.current && !nowEmpty) { if (wasEmptyRef.current && !nowEmpty) {
setSettling(true); setSettling(true);
} else if (!wasEmptyRef.current && nowEmpty) { } else if (!wasEmptyRef.current && nowEmpty) {
setRising(true); setRising(true);
setTopBarExiting(true);
} }
wasEmptyRef.current = nowEmpty; wasEmptyRef.current = nowEmpty;
}, [combatantCount]); }, [combatantCount]);
const empty = combatantCount === 0;
const risingClass = rising ? " animate-rise-to-center" : "";
const settlingClass = settling ? " animate-settle-to-bottom" : "";
const topBarClass = settling
? " animate-slide-down-in"
: topBarExiting
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
: "";
const showTopBar = !empty || topBarExiting;
return { return {
settling, risingClass,
rising, settlingClass,
topBarClass,
showTopBar,
onSettleEnd: () => setSettling(false), onSettleEnd: () => setSettling(false),
onRiseEnd: () => setRising(false), onRiseEnd: () => setRising(false),
onTopBarExitEnd: () => setTopBarExiting(false),
}; };
} }
@@ -218,16 +239,15 @@ export function App() {
}, [encounter.activeIndex, encounter.combatants, isLoaded]); }, [encounter.activeIndex, encounter.combatants, isLoaded]);
const isEmpty = encounter.combatants.length === 0; const isEmpty = encounter.combatants.length === 0;
const risingClass = actionBarAnim.rising ? " animate-rise-to-center" : "";
const settlingClass = actionBarAnim.settling
? " animate-settle-to-bottom"
: "";
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0"> <div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{/* Turn Navigation — fixed at top */} {actionBarAnim.showTopBar && (
<div className="shrink-0 pt-8"> <div
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
>
<TurnNavigation <TurnNavigation
encounter={encounter} encounter={encounter}
onAdvanceTurn={advanceTurn} onAdvanceTurn={advanceTurn}
@@ -237,18 +257,13 @@ export function App() {
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/> />
</div> </div>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)} )}
{isEmpty ? ( {isEmpty ? (
/* Empty state — ActionBar centered */ /* Empty state — ActionBar centered */
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%]"> <div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
<div <div
className={`w-full${risingClass}`} className={`w-full${actionBarAnim.risingClass}`}
onAnimationEnd={actionBarAnim.onRiseEnd} onAnimationEnd={actionBarAnim.onRiseEnd}
> >
<ActionBar <ActionBar
@@ -269,6 +284,12 @@ export function App() {
</div> </div>
) : ( ) : (
<> <>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Scrollable area — combatant list */} {/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col px-2 py-2"> <div className="flex flex-col px-2 py-2">
@@ -301,7 +322,7 @@ export function App() {
{/* Action Bar — fixed at bottom */} {/* Action Bar — fixed at bottom */}
<div <div
className={`shrink-0 pb-8${settlingClass}`} className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
onAnimationEnd={actionBarAnim.onSettleEnd} onAnimationEnd={actionBarAnim.onSettleEnd}
> >
<ActionBar <ActionBar

View File

@@ -95,7 +95,7 @@
} }
@utility animate-settle-to-bottom { @utility animate-settle-to-bottom {
animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1); animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
} }
@keyframes rise-to-center { @keyframes rise-to-center {
@@ -113,7 +113,40 @@
} }
@utility animate-rise-to-center { @utility animate-rise-to-center {
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1); animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-down-in {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-slide-down-in {
animation: slide-down-in 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes slide-up-out {
from {
transform: translateY(0);
opacity: 1;
}
60% {
opacity: 0;
}
to {
transform: translateY(-100%);
opacity: 0;
}
}
@utility animate-slide-up-out {
animation: slide-up-out 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
} }
@custom-variant pointer-coarse (@media (pointer: coarse)); @custom-variant pointer-coarse (@media (pointer: coarse));