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:
@@ -3,7 +3,6 @@ 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 { Plus } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, 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";
|
||||||
@@ -22,6 +21,29 @@ function rollDice(): number {
|
|||||||
return Math.floor(Math.random() * 20) + 1;
|
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() {
|
export function App() {
|
||||||
const {
|
const {
|
||||||
encounter,
|
encounter,
|
||||||
@@ -171,6 +193,7 @@ export function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||||
|
|
||||||
// Auto-scroll to the active combatant when the turn changes
|
// Auto-scroll to the active combatant when the turn changes
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -194,6 +217,12 @@ export function App() {
|
|||||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
setSelectedCreatureId(active.creatureId as CreatureId);
|
||||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
}, [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 (
|
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="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>
|
</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 */}
|
{/* 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
|
<div className="flex flex-col px-2 py-2">
|
||||||
className={`flex flex-col px-2 py-2${encounter.combatants.length === 0 ? " h-full items-center justify-center" : ""}`}
|
{encounter.combatants.map((c, i) => (
|
||||||
>
|
|
||||||
{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) => (
|
|
||||||
<CombatantRow
|
<CombatantRow
|
||||||
key={c.id}
|
key={c.id}
|
||||||
ref={i === encounter.activeIndex ? activeRowRef : null}
|
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||||
@@ -252,13 +295,15 @@ export function App() {
|
|||||||
c.creatureId ? handleRollInitiative : undefined
|
c.creatureId ? handleRollInitiative : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Bar — fixed at bottom */}
|
{/* Action Bar — fixed at bottom */}
|
||||||
<div className="shrink-0 pb-8">
|
<div
|
||||||
|
className={`shrink-0 pb-8${settlingClass}`}
|
||||||
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
onAddCombatant={addCombatant}
|
onAddCombatant={addCombatant}
|
||||||
onAddFromBestiary={handleAddFromBestiary}
|
onAddFromBestiary={handleAddFromBestiary}
|
||||||
@@ -273,6 +318,8 @@ export function App() {
|
|||||||
onManagePlayers={() => setManagementOpen(true)}
|
onManagePlayers={() => setManagementOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pinned Stat Block Panel (left) */}
|
{/* Pinned Stat Block Panel (left) */}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface ActionBarProps {
|
|||||||
playerCharacters?: readonly PlayerCharacter[];
|
playerCharacters?: readonly PlayerCharacter[];
|
||||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
function creatureKey(r: SearchResult): string {
|
||||||
@@ -50,6 +51,7 @@ export function ActionBar({
|
|||||||
playerCharacters,
|
playerCharacters,
|
||||||
onAddFromPlayerCharacter,
|
onAddFromPlayerCharacter,
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
|
autoFocus,
|
||||||
}: ActionBarProps) {
|
}: ActionBarProps) {
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
@@ -260,6 +262,7 @@ export function ActionBar({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="+ Add combatants"
|
placeholder="+ Add combatants"
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{hasSuggestions && (
|
{hasSuggestions && (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||||
|
|||||||
@@ -80,20 +80,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes breathe {
|
@keyframes settle-to-bottom {
|
||||||
0%,
|
from {
|
||||||
100% {
|
transform: translateY(-40vh);
|
||||||
opacity: 0.4;
|
opacity: 0;
|
||||||
scale: 0.9;
|
|
||||||
}
|
}
|
||||||
50% {
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
scale: 1.1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility animate-breathe {
|
@utility animate-settle-to-bottom {
|
||||||
animation: breathe 3s ease-in-out infinite;
|
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));
|
@custom-variant pointer-coarse (@media (pointer: coarse));
|
||||||
|
|||||||
Reference in New Issue
Block a user