Decompose ActionBar into hook and focused sub-components
Extract useActionBarState hook with all search/queue/mode state and handlers. Extract RollAllButton (context-consuming, zero props), BrowseSuggestions, CustomStatFields, and refactor AddModeSuggestions to use grouped SuggestionActions interface (11 props → 6). ActionBar is now a ~120-line layout shell. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
Check,
|
||||
Eye,
|
||||
@@ -10,19 +10,17 @@ import {
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { type RefObject, useCallback, useState } from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import {
|
||||
creatureKey,
|
||||
type QueuedCreature,
|
||||
type SuggestionActions,
|
||||
useActionBarState,
|
||||
} from "../hooks/use-action-bar-state.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
@@ -32,11 +30,6 @@ 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 {
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
autoFocus?: boolean;
|
||||
@@ -44,8 +37,13 @@ interface ActionBarProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
return `${r.source}:${r.name}`;
|
||||
interface AddModeSuggestionsProps {
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
pcMatches: PlayerCharacter[];
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
actions: SuggestionActions;
|
||||
}
|
||||
|
||||
function AddModeSuggestions({
|
||||
@@ -54,34 +52,15 @@ function AddModeSuggestions({
|
||||
pcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
onDismiss,
|
||||
onClickSuggestion,
|
||||
onSetSuggestionIndex,
|
||||
onSetQueued,
|
||||
onConfirmQueued,
|
||||
onAddFromPlayerCharacter,
|
||||
onClear,
|
||||
}: Readonly<{
|
||||
nameInput: string;
|
||||
suggestions: SearchResult[];
|
||||
pcMatches: PlayerCharacter[];
|
||||
suggestionIndex: number;
|
||||
queued: QueuedCreature | null;
|
||||
onDismiss: () => void;
|
||||
onClear: () => void;
|
||||
onClickSuggestion: (result: SearchResult) => void;
|
||||
onSetSuggestionIndex: (i: number) => void;
|
||||
onSetQueued: (q: QueuedCreature | null) => void;
|
||||
onConfirmQueued: () => void;
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
}>) {
|
||||
actions,
|
||||
}: Readonly<AddModeSuggestionsProps>) {
|
||||
return (
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onDismiss}
|
||||
onClick={actions.dismiss}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||
@@ -108,8 +87,8 @@ function AddModeSuggestions({
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onAddFromPlayerCharacter?.(pc);
|
||||
onClear();
|
||||
actions.addFromPlayerCharacter?.(pc);
|
||||
actions.clear();
|
||||
}}
|
||||
>
|
||||
{!!PcIcon && (
|
||||
@@ -145,8 +124,8 @@ function AddModeSuggestions({
|
||||
"hover:bg-hover-neutral-bg",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onClickSuggestion(result)}
|
||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
||||
onClick={() => actions.clickSuggestion(result)}
|
||||
onMouseEnter={() => actions.setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||
@@ -159,9 +138,9 @@ function AddModeSuggestions({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (queued.count <= 1) {
|
||||
onSetQueued(null);
|
||||
actions.setQueued(null);
|
||||
} else {
|
||||
onSetQueued({
|
||||
actions.setQueued({
|
||||
...queued,
|
||||
count: queued.count - 1,
|
||||
});
|
||||
@@ -179,7 +158,7 @@ function AddModeSuggestions({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetQueued({
|
||||
actions.setQueued({
|
||||
...queued,
|
||||
count: queued.count + 1,
|
||||
});
|
||||
@@ -193,7 +172,7 @@ function AddModeSuggestions({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfirmQueued();
|
||||
actions.confirmQueued();
|
||||
}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -214,6 +193,152 @@ function AddModeSuggestions({
|
||||
);
|
||||
}
|
||||
|
||||
interface BrowseSuggestionsProps {
|
||||
suggestions: SearchResult[];
|
||||
suggestionIndex: number;
|
||||
onSelect: (result: SearchResult) => void;
|
||||
onHover: (index: number) => void;
|
||||
}
|
||||
|
||||
function BrowseSuggestions({
|
||||
suggestions,
|
||||
suggestionIndex,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: Readonly<BrowseSuggestionsProps>) {
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{suggestions.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"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={() => onSelect(result)}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomStatFieldsProps {
|
||||
customInit: string;
|
||||
customAc: string;
|
||||
customMaxHp: string;
|
||||
onInitChange: (v: string) => void;
|
||||
onAcChange: (v: string) => void;
|
||||
onMaxHpChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CustomStatFields({
|
||||
customInit,
|
||||
customAc,
|
||||
customMaxHp,
|
||||
onInitChange,
|
||||
onAcChange,
|
||||
onMaxHpChange,
|
||||
}: Readonly<CustomStatFieldsProps>) {
|
||||
return (
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customInit}
|
||||
onChange={(e) => onInitChange(e.target.value)}
|
||||
placeholder="Init"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customAc}
|
||||
onChange={(e) => onAcChange(e.target.value)}
|
||||
placeholder="AC"
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customMaxHp}
|
||||
onChange={(e) => onMaxHpChange(e.target.value)}
|
||||
placeholder="MaxHP"
|
||||
className="w-18 text-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RollAllButton() {
|
||||
const { hasCreatureCombatants, canRollAllInitiative } = useEncounterContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
|
||||
const [menuPos, setMenuPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const openMenu = useCallback((x: number, y: number) => {
|
||||
setMenuPos({ x, y });
|
||||
}, []);
|
||||
|
||||
const longPress = useLongPress(
|
||||
useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) openMenu(touch.clientX, touch.clientY);
|
||||
},
|
||||
[openMenu],
|
||||
),
|
||||
);
|
||||
|
||||
if (!hasCreatureCombatants) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={() => handleRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...longPress}
|
||||
disabled={!canRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!menuPos && (
|
||||
<RollModeMenu
|
||||
position={menuPos}
|
||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||
onClose={() => setMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function buildOverflowItems(opts: {
|
||||
onManagePlayers?: () => void;
|
||||
onOpenSourceManager?: () => void;
|
||||
@@ -262,253 +387,33 @@ export function ActionBar({
|
||||
onOpenSettings,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const {
|
||||
addCombatant,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
nameInput,
|
||||
suggestions,
|
||||
pcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
customInit,
|
||||
customAc,
|
||||
customMaxHp,
|
||||
browseMode,
|
||||
bestiaryLoaded,
|
||||
hasSuggestions,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
suggestionActions,
|
||||
handleNameChange,
|
||||
handleKeyDown,
|
||||
handleBrowseKeyDown,
|
||||
handleAdd,
|
||||
handleBrowseSelect,
|
||||
toggleBrowseMode,
|
||||
setCustomInit,
|
||||
setCustomAc,
|
||||
setCustomMaxHp,
|
||||
} = useActionBarState();
|
||||
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
showCreature(cId);
|
||||
},
|
||||
[showCreature],
|
||||
);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const deferredSuggestions = useDeferredValue(suggestions);
|
||||
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||
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++) {
|
||||
handleAddFromBestiary(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: React.SubmitEvent<HTMLFormElement>) => {
|
||||
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;
|
||||
addCombatant(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 =
|
||||
deferredSuggestions.length > 0 || deferredPcMatches.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();
|
||||
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
handleViewStatBlock(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((prev) => {
|
||||
const next = !prev;
|
||||
setSuggestionIndex(-1);
|
||||
setQueued(null);
|
||||
if (next) {
|
||||
handleBrowseSearch(nameInput);
|
||||
} else {
|
||||
handleAddSearch(nameInput);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const openRollAllMenu = useCallback((x: number, y: number) => {
|
||||
setRollAllMenuPos({ x, y });
|
||||
}, []);
|
||||
|
||||
const rollAllLongPress = useLongPress(
|
||||
useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
|
||||
},
|
||||
[openRollAllMenu],
|
||||
),
|
||||
);
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
onOpenSourceManager: showSourceManager,
|
||||
@@ -560,110 +465,40 @@ export function ActionBar({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{browseMode && deferredSuggestions.length > 0 && (
|
||||
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{deferredSuggestions.map((result, i) => (
|
||||
<li key={creatureKey(result)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"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-muted-foreground text-xs">
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{!!browseMode && (
|
||||
<BrowseSuggestions
|
||||
suggestions={suggestions}
|
||||
suggestionIndex={suggestionIndex}
|
||||
onSelect={handleBrowseSelect}
|
||||
onHover={suggestionActions.setSuggestionIndex}
|
||||
/>
|
||||
)}
|
||||
{!browseMode && hasSuggestions && (
|
||||
<AddModeSuggestions
|
||||
nameInput={nameInput}
|
||||
suggestions={deferredSuggestions}
|
||||
pcMatches={deferredPcMatches}
|
||||
suggestions={suggestions}
|
||||
pcMatches={pcMatches}
|
||||
suggestionIndex={suggestionIndex}
|
||||
queued={queued}
|
||||
onDismiss={dismissSuggestions}
|
||||
onClear={clearInput}
|
||||
onClickSuggestion={handleClickSuggestion}
|
||||
onSetSuggestionIndex={setSuggestionIndex}
|
||||
onSetQueued={setQueued}
|
||||
onConfirmQueued={confirmQueued}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
actions={suggestionActions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customInit}
|
||||
onChange={(e) => setCustomInit(e.target.value)}
|
||||
placeholder="Init"
|
||||
className="w-16 text-center"
|
||||
<CustomStatFields
|
||||
customInit={customInit}
|
||||
customAc={customAc}
|
||||
customMaxHp={customMaxHp}
|
||||
onInitChange={setCustomInit}
|
||||
onAcChange={setCustomAc}
|
||||
onMaxHpChange={setCustomMaxHp}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
{!!hasCreatureCombatants && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={() => handleRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openRollAllMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...rollAllLongPress}
|
||||
disabled={!canRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
{!!rollAllMenuPos && (
|
||||
<RollModeMenu
|
||||
position={rollAllMenuPos}
|
||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||
onClose={() => setRollAllMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<RollAllButton />
|
||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
299
apps/web/src/hooks/use-action-bar-state.ts
Normal file
299
apps/web/src/hooks/use-action-bar-state.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
export interface QueuedCreature {
|
||||
result: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SuggestionActions {
|
||||
dismiss: () => void;
|
||||
clear: () => void;
|
||||
clickSuggestion: (result: SearchResult) => void;
|
||||
setSuggestionIndex: (i: number) => void;
|
||||
setQueued: (q: QueuedCreature | null) => void;
|
||||
confirmQueued: () => void;
|
||||
addFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
}
|
||||
|
||||
export function creatureKey(r: SearchResult): string {
|
||||
return `${r.source}:${r.name}`;
|
||||
}
|
||||
|
||||
export function useActionBarState() {
|
||||
const { addCombatant, addFromBestiary, addFromPlayerCharacter } =
|
||||
useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const deferredSuggestions = useDeferredValue(suggestions);
|
||||
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||
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 = useCallback(() => {
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}, []);
|
||||
|
||||
const dismissSuggestions = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}, []);
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
);
|
||||
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
showCreature(cId);
|
||||
},
|
||||
[showCreature],
|
||||
);
|
||||
|
||||
const confirmQueued = useCallback(() => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
handleAddFromBestiary(queued.result);
|
||||
}
|
||||
clearInput();
|
||||
}, [queued, handleAddFromBestiary, 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: React.SubmitEvent<HTMLFormElement>) => {
|
||||
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;
|
||||
addCombatant(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 = useCallback((result: SearchResult) => {
|
||||
const key = creatureKey(result);
|
||||
setQueued((prev) => {
|
||||
if (prev && creatureKey(prev.result) === key) {
|
||||
return { ...prev, count: prev.count + 1 };
|
||||
}
|
||||
return { result, count: 1 };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleEnter = () => {
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
} else if (suggestionIndex >= 0) {
|
||||
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions =
|
||||
deferredSuggestions.length > 0 || deferredPcMatches.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();
|
||||
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
handleViewStatBlock(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((prev) => {
|
||||
const next = !prev;
|
||||
setSuggestionIndex(-1);
|
||||
setQueued(null);
|
||||
if (next) {
|
||||
handleBrowseSearch(nameInput);
|
||||
} else {
|
||||
handleAddSearch(nameInput);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const suggestionActions: SuggestionActions = useMemo(
|
||||
() => ({
|
||||
dismiss: dismissSuggestions,
|
||||
clear: clearInput,
|
||||
clickSuggestion: handleClickSuggestion,
|
||||
setSuggestionIndex,
|
||||
setQueued,
|
||||
confirmQueued,
|
||||
addFromPlayerCharacter,
|
||||
}),
|
||||
[
|
||||
dismissSuggestions,
|
||||
clearInput,
|
||||
handleClickSuggestion,
|
||||
confirmQueued,
|
||||
addFromPlayerCharacter,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
nameInput,
|
||||
suggestions: deferredSuggestions,
|
||||
pcMatches: deferredPcMatches,
|
||||
suggestionIndex,
|
||||
queued,
|
||||
customInit,
|
||||
customAc,
|
||||
customMaxHp,
|
||||
browseMode,
|
||||
bestiaryLoaded,
|
||||
hasSuggestions,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
|
||||
// Actions
|
||||
suggestionActions,
|
||||
handleNameChange,
|
||||
handleKeyDown,
|
||||
handleBrowseKeyDown,
|
||||
handleAdd,
|
||||
handleBrowseSelect,
|
||||
toggleBrowseMode,
|
||||
setCustomInit,
|
||||
setCustomAc,
|
||||
setCustomMaxHp,
|
||||
} as const;
|
||||
}
|
||||
Reference in New Issue
Block a user