Center action bar in empty state for better onboarding UX

Replace the abstract + icon with the actual input field centered at the
optical center when no combatants exist. Animate the transition in both
directions: settling down when the first combatant is added, rising up
when all combatants are removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-13 14:29:51 +01:00
parent 96b37d4bdd
commit 72d4f30e60
3 changed files with 136 additions and 66 deletions

View File

@@ -3,7 +3,6 @@ import {
rollInitiativeUseCase,
} from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { Plus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row";
@@ -22,6 +21,29 @@ function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
}
function useActionBarAnimation(combatantCount: number) {
const wasEmptyRef = useRef(combatantCount === 0);
const [settling, setSettling] = useState(false);
const [rising, setRising] = useState(false);
useEffect(() => {
const nowEmpty = combatantCount === 0;
if (wasEmptyRef.current && !nowEmpty) {
setSettling(true);
} else if (!wasEmptyRef.current && nowEmpty) {
setRising(true);
}
wasEmptyRef.current = nowEmpty;
}, [combatantCount]);
return {
settling,
rising,
onSettleEnd: () => setSettling(false),
onRiseEnd: () => setRising(false),
};
}
export function App() {
const {
encounter,
@@ -171,6 +193,7 @@ export function App() {
}, []);
const actionBarInputRef = useRef<HTMLInputElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
@@ -194,6 +217,12 @@ export function App() {
setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
const isEmpty = encounter.combatants.length === 0;
const risingClass = actionBarAnim.rising ? " animate-rise-to-center" : "";
const settlingClass = actionBarAnim.settling
? " animate-settle-to-bottom"
: "";
return (
<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">
@@ -215,21 +244,35 @@ export function App() {
</div>
)}
{isEmpty ? (
/* Empty state — ActionBar centered */
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%]">
<div
className={`w-full${risingClass}`}
onAnimationEnd={actionBarAnim.onRiseEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
playerCharacters={playerCharacters}
onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)}
autoFocus
/>
</div>
</div>
) : (
<>
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<div
className={`flex flex-col px-2 py-2${encounter.combatants.length === 0 ? " h-full items-center justify-center" : ""}`}
>
{encounter.combatants.length === 0 ? (
<button
type="button"
onClick={() => actionBarInputRef.current?.focus()}
className="animate-breathe cursor-pointer text-muted-foreground transition-colors hover:text-primary"
>
<Plus className="size-14" />
</button>
) : (
encounter.combatants.map((c, i) => (
<div className="flex flex-col px-2 py-2">
{encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
ref={i === encounter.activeIndex ? activeRowRef : null}
@@ -252,13 +295,15 @@ export function App() {
c.creatureId ? handleRollInitiative : undefined
}
/>
))
)}
))}
</div>
</div>
{/* Action Bar — fixed at bottom */}
<div className="shrink-0 pb-8">
<div
className={`shrink-0 pb-8${settlingClass}`}
onAnimationEnd={actionBarAnim.onSettleEnd}
>
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
@@ -273,6 +318,8 @@ export function App() {
onManagePlayers={() => setManagementOpen(true)}
/>
</div>
</>
)}
</div>
{/* Pinned Stat Block Panel (left) */}

View File

@@ -32,6 +32,7 @@ interface ActionBarProps {
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
autoFocus?: boolean;
}
function creatureKey(r: SearchResult): string {
@@ -50,6 +51,7 @@ export function ActionBar({
playerCharacters,
onAddFromPlayerCharacter,
onManagePlayers,
autoFocus,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
@@ -260,6 +262,7 @@ export function ActionBar({
onKeyDown={handleKeyDown}
placeholder="+ Add combatants"
className="max-w-xs"
autoFocus={autoFocus}
/>
{hasSuggestions && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">

View File

@@ -80,20 +80,40 @@
}
}
@keyframes breathe {
0%,
100% {
opacity: 0.4;
scale: 0.9;
@keyframes settle-to-bottom {
from {
transform: translateY(-40vh);
opacity: 0;
}
50% {
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
scale: 1.1;
}
}
@utility animate-breathe {
animation: breathe 3s ease-in-out infinite;
@utility animate-settle-to-bottom {
animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes rise-to-center {
from {
transform: translateY(40vh);
opacity: 0;
}
40% {
opacity: 1;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@utility animate-rise-to-center {
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1);
}
@custom-variant pointer-coarse (@media (pointer: coarse));