Files
initiative/apps/web/src/components/action-bar.tsx
Lukas fab9301b20
All checks were successful
CI / check (push) Successful in 1m7s
CI / build-image (push) Has been skipped
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>
2026-03-25 11:41:35 +01:00

507 lines
14 KiB
TypeScript

import type { PlayerCharacter } from "@initiative/domain";
import {
Check,
Eye,
EyeOff,
Import,
Library,
Minus,
Plus,
Settings,
Users,
} from "lucide-react";
import React, { type RefObject, useCallback, useState } from "react";
import type { SearchResult } 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 {
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";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
interface ActionBarProps {
inputRef?: RefObject<HTMLInputElement | null>;
autoFocus?: boolean;
onManagePlayers?: () => void;
onOpenSettings?: () => void;
}
interface AddModeSuggestionsProps {
nameInput: string;
suggestions: SearchResult[];
pcMatches: PlayerCharacter[];
suggestionIndex: number;
queued: QueuedCreature | null;
actions: SuggestionActions;
}
function AddModeSuggestions({
nameInput,
suggestions,
pcMatches,
suggestionIndex,
queued,
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={actions.dismiss}
>
<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-muted-foreground text-xs">
Esc
</kbd>
</button>
<div className="max-h-48 overflow-y-auto py-1">
{pcMatches.length > 0 && (
<>
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
Players
</div>
<ul>
{pcMatches.map((pc) => {
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
const pcColor = pc.color
? PLAYER_COLOR_HEX[pc.color]
: undefined;
return (
<li key={pc.id}>
<button
type="button"
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={() => {
actions.addFromPlayerCharacter?.(pc);
actions.clear();
}}
>
{!!PcIcon && (
<PcIcon size={14} style={{ color: pcColor }} />
)}
<span className="flex-1 truncate">{pc.name}</span>
<span className="text-muted-foreground text-xs">
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={cn(
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
isQueued && "bg-accent/30",
!isQueued && i === suggestionIndex && "bg-accent/20",
!isQueued &&
i !== suggestionIndex &&
"hover:bg-hover-neutral-bg",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => actions.clickSuggestion(result)}
onMouseEnter={() => actions.setSuggestionIndex(i)}
>
<span>{result.name}</span>
<span className="flex items-center gap-1 text-muted-foreground text-xs">
{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) {
actions.setQueued(null);
} else {
actions.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-primary-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();
actions.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();
actions.confirmQueued();
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
result.sourceDisplayName
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
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;
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
onOpenSettings?: () => void;
}): 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: "Import All Sources",
onClick: opts.onBulkImport,
disabled: opts.bulkImportDisabled,
});
}
if (opts.onOpenSettings) {
items.push({
icon: <Settings className="h-4 w-4" />,
label: "Settings",
onClick: opts.onOpenSettings,
});
}
return items;
}
export function ActionBar({
inputRef,
autoFocus,
onManagePlayers,
onOpenSettings,
}: Readonly<ActionBarProps>) {
const {
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 overflowItems = buildOverflowItems({
onManagePlayers,
onOpenSourceManager: showSourceManager,
bestiaryLoaded,
onBulkImport: showBulkImport,
bulkImportDisabled: bulkImportState.status === "loading",
onOpenSettings,
});
return (
<div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
<form
onSubmit={handleAdd}
className="relative flex flex-1 flex-wrap items-center gap-3 sm:flex-nowrap"
>
<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 && (
<button
type="button"
tabIndex={-1}
className={cn(
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
browseMode && "text-accent",
)}
onMouseDown={(e) => e.preventDefault()}
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 && (
<BrowseSuggestions
suggestions={suggestions}
suggestionIndex={suggestionIndex}
onSelect={handleBrowseSelect}
onHover={suggestionActions.setSuggestionIndex}
/>
)}
{!browseMode && hasSuggestions && (
<AddModeSuggestions
nameInput={nameInput}
suggestions={suggestions}
pcMatches={pcMatches}
suggestionIndex={suggestionIndex}
queued={queued}
actions={suggestionActions}
/>
)}
</div>
</div>
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<CustomStatFields
customInit={customInit}
customAc={customAc}
customMaxHp={customMaxHp}
onInitChange={setCustomInit}
onAcChange={setCustomAc}
onMaxHpChange={setCustomMaxHp}
/>
)}
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<Button type="submit">Add</Button>
)}
<RollAllButton />
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form>
</div>
);
}