Implement the 019-combatant-row-declutter feature that replaces always-visible HP controls and AC/MaxHP inputs with compact click-to-edit and click-to-adjust patterns in the encounter tracker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-06 15:07:04 +01:00
parent e59fd83292
commit 0c0da9b90e
11 changed files with 723 additions and 207 deletions

View File

@@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
import { QuickHpInput } from "./quick-hp-input";
import { HpAdjustPopover } from "./hp-adjust-popover";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -91,144 +91,169 @@ function EditableName({
);
}
function MaxHpInput({
function MaxHpDisplay({
maxHp,
onCommit,
}: {
maxHp: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const prev = useRef(maxHp);
if (maxHp !== prev.current) {
prev.current = maxHp;
setDraft(maxHp?.toString() ?? "");
}
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
return;
}
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 1) {
onCommit(n);
} else {
setDraft(maxHp?.toString() ?? "");
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 1) {
onCommit(n);
}
}
}, [draft, maxHp, onCommit]);
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 (
<Input
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();
}}
/>
<button
type="button"
onClick={startEditing}
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-primary"
>
{maxHp ?? "Max"}
</button>
);
}
function CurrentHpInput({
function ClickableHp({
currentHp,
maxHp,
onCommit,
className,
onAdjust,
}: {
currentHp: number | undefined;
maxHp: number | undefined;
onCommit: (value: number) => void;
className?: string;
onAdjust: (delta: number) => void;
}) {
const [draft, setDraft] = useState(currentHp?.toString() ?? "");
const prev = useRef(currentHp);
const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp);
if (currentHp !== prev.current) {
prev.current = currentHp;
setDraft(currentHp?.toString() ?? "");
if (maxHp === undefined) {
return (
<span className="inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground">
--
</span>
);
}
const commit = useCallback(() => {
if (currentHp === undefined) return;
if (draft === "") {
setDraft(currentHp.toString());
return;
}
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n)) {
onCommit(n);
} else {
setDraft(currentHp.toString());
}
}, [draft, currentHp, onCommit]);
return (
<Input
type="text"
inputMode="numeric"
value={draft}
placeholder="HP"
disabled={maxHp === undefined}
className={cn("h-7 w-[7ch] text-center text-sm tabular-nums", className)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
<div className="relative">
<button
type="button"
onClick={() => setPopoverOpen(true)}
className={cn(
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-primary",
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
)}
>
{currentHp}
</button>
{popoverOpen && (
<HpAdjustPopover
onAdjust={onAdjust}
onClose={() => setPopoverOpen(false)}
/>
)}
</div>
);
}
function AcInput({
function AcDisplay({
ac,
onCommit,
}: {
ac: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? "");
const prev = useRef(ac);
if (ac !== prev.current) {
prev.current = ac;
setDraft(ac?.toString() ?? "");
}
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
return;
}
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 0) {
onCommit(n);
} else {
setDraft(ac?.toString() ?? "");
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 0) {
onCommit(n);
}
}
}, [draft, ac, onCommit]);
setEditing(false);
}, [draft, onCommit]);
const startEditing = useCallback(() => {
setDraft(ac?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [ac]);
if (editing) {
return (
<div className="flex items-center gap-1">
<Shield size={14} className="text-muted-foreground" />
<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);
}}
/>
</div>
);
}
return (
<div className="flex items-center gap-1">
<Shield size={14} className="text-muted-foreground" />
<Input
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();
}}
/>
</div>
<button
type="button"
onClick={startEditing}
className="flex items-center gap-1 text-sm tabular-nums text-muted-foreground transition-colors hover:text-primary"
>
<Shield size={14} />
{ac !== undefined ? <span>{ac}</span> : null}
</button>
);
}
@@ -329,32 +354,21 @@ export function CombatantRow({
<EditableName name={name} combatantId={id} onRename={onRename} />
{/* AC */}
<AcInput ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
{/* HP */}
<div className="flex items-center gap-1">
<CurrentHpInput
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
className={cn(
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
)}
onCommit={(value) => {
if (currentHp === undefined) return;
const delta = value - currentHp;
if (delta !== 0) onAdjustHp(id, delta);
}}
onAdjust={(delta) => onAdjustHp(id, delta)}
/>
{maxHp !== undefined && (
<span className="text-sm tabular-nums text-muted-foreground">
/
</span>
)}
<MaxHpInput maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
{maxHp !== undefined && (
<QuickHpInput combatantId={id} onAdjustHp={onAdjustHp} />
)}
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
</div>
{/* Actions */}