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

@@ -0,0 +1,109 @@
import { Heart, Sword } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void;
readonly onClose: () => void;
}
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
const [inputValue, setInputValue] = useState("");
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const parsedValue =
inputValue === "" ? null : Number.parseInt(inputValue, 10);
const isValid =
parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0;
const applyDelta = useCallback(
(sign: -1 | 1) => {
if (inputValue === "") return;
const n = Number.parseInt(inputValue, 10);
if (Number.isNaN(n) || n <= 0) return;
onAdjust(sign * n);
onClose();
},
[inputValue, onAdjust, onClose],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (e.shiftKey) {
applyDelta(1);
} else {
applyDelta(-1);
}
} else if (e.key === "Escape") {
onClose();
}
},
[applyDelta, onClose],
);
return (
<div
ref={ref}
className="absolute z-10 mt-1 rounded-md border border-border bg-background p-2 shadow-lg"
>
<div className="flex items-center gap-1">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={inputValue}
placeholder="HP"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => {
const v = e.target.value;
if (v === "" || /^\d+$/.test(v)) {
setInputValue(v);
}
}}
onKeyDown={handleKeyDown}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</Button>
</div>
</div>
);
}