Improve empty encounter UX with interactive add button
All checks were successful
CI / check (push) Successful in 44s
CI / build-image (push) Successful in 22s

Replace the static "No combatants yet" text with a centered, breathing
"+" icon that focuses the action bar input on click, guiding users to
add their first combatant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-12 10:56:56 +01:00
parent 7feaf90eab
commit 768e7a390f
3 changed files with 40 additions and 5 deletions

View File

@@ -3,6 +3,7 @@ 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";
@@ -160,6 +161,8 @@ export function App() {
setPinnedCreatureId(null); setPinnedCreatureId(null);
}, []); }, []);
const actionBarInputRef = useRef<HTMLInputElement>(null);
// 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);
useEffect(() => { useEffect(() => {
@@ -205,11 +208,17 @@ export function App() {
{/* 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${encounter.combatants.length === 0 ? " h-full items-center justify-center" : ""}`}
>
{encounter.combatants.length === 0 ? ( {encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"> <button
No combatants yet add one to get started type="button"
</p> 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) => ( encounter.combatants.map((c, i) => (
<CombatantRow <CombatantRow
@@ -249,6 +258,7 @@ export function App() {
onViewStatBlock={handleViewStatBlock} onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport} onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"} bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
/> />
</div> </div>
</div> </div>

View File

@@ -1,5 +1,11 @@
import { Check, Eye, Import, Minus, Plus } from "lucide-react"; import { Check, Eye, Import, Minus, Plus } from "lucide-react";
import { type FormEvent, useEffect, useRef, useState } from "react"; import {
type FormEvent,
type RefObject,
useEffect,
useRef,
useState,
} from "react";
import type { SearchResult } from "../hooks/use-bestiary.js"; import type { SearchResult } from "../hooks/use-bestiary.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
@@ -20,6 +26,7 @@ interface ActionBarProps {
onViewStatBlock?: (result: SearchResult) => void; onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void; onBulkImport?: () => void;
bulkImportDisabled?: boolean; bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
} }
function creatureKey(r: SearchResult): string { function creatureKey(r: SearchResult): string {
@@ -34,6 +41,7 @@ export function ActionBar({
onViewStatBlock, onViewStatBlock,
onBulkImport, onBulkImport,
bulkImportDisabled, bulkImportDisabled,
inputRef,
}: ActionBarProps) { }: ActionBarProps) {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
@@ -222,6 +230,7 @@ export function ActionBar({
> >
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
ref={inputRef}
type="text" type="text"
value={nameInput} value={nameInput}
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}

View File

@@ -80,6 +80,22 @@
} }
} }
@keyframes breathe {
0%,
100% {
opacity: 0.4;
scale: 0.9;
}
50% {
opacity: 1;
scale: 1.1;
}
}
@utility animate-breathe {
animation: breathe 3s ease-in-out infinite;
}
@custom-variant pointer-coarse (@media (pointer: coarse)); @custom-variant pointer-coarse (@media (pointer: coarse));
@utility animate-confirm-pulse { @utility animate-confirm-pulse {