The "Add as custom" button and Escape key were clearing the name input along with the suggestions, preventing the custom fields (Init, AC, MaxHP) from ever appearing. Now only the suggestions are dismissed, keeping the typed name intact so the custom combatant form renders. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
583 lines
16 KiB
TypeScript
583 lines
16 KiB
TypeScript
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
|
|
import {
|
|
Check,
|
|
Eye,
|
|
EyeOff,
|
|
Import,
|
|
Library,
|
|
Minus,
|
|
Plus,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { type FormEvent, type RefObject, useState } from "react";
|
|
import type { SearchResult } from "../hooks/use-bestiary.js";
|
|
import { cn } from "../lib/utils.js";
|
|
import { D20Icon } from "./d20-icon.js";
|
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
|
import { Button } from "./ui/button.js";
|
|
import { Input } from "./ui/input.js";
|
|
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
|
|
|
interface QueuedCreature {
|
|
result: SearchResult;
|
|
count: number;
|
|
}
|
|
|
|
interface ActionBarProps {
|
|
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;
|
|
inputRef?: RefObject<HTMLInputElement | null>;
|
|
playerCharacters?: readonly PlayerCharacter[];
|
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
|
onManagePlayers?: () => void;
|
|
onRollAllInitiative?: () => void;
|
|
showRollAllInitiative?: boolean;
|
|
onOpenSourceManager?: () => void;
|
|
autoFocus?: boolean;
|
|
}
|
|
|
|
function creatureKey(r: SearchResult): string {
|
|
return `${r.source}:${r.name}`;
|
|
}
|
|
|
|
function AddModeSuggestions({
|
|
nameInput,
|
|
suggestions,
|
|
pcMatches,
|
|
suggestionIndex,
|
|
queued,
|
|
onDismiss,
|
|
onClickSuggestion,
|
|
onSetSuggestionIndex,
|
|
onSetQueued,
|
|
onConfirmQueued,
|
|
onAddFromPlayerCharacter,
|
|
}: {
|
|
nameInput: string;
|
|
suggestions: SearchResult[];
|
|
pcMatches: PlayerCharacter[];
|
|
suggestionIndex: number;
|
|
queued: QueuedCreature | null;
|
|
onDismiss: () => void;
|
|
onClickSuggestion: (result: SearchResult) => void;
|
|
onSetSuggestionIndex: (i: number) => void;
|
|
onSetQueued: (q: QueuedCreature | null) => void;
|
|
onConfirmQueued: () => void;
|
|
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
|
}) {
|
|
return (
|
|
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={onDismiss}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
|
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
|
|
Esc
|
|
</kbd>
|
|
</button>
|
|
<div className="max-h-48 overflow-y-auto py-1">
|
|
{pcMatches.length > 0 && (
|
|
<>
|
|
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
|
|
Players
|
|
</div>
|
|
<ul>
|
|
{pcMatches.map((pc) => {
|
|
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
|
const pcColor =
|
|
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
|
return (
|
|
<li key={pc.id}>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => {
|
|
onAddFromPlayerCharacter?.(pc);
|
|
onDismiss();
|
|
}}
|
|
>
|
|
{PcIcon && (
|
|
<PcIcon size={14} style={{ color: pcColor }} />
|
|
)}
|
|
<span className="flex-1 truncate">{pc.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
Player
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</>
|
|
)}
|
|
{suggestions.length > 0 && (
|
|
<ul>
|
|
{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 ${
|
|
isQueued
|
|
? "bg-accent/30 text-foreground"
|
|
: i === suggestionIndex
|
|
? "bg-accent/20 text-foreground"
|
|
: "text-foreground hover:bg-hover-neutral-bg"
|
|
}`}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onClickSuggestion(result)}
|
|
onMouseEnter={() => onSetSuggestionIndex(i)}
|
|
>
|
|
<span>{result.name}</span>
|
|
<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) {
|
|
onSetQueued(null);
|
|
} else {
|
|
onSetQueued({
|
|
...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();
|
|
onSetQueued({
|
|
...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();
|
|
onConfirmQueued();
|
|
}}
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
) : (
|
|
result.sourceDisplayName
|
|
)}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildOverflowItems(opts: {
|
|
onManagePlayers?: () => void;
|
|
onOpenSourceManager?: () => void;
|
|
bestiaryLoaded: boolean;
|
|
onBulkImport?: () => void;
|
|
bulkImportDisabled?: boolean;
|
|
}): OverflowMenuItem[] {
|
|
const items: OverflowMenuItem[] = [];
|
|
if (opts.onManagePlayers) {
|
|
items.push({
|
|
icon: <Users className="h-4 w-4" />,
|
|
label: "Player Characters",
|
|
onClick: opts.onManagePlayers,
|
|
});
|
|
}
|
|
if (opts.onOpenSourceManager) {
|
|
items.push({
|
|
icon: <Library className="h-4 w-4" />,
|
|
label: "Manage Sources",
|
|
onClick: opts.onOpenSourceManager,
|
|
});
|
|
}
|
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
|
items.push({
|
|
icon: <Import className="h-4 w-4" />,
|
|
label: "Bulk Import",
|
|
onClick: opts.onBulkImport,
|
|
disabled: opts.bulkImportDisabled,
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
export function ActionBar({
|
|
onAddCombatant,
|
|
onAddFromBestiary,
|
|
bestiarySearch,
|
|
bestiaryLoaded,
|
|
onViewStatBlock,
|
|
onBulkImport,
|
|
bulkImportDisabled,
|
|
inputRef,
|
|
playerCharacters,
|
|
onAddFromPlayerCharacter,
|
|
onManagePlayers,
|
|
onRollAllInitiative,
|
|
showRollAllInitiative,
|
|
onOpenSourceManager,
|
|
autoFocus,
|
|
}: ActionBarProps) {
|
|
const [nameInput, setNameInput] = useState("");
|
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
|
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
|
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
|
const [customInit, setCustomInit] = useState("");
|
|
const [customAc, setCustomAc] = useState("");
|
|
const [customMaxHp, setCustomMaxHp] = useState("");
|
|
const [browseMode, setBrowseMode] = useState(false);
|
|
|
|
const clearCustomFields = () => {
|
|
setCustomInit("");
|
|
setCustomAc("");
|
|
setCustomMaxHp("");
|
|
};
|
|
|
|
const clearInput = () => {
|
|
setNameInput("");
|
|
setSuggestions([]);
|
|
setPcMatches([]);
|
|
setQueued(null);
|
|
setSuggestionIndex(-1);
|
|
};
|
|
|
|
const dismissSuggestions = () => {
|
|
setSuggestions([]);
|
|
setPcMatches([]);
|
|
setQueued(null);
|
|
setSuggestionIndex(-1);
|
|
};
|
|
|
|
const confirmQueued = () => {
|
|
if (!queued) return;
|
|
for (let i = 0; i < queued.count; i++) {
|
|
onAddFromBestiary(queued.result);
|
|
}
|
|
clearInput();
|
|
};
|
|
|
|
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 (browseMode) return;
|
|
if (queued) {
|
|
confirmQueued();
|
|
return;
|
|
}
|
|
if (nameInput.trim() === "") return;
|
|
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([]);
|
|
setPcMatches([]);
|
|
clearCustomFields();
|
|
};
|
|
|
|
const handleBrowseSearch = (value: string) => {
|
|
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
|
};
|
|
|
|
const handleAddSearch = (value: string) => {
|
|
let newSuggestions: SearchResult[] = [];
|
|
let newPcMatches: PlayerCharacter[] = [];
|
|
if (value.length >= 2) {
|
|
newSuggestions = bestiarySearch(value);
|
|
setSuggestions(newSuggestions);
|
|
if (playerCharacters && playerCharacters.length > 0) {
|
|
const lower = value.toLowerCase();
|
|
newPcMatches = playerCharacters.filter((pc) =>
|
|
pc.name.toLowerCase().includes(lower),
|
|
);
|
|
}
|
|
setPcMatches(newPcMatches);
|
|
} else {
|
|
setSuggestions([]);
|
|
setPcMatches([]);
|
|
}
|
|
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
|
clearCustomFields();
|
|
}
|
|
if (queued) {
|
|
const qKey = creatureKey(queued.result);
|
|
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
|
if (!stillVisible) {
|
|
setQueued(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleNameChange = (value: string) => {
|
|
setNameInput(value);
|
|
setSuggestionIndex(-1);
|
|
if (browseMode) {
|
|
handleBrowseSearch(value);
|
|
} else {
|
|
handleAddSearch(value);
|
|
}
|
|
};
|
|
|
|
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 handleEnter = () => {
|
|
if (queued) {
|
|
confirmQueued();
|
|
} else if (suggestionIndex >= 0) {
|
|
handleClickSuggestion(suggestions[suggestionIndex]);
|
|
}
|
|
};
|
|
|
|
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (!hasSuggestions) return;
|
|
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleEnter();
|
|
} else if (e.key === "Escape") {
|
|
dismissSuggestions();
|
|
}
|
|
};
|
|
|
|
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setBrowseMode(false);
|
|
clearInput();
|
|
return;
|
|
}
|
|
if (suggestions.length === 0) return;
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
|
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
|
e.preventDefault();
|
|
onViewStatBlock?.(suggestions[suggestionIndex]);
|
|
setBrowseMode(false);
|
|
clearInput();
|
|
}
|
|
};
|
|
|
|
const handleBrowseSelect = (result: SearchResult) => {
|
|
onViewStatBlock?.(result);
|
|
setBrowseMode(false);
|
|
clearInput();
|
|
};
|
|
|
|
const toggleBrowseMode = () => {
|
|
setBrowseMode((m) => !m);
|
|
clearInput();
|
|
clearCustomFields();
|
|
};
|
|
|
|
const overflowItems = buildOverflowItems({
|
|
onManagePlayers,
|
|
onOpenSourceManager,
|
|
bestiaryLoaded,
|
|
onBulkImport,
|
|
bulkImportDisabled,
|
|
});
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
|
<form
|
|
onSubmit={handleAdd}
|
|
className="relative flex flex-1 items-center gap-2"
|
|
>
|
|
<div className="flex-1">
|
|
<div className="relative max-w-xs">
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={nameInput}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
|
placeholder={
|
|
browseMode ? "Search stat blocks..." : "+ Add combatants"
|
|
}
|
|
className="pr-8"
|
|
autoFocus={autoFocus}
|
|
/>
|
|
{bestiaryLoaded && onViewStatBlock && (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
className={cn(
|
|
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
|
browseMode && "text-accent",
|
|
)}
|
|
onClick={toggleBrowseMode}
|
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
|
aria-label={
|
|
browseMode ? "Switch to add mode" : "Browse stat blocks"
|
|
}
|
|
>
|
|
{browseMode ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
)}
|
|
{browseMode && suggestions.length > 0 && (
|
|
<div className="absolute bottom-full z-50 mb-1 w-full 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={creatureKey(result)}>
|
|
<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"
|
|
}`}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => handleBrowseSelect(result)}
|
|
onMouseEnter={() => setSuggestionIndex(i)}
|
|
>
|
|
<span>{result.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{result.sourceDisplayName}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{!browseMode && hasSuggestions && (
|
|
<AddModeSuggestions
|
|
nameInput={nameInput}
|
|
suggestions={suggestions}
|
|
pcMatches={pcMatches}
|
|
suggestionIndex={suggestionIndex}
|
|
queued={queued}
|
|
onDismiss={dismissSuggestions}
|
|
onClickSuggestion={handleClickSuggestion}
|
|
onSetSuggestionIndex={setSuggestionIndex}
|
|
onSetQueued={setQueued}
|
|
onConfirmQueued={confirmQueued}
|
|
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
|
<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>
|
|
)}
|
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
|
<Button type="submit">Add</Button>
|
|
)}
|
|
{showRollAllInitiative && onRollAllInitiative && (
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-hover-action"
|
|
onClick={onRollAllInitiative}
|
|
title="Roll all initiative"
|
|
aria-label="Roll all initiative"
|
|
>
|
|
<D20Icon className="h-6 w-6" />
|
|
</Button>
|
|
)}
|
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|