Files
initiative/apps/web/src/components/combatant-row.tsx
Lukas c4079c384b
Some checks failed
CI / check (push) Failing after 17s
CI / build-image (push) Has been skipped
Fix initiative input clipping inside container
Widen initiative grid column from 3rem to 3.5rem and use w-full
on the editing input so it fits within the rounded background
container without overflowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:26:39 +01:00

635 lines
16 KiB
TypeScript

import {
type CombatantId,
type ConditionId,
type CreatureId,
deriveHpStatus,
type PlayerIcon,
type RollMode,
} from "@initiative/domain";
import { Brain, Pencil, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useLongPress } from "../hooks/use-long-press.js";
import { cn } from "../lib/utils.js";
import { AcShield } from "./ac-shield.js";
import { ConditionPicker } from "./condition-picker.js";
import { ConditionTags } from "./condition-tags.js";
import { D20Icon } from "./d20-icon.js";
import { HpAdjustPopover } from "./hp-adjust-popover.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { ConfirmButton } from "./ui/confirm-button.js";
import { Input } from "./ui/input.js";
interface Combatant {
readonly id: CombatantId;
readonly name: string;
readonly initiative?: number;
readonly maxHp?: number;
readonly currentHp?: number;
readonly tempHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly color?: string;
readonly icon?: string;
readonly creatureId?: CreatureId;
}
interface CombatantRowProps {
combatant: Combatant;
isActive: boolean;
}
function EditableName({
name,
combatantId,
onRename,
color,
onToggleStatBlock,
}: Readonly<{
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
color?: string;
onToggleStatBlock?: () => void;
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
const trimmed = draft.trim();
if (trimmed !== "" && trimmed !== name) {
onRename(combatantId, trimmed);
}
setEditing(false);
}, [draft, name, combatantId, onRename]);
const startEditing = useCallback(() => {
setDraft(name);
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [name]);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
value={draft}
className="h-7 max-w-48 text-sm"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<>
<button
type="button"
onClick={onToggleStatBlock}
disabled={!onToggleStatBlock}
className={cn(
"truncate text-left text-sm transition-colors",
onToggleStatBlock
? "cursor-pointer text-foreground hover:text-hover-neutral"
: "cursor-default text-foreground",
)}
style={color ? { color } : undefined}
>
{name}
</button>
<button
type="button"
onClick={startEditing}
title="Rename"
aria-label="Rename"
className="inline-flex shrink-0 items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100"
>
<Pencil size={14} />
</button>
</>
);
}
function MaxHpDisplay({
maxHp,
onCommit,
}: Readonly<{
maxHp: number | undefined;
onCommit: (value: number | undefined) => void;
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
} else {
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 1) {
onCommit(n);
}
}
setEditing(false);
}, [draft, onCommit]);
const startEditing = useCallback(() => {
setDraft(maxHp?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [maxHp]);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
placeholder="Max"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<button
type="button"
onClick={startEditing}
className={cn(
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
maxHp === undefined
? "text-muted-foreground text-sm"
: "text-muted-foreground text-xs",
)}
>
{maxHp ?? "Max"}
</button>
);
}
function ClickableHp({
currentHp,
maxHp,
tempHp,
onAdjust,
onSetTempHp,
}: Readonly<{
currentHp: number | undefined;
maxHp: number | undefined;
tempHp: number | undefined;
onAdjust: (delta: number) => void;
onSetTempHp: (value: number) => void;
}>) {
const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp);
if (maxHp === undefined) {
return null;
}
return (
<div className="relative flex items-center">
<button
type="button"
onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
className={cn(
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
)}
>
{currentHp}
</button>
{!!tempHp && (
<span className="font-medium text-cyan-400 text-sm leading-7">
+{tempHp}
</span>
)}
{!!popoverOpen && (
<HpAdjustPopover
onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={() => setPopoverOpen(false)}
/>
)}
</div>
);
}
function AcDisplay({
ac,
onCommit,
}: Readonly<{
ac: number | undefined;
onCommit: (value: number | undefined) => void;
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
} else {
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 0) {
onCommit(n);
}
}
setEditing(false);
}, [draft, onCommit]);
const startEditing = useCallback(() => {
setDraft(ac?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [ac]);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
placeholder="AC"
className="h-7 w-[6ch] text-center text-sm tabular-nums"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return <AcShield value={ac} onClick={startEditing} />;
}
function InitiativeDisplay({
initiative,
combatantId,
dimmed,
onSetInitiative,
onRollInitiative,
}: Readonly<{
initiative: number | undefined;
combatantId: CombatantId;
dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
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],
),
);
const commit = useCallback(() => {
if (draft === "") {
onSetInitiative(combatantId, undefined);
} else {
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n)) {
onSetInitiative(combatantId, n);
}
}
setEditing(false);
}, [draft, combatantId, onSetInitiative]);
const startEditing = useCallback(() => {
setDraft(initiative?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [initiative]);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
placeholder="--"
className={cn(
"h-7 w-full text-center text-sm tabular-nums",
dimmed && "opacity-50",
)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
// Empty + bestiary creature -> d20 roll button
if (initiative === undefined && onRollInitiative) {
return (
<>
<button
type="button"
onClick={() => onRollInitiative(combatantId)}
onContextMenu={(e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY);
}}
{...longPress}
className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
dimmed && "opacity-50",
)}
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-7 w-7" />
</button>
{!!menuPos && (
<RollModeMenu
position={menuPos}
onSelect={(mode) => onRollInitiative(combatantId, mode)}
onClose={() => setMenuPos(null)}
/>
)}
</>
);
}
// Has value -> bold number, click to edit
// Empty + manual -> "--" placeholder, click to edit
return (
<button
type="button"
onClick={startEditing}
className={cn(
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
initiative === undefined
? "text-muted-foreground hover:text-hover-neutral"
: "font-medium text-foreground hover:text-hover-neutral",
dimmed && "opacity-50",
)}
>
{initiative ?? "--"}
</button>
);
}
function rowBorderClass(
isActive: boolean,
isConcentrating: boolean | undefined,
): string {
if (isActive && isConcentrating)
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
if (isActive)
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
if (isConcentrating)
return "border border-l-2 border-transparent border-l-purple-400";
return "border border-l-2 border-transparent";
}
function concentrationIconClass(
isConcentrating: boolean | undefined,
dimmed: boolean,
): string {
if (!isConcentrating)
return "opacity-0 group-hover:opacity-50 text-muted-foreground";
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
}
export function CombatantRow({
ref,
combatant,
isActive,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const {
editCombatant,
setInitiative,
removeCombatant,
setHp,
adjustHp,
setTempHp,
setAc,
toggleCondition,
toggleConcentration,
} = useEncounterContext();
const { selectedCreatureId, showCreature, toggleCollapse } =
useSidePanelContext();
const { handleRollInitiative } = useInitiativeRollsContext();
// Derive what was previously conditional props
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
const { creatureId } = combatant;
const hasStatBlock = !!creatureId;
const onToggleStatBlock = hasStatBlock
? () => {
if (isStatBlockOpen) {
toggleCollapse();
} else {
showCreature(creatureId);
}
}
: undefined;
const onRollInitiative = combatant.creatureId
? handleRollInitiative
: undefined;
const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp);
const dimmed = status === "unconscious";
const [pickerOpen, setPickerOpen] = useState(false);
const conditionAnchorRef = useRef<HTMLDivElement>(null);
const prevHpRef = useRef(currentHp);
const prevTempHpRef = useRef(combatant.tempHp);
const [isPulsing, setIsPulsing] = useState(false);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
const prevHp = prevHpRef.current;
const prevTempHp = prevTempHpRef.current;
prevHpRef.current = currentHp;
prevTempHpRef.current = combatant.tempHp;
const realHpDropped =
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
const tempHpDropped =
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
setIsPulsing(true);
clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
}
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
useEffect(() => {
if (!combatant.isConcentrating) {
clearTimeout(pulseTimerRef.current);
setIsPulsing(false);
}
}, [combatant.isConcentrating]);
const pcColor = combatant.color
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
: undefined;
return (
<div
ref={ref}
className={cn(
"group rounded-lg pr-3 transition-colors",
rowBorderClass(isActive, combatant.isConcentrating),
isPulsing && "animate-concentration-pulse",
)}
>
<div className="grid grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] items-center gap-3 py-2">
{/* Concentration */}
<button
type="button"
onClick={() => toggleConcentration(id)}
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
concentrationIconClass(combatant.isConcentrating, dimmed),
)}
>
<Brain size={16} />
</button>
{/* Initiative */}
<div className="rounded-md bg-muted/30 px-1">
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={setInitiative}
onRollInitiative={onRollInitiative}
/>
</div>
{/* AC */}
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
</div>
{/* Name + Conditions */}
<div
className={cn(
"relative flex min-w-0 flex-wrap items-center gap-1",
dimmed && "opacity-50",
)}
>
{!!combatant.icon &&
!!combatant.color &&
(() => {
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
const iconColor =
PLAYER_COLOR_HEX[
combatant.color as keyof typeof PLAYER_COLOR_HEX
];
return PcIcon ? (
<PcIcon
size={16}
style={{ color: iconColor }}
className="shrink-0"
/>
) : null;
})()}
<EditableName
name={name}
combatantId={id}
onRename={editCombatant}
color={pcColor}
onToggleStatBlock={onToggleStatBlock}
/>
<div ref={conditionAnchorRef}>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => toggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
</div>
{!!pickerOpen && (
<ConditionPicker
anchorRef={conditionAnchorRef}
activeConditions={combatant.conditions}
onToggle={(conditionId) => toggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
{/* HP */}
<div
className={cn(
"flex items-center rounded-md tabular-nums",
maxHp === undefined
? ""
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
dimmed && "opacity-50",
)}
>
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
tempHp={combatant.tempHp}
onAdjust={(delta) => adjustHp(id, delta)}
onSetTempHp={(value) => setTempHp(id, value)}
/>
{maxHp !== undefined && (
<span className="text-muted-foreground/50 text-xs">/</span>
)}
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
</div>
{/* Actions */}
<ConfirmButton
icon={<X size={16} />}
label="Remove combatant"
onConfirm={() => removeCombatant(id)}
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
/>
</div>
</div>
);
}