Declutter action bars: overflow menu, browse toggle, conditional D20
Top bar stripped to turn navigation only (Prev, round badge, Clear, Next). Roll All Initiative, Manage Sources, and Bulk Import moved to a new overflow menu in the bottom bar. Player Characters also moved there. Browse stat blocks is now an Eye/EyeOff toggle inside the search input that switches between add mode and browse mode. Add button only appears when entering a custom creature name. Roll All Initiative button shows conditionally — only when bestiary creatures lack initiative values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,6 @@ function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -72,8 +70,6 @@ describe("TurnNavigation", () => {
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
@@ -88,8 +84,6 @@ describe("TurnNavigation", () => {
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
@@ -110,8 +104,6 @@ describe("TurnNavigation", () => {
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
@@ -129,8 +121,6 @@ describe("TurnNavigation", () => {
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
onRollAllInitiative={vi.fn()}
|
||||
onOpenSourceManager={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||
@@ -173,16 +163,6 @@ describe("TurnNavigation", () => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Next turn" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Roll all initiative",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Manage cached sources",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a 40-character name without truncation class issues", () => {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
|
||||
import { Check, Eye, Import, Minus, Plus, Users } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
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;
|
||||
@@ -32,6 +38,9 @@ interface ActionBarProps {
|
||||
playerCharacters?: readonly PlayerCharacter[];
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
onManagePlayers?: () => void;
|
||||
onRollAllInitiative?: () => void;
|
||||
showRollAllInitiative?: boolean;
|
||||
onOpenSourceManager?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
@@ -39,6 +48,201 @@ 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,
|
||||
@@ -51,6 +255,9 @@ export function ActionBar({
|
||||
playerCharacters,
|
||||
onAddFromPlayerCharacter,
|
||||
onManagePlayers,
|
||||
onRollAllInitiative,
|
||||
showRollAllInitiative,
|
||||
onOpenSourceManager,
|
||||
autoFocus,
|
||||
}: ActionBarProps) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
@@ -61,14 +268,7 @@ export function ActionBar({
|
||||
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 [browseMode, setBrowseMode] = useState(false);
|
||||
|
||||
const clearCustomFields = () => {
|
||||
setCustomInit("");
|
||||
@@ -76,16 +276,20 @@ export function ActionBar({
|
||||
setCustomMaxHp("");
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
onAddFromBestiary(queued.result);
|
||||
}
|
||||
setQueued(null);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setSuggestionIndex(-1);
|
||||
clearInput();
|
||||
};
|
||||
|
||||
const parseNum = (v: string): number | undefined => {
|
||||
@@ -96,6 +300,7 @@ export function ActionBar({
|
||||
|
||||
const handleAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (browseMode) return;
|
||||
if (queued) {
|
||||
confirmQueued();
|
||||
return;
|
||||
@@ -115,9 +320,11 @@ export function ActionBar({
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
const handleBrowseSearch = (value: string) => {
|
||||
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||
};
|
||||
|
||||
const handleAddSearch = (value: string) => {
|
||||
let newSuggestions: SearchResult[] = [];
|
||||
let newPcMatches: PlayerCharacter[] = [];
|
||||
if (value.length >= 2) {
|
||||
@@ -146,6 +353,16 @@ export function ActionBar({
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
@@ -178,74 +395,50 @@ export function ActionBar({
|
||||
e.preventDefault();
|
||||
handleEnter();
|
||||
} else if (e.key === "Escape") {
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeViewer();
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
return;
|
||||
}
|
||||
if (viewerResults.length === 0) return;
|
||||
|
||||
if (suggestions.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0));
|
||||
setSuggestionIndex((i) => (i < suggestions.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) {
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleViewerSelect(viewerResults[viewerIndex]);
|
||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
// 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]);
|
||||
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">
|
||||
@@ -253,163 +446,85 @@ export function ActionBar({
|
||||
onSubmit={handleAdd}
|
||||
className="relative flex flex-1 items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="+ Add combatants"
|
||||
className="max-w-xs"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{hasSuggestions && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||
<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"
|
||||
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={() => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}}
|
||||
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"
|
||||
}
|
||||
>
|
||||
<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>
|
||||
{browseMode ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</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);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}}
|
||||
>
|
||||
{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={() => handleClickSuggestion(result)}
|
||||
onMouseEnter={() => setSuggestionIndex(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) {
|
||||
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>
|
||||
)}
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{!browseMode && hasSuggestions && (
|
||||
<AddModeSuggestions
|
||||
nameInput={nameInput}
|
||||
suggestions={suggestions}
|
||||
pcMatches={pcMatches}
|
||||
suggestionIndex={suggestionIndex}
|
||||
queued={queued}
|
||||
onDismiss={clearInput}
|
||||
onClickSuggestion={handleClickSuggestion}
|
||||
onSetSuggestionIndex={setSuggestionIndex}
|
||||
onSetQueued={setQueued}
|
||||
onConfirmQueued={confirmQueued}
|
||||
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{nameInput.length >= 2 && !hasSuggestions && (
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -437,96 +552,25 @@ export function ActionBar({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
<div className="flex items-center gap-0">
|
||||
{onManagePlayers && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onManagePlayers}
|
||||
title="Player characters"
|
||||
aria-label="Player characters"
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{bestiaryLoaded && onViewStatBlock && (
|
||||
<div ref={viewerRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
|
||||
title="Browse stat blocks"
|
||||
aria-label="Browse stat blocks"
|
||||
>
|
||||
<Eye className="h-5 w-5" />
|
||||
</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>
|
||||
)}
|
||||
{bestiaryLoaded && onBulkImport && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onBulkImport}
|
||||
disabled={bulkImportDisabled}
|
||||
title="Bulk import"
|
||||
aria-label="Bulk import"
|
||||
>
|
||||
<Import className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<Button type="submit" size="sm">
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { Library, StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
|
||||
@@ -9,8 +8,6 @@ interface TurnNavigationProps {
|
||||
onAdvanceTurn: () => void;
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
onRollAllInitiative: () => void;
|
||||
onOpenSourceManager: () => void;
|
||||
}
|
||||
|
||||
export function TurnNavigation({
|
||||
@@ -18,8 +15,6 @@ export function TurnNavigation({
|
||||
onAdvanceTurn,
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
onRollAllInitiative,
|
||||
onOpenSourceManager,
|
||||
}: TurnNavigationProps) {
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -49,35 +44,13 @@ export function TurnNavigation({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onOpenSourceManager}
|
||||
title="Manage cached sources"
|
||||
aria-label="Manage cached sources"
|
||||
>
|
||||
<Library className="h-5 w-5" />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={onAdvanceTurn}
|
||||
|
||||
72
apps/web/src/components/ui/overflow-menu.tsx
Normal file
72
apps/web/src/components/ui/overflow-menu.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./button";
|
||||
|
||||
export interface OverflowMenuItem {
|
||||
readonly icon: ReactNode;
|
||||
readonly label: string;
|
||||
readonly onClick: () => void;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
interface OverflowMenuProps {
|
||||
readonly items: readonly OverflowMenuItem[];
|
||||
}
|
||||
|
||||
export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<EllipsisVertical className="h-5 w-5" />
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-muted/20 disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user