Overhaul bottom bar: batch add, custom fields, stat block viewer

Unify the action bar into a single search input with inline bestiary
dropdown. Clicking a dropdown entry queues it with +/- count controls
and a confirm button; Enter or confirm adds N copies to combat.

When no bestiary match exists, optional Init/AC/MaxHP fields appear
for custom creatures. The eye icon opens a separate search dropdown
to preview stat blocks without leaving the add flow.

Fix batch-add bug where only the last creature got a creatureId by
using store.save() instead of setEncounter() in addFromBestiary.
Prevent dropdown buttons from stealing input focus so Enter confirms
the queued batch.

Remove the now-redundant BestiarySearch component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-11 15:27:06 +01:00
parent 460c65bf49
commit b6e052f198
13 changed files with 931 additions and 213 deletions

View File

@@ -1,61 +1,136 @@
import { Import, Search } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { BestiarySearch } from "./bestiary-search.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface QueuedCreature {
result: SearchResult;
count: number;
}
interface ActionBarProps {
onAddCombatant: (name: string) => void;
onAddCombatant: (
name: string,
opts?: { initiative?: number; ac?: number; maxHp?: number },
) => void;
onAddFromBestiary: (result: SearchResult) => void;
bestiarySearch: (query: string) => SearchResult[];
bestiaryLoaded: boolean;
onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
}
function creatureKey(r: SearchResult): string {
return `${r.source}:${r.name}`;
}
export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
onViewStatBlock,
onBulkImport,
bulkImportDisabled,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [queued, setQueued] = useState<QueuedCreature | null>(null);
const [customInit, setCustomInit] = useState("");
const [customAc, setCustomAc] = useState("");
const [customMaxHp, setCustomMaxHp] = useState("");
// Stat block viewer: separate dropdown
const [viewerOpen, setViewerOpen] = useState(false);
const [viewerQuery, setViewerQuery] = useState("");
const [viewerResults, setViewerResults] = useState<SearchResult[]>([]);
const [viewerIndex, setViewerIndex] = useState(-1);
const viewerRef = useRef<HTMLDivElement>(null);
const viewerInputRef = useRef<HTMLInputElement>(null);
const clearCustomFields = () => {
setCustomInit("");
setCustomAc("");
setCustomMaxHp("");
};
const confirmQueued = () => {
if (!queued) return;
for (let i = 0; i < queued.count; i++) {
onAddFromBestiary(queued.result);
}
setQueued(null);
setNameInput("");
setSuggestions([]);
setSuggestionIndex(-1);
};
const parseNum = (v: string): number | undefined => {
if (v.trim() === "") return undefined;
const n = Number(v);
return Number.isNaN(n) ? undefined : n;
};
const handleAdd = (e: FormEvent) => {
e.preventDefault();
if (queued) {
confirmQueued();
return;
}
if (nameInput.trim() === "") return;
onAddCombatant(nameInput);
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
const init = parseNum(customInit);
const ac = parseNum(customAc);
const maxHp = parseNum(customMaxHp);
if (init !== undefined) opts.initiative = init;
if (ac !== undefined) opts.ac = ac;
if (maxHp !== undefined) opts.maxHp = maxHp;
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
setSuggestions([]);
clearCustomFields();
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
let newSuggestions: SearchResult[] = [];
if (value.length >= 2) {
setSuggestions(bestiarySearch(value));
newSuggestions = bestiarySearch(value);
setSuggestions(newSuggestions);
} else {
setSuggestions([]);
}
if (newSuggestions.length > 0) {
clearCustomFields();
}
if (queued) {
const qKey = creatureKey(queued.result);
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
if (!stillVisible) {
setQueued(null);
}
}
};
const handleSelectCreature = (result: SearchResult) => {
onAddFromBestiary(result);
setSearchOpen(false);
setNameInput("");
setSuggestions([]);
const handleClickSuggestion = (result: SearchResult) => {
const key = creatureKey(result);
if (queued && creatureKey(queued.result) === key) {
setQueued({ ...queued, count: queued.count + 1 });
} else {
setQueued({ result, count: 1 });
}
};
const handleSelectSuggestion = (result: SearchResult) => {
onAddFromBestiary(result);
setNameInput("");
setSuggestions([]);
const handleEnter = () => {
if (queued) {
confirmQueued();
} else if (suggestionIndex >= 0) {
handleClickSuggestion(suggestions[suggestionIndex]);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -67,91 +142,276 @@ export function ActionBar({
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) {
} else if (e.key === "Enter") {
e.preventDefault();
handleSelectSuggestion(suggestions[suggestionIndex]);
handleEnter();
} else if (e.key === "Escape") {
setQueued(null);
setSuggestionIndex(-1);
setSuggestions([]);
}
};
// Stat block viewer dropdown handlers
const openViewer = () => {
setViewerOpen(true);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
requestAnimationFrame(() => viewerInputRef.current?.focus());
};
const closeViewer = () => {
setViewerOpen(false);
setViewerQuery("");
setViewerResults([]);
setViewerIndex(-1);
};
const handleViewerQueryChange = (value: string) => {
setViewerQuery(value);
setViewerIndex(-1);
if (value.length >= 2) {
setViewerResults(bestiarySearch(value));
} else {
setViewerResults([]);
}
};
const handleViewerSelect = (result: SearchResult) => {
onViewStatBlock?.(result);
closeViewer();
};
const handleViewerKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
closeViewer();
return;
}
if (viewerResults.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1));
} else if (e.key === "Enter" && viewerIndex >= 0) {
e.preventDefault();
handleViewerSelect(viewerResults[viewerIndex]);
}
};
// Close viewer on outside click
useEffect(() => {
if (!viewerOpen) return;
function handleClickOutside(e: MouseEvent) {
if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) {
closeViewer();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [viewerOpen]);
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
{searchOpen ? (
<BestiarySearch
onSelectCreature={handleSelectCreature}
onClose={() => setSearchOpen(false)}
searchFn={bestiarySearch}
/>
) : (
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="relative flex-1">
<Input
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Combatant name"
className="max-w-xs"
/>
{suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((result, i) => (
<li key={`${result.source}:${result.name}`}>
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="relative flex-1">
<Input
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search creatures to add..."
className="max-w-xs"
/>
{suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((result, i) => {
const key = creatureKey(result);
const isQueued =
queued !== null && creatureKey(queued.result) === key;
return (
<li key={key}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
isQueued
? "bg-accent/30 text-foreground"
: i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleSelectSuggestion(result)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleClickSuggestion(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{result.sourceDisplayName}
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{isQueued ? (
<>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (queued.count <= 1) {
setQueued(null);
} else {
setQueued({
...queued,
count: queued.count - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
{queued.count}
</span>
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setQueued({
...queued,
count: queued.count + 1,
});
}}
>
<Plus className="h-3 w-3" />
</button>
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
confirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
))}
</ul>
);
})}
</ul>
</div>
)}
</div>
{nameInput.length >= 2 && suggestions.length === 0 && (
<div className="flex items-center gap-2">
<Input
type="text"
inputMode="numeric"
value={customInit}
onChange={(e) => setCustomInit(e.target.value)}
placeholder="Init"
className="w-16 text-center"
/>
<Input
type="text"
inputMode="numeric"
value={customAc}
onChange={(e) => setCustomAc(e.target.value)}
placeholder="AC"
className="w-16 text-center"
/>
<Input
type="text"
inputMode="numeric"
value={customMaxHp}
onChange={(e) => setCustomMaxHp(e.target.value)}
placeholder="MaxHP"
className="w-18 text-center"
/>
</div>
)}
<Button type="submit" size="sm">
Add
</Button>
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
>
<Eye className="h-4 w-4" />
</Button>
{viewerOpen && (
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
<div className="p-2">
<Input
ref={viewerInputRef}
type="text"
value={viewerQuery}
onChange={(e) => handleViewerQueryChange(e.target.value)}
onKeyDown={handleViewerKeyDown}
placeholder="Search stat blocks..."
className="w-full"
/>
</div>
{viewerResults.length > 0 && (
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
{viewerResults.map((result, i) => (
<li key={creatureKey(result)}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === viewerIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleViewerSelect(result)}
onMouseEnter={() => setViewerIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
)}
{viewerQuery.length >= 2 && viewerResults.length === 0 && (
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
)}
</div>
)}
</div>
<Button type="submit" size="sm">
Add
)}
{bestiaryLoaded && onBulkImport && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onBulkImport}
disabled={bulkImportDisabled}
>
<Import className="h-4 w-4" />
</Button>
{bestiaryLoaded && (
<>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setSearchOpen(true)}
>
<Search className="h-4 w-4" />
</Button>
{onBulkImport && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onBulkImport}
disabled={bulkImportDisabled}
>
<Import className="h-4 w-4" />
</Button>
)}
</>
)}
</form>
)}
)}
</form>
</div>
);
}

View File

@@ -1,129 +0,0 @@
import { Search, X } from "lucide-react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type { SearchResult } from "../hooks/use-bestiary.js";
import { Input } from "./ui/input.js";
interface BestiarySearchProps {
onSelectCreature: (result: SearchResult) => void;
onClose: () => void;
searchFn: (query: string) => SearchResult[];
}
export function BestiarySearch({
onSelectCreature,
onClose,
searchFn,
}: BestiarySearchProps) {
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const results = query.length >= 2 ? searchFn(query) : [];
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
setHighlightIndex(-1);
}, [query]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
return;
}
if (e.key === "Enter" && highlightIndex >= 0) {
e.preventDefault();
onSelectCreature(results[highlightIndex]);
}
},
[results, highlightIndex, onClose, onSelectCreature],
);
return (
<div ref={containerRef} className="relative w-full max-w-sm">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search bestiary..."
className="flex-1"
/>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-hover-neutral"
>
<X className="h-4 w-4" />
</button>
</div>
{query.length >= 2 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
{results.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
) : (
<ul className="max-h-60 overflow-y-auto py-1">
{results.map((result, i) => (
<li key={`${result.source}:${result.name}`}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === highlightIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => onSelectCreature(result)}
onMouseEnter={() => setHighlightIndex(i)}
>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{result.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}