Refactor combatant row: single-click rename, book icon for stat blocks

Replace 250ms click timer and double-click detection with immediate
single-click rename for all combatant types. Add a BookOpen icon before
the name on bestiary rows as the dedicated stat block trigger. Remove
auto-show stat block on turn advance. Update specs to match: consistent
collapse/expand terminology, book icon requirements, no row-click stat
block behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-15 16:47:02 +01:00
parent e68145319f
commit 46b444caba
7 changed files with 77 additions and 177 deletions

View File

@@ -4,7 +4,7 @@ import {
deriveHpStatus,
type PlayerIcon,
} from "@initiative/domain";
import { Brain, X } from "lucide-react";
import { BookOpen, Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { AcShield } from "./ac-shield";
@@ -48,21 +48,16 @@ function EditableName({
name,
combatantId,
onRename,
onShowStatBlock,
color,
}: Readonly<{
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => void;
onShowStatBlock?: () => void;
color?: string;
}>) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const longPressTriggeredRef = useRef(false);
const commit = useCallback(() => {
const trimmed = draft.trim();
@@ -78,46 +73,6 @@ function EditableName({
requestAnimationFrame(() => inputRef.current?.select());
}, [name]);
useEffect(() => {
return () => {
clearTimeout(clickTimerRef.current);
clearTimeout(longPressTimerRef.current);
};
}, []);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = undefined;
startEditing();
} else {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = undefined;
onShowStatBlock?.();
}, 250);
}
},
[startEditing, onShowStatBlock],
);
const handleTouchStart = useCallback(() => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
startEditing();
}, 500);
}, [startEditing]);
const cancelLongPress = useCallback(() => {
clearTimeout(longPressTimerRef.current);
}, []);
if (editing) {
return (
<Input
@@ -138,11 +93,7 @@ function EditableName({
return (
<button
type="button"
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onTouchMove={cancelLongPress}
onClick={startEditing}
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
style={color ? { color } : undefined}
>
@@ -428,17 +379,6 @@ function concentrationIconClass(
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
}
function activateOnKeyDown(
handler: () => void,
): (e: { key: string; preventDefault: () => void }) => void {
return (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handler();
}
};
}
export function CombatantRow({
ref,
combatant,
@@ -491,31 +431,19 @@ export function CombatantRow({
: undefined;
return (
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
<div
ref={ref}
role={onShowStatBlock ? "button" : undefined}
tabIndex={onShowStatBlock ? 0 : undefined}
className={cn(
"group rounded-md pr-3 transition-colors",
rowBorderClass(isActive, combatant.isConcentrating),
isPulsing && "animate-concentration-pulse",
onShowStatBlock && "cursor-pointer",
)}
onClick={onShowStatBlock}
onKeyDown={
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
}
>
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
{/* Concentration */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleConcentration(id);
}}
onClick={() => onToggleConcentration(id)}
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
@@ -527,20 +455,13 @@ export function CombatantRow({
</button>
{/* Initiative */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onRollInitiative={onRollInitiative}
/>
</div>
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onRollInitiative={onRollInitiative}
/>
{/* Name + Conditions */}
<div
@@ -549,6 +470,17 @@ export function CombatantRow({
dimmed && "opacity-50",
)}
>
{!!onShowStatBlock && (
<button
type="button"
onClick={onShowStatBlock}
title="View stat block"
aria-label="View stat block"
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral"
>
<BookOpen size={14} />
</button>
)}
{!!combatant.icon &&
!!combatant.color &&
(() => {
@@ -569,7 +501,6 @@ export function CombatantRow({
name={name}
combatantId={id}
onRename={onRename}
onShowStatBlock={onShowStatBlock}
color={pcColor}
/>
<ConditionTags
@@ -587,24 +518,12 @@ export function CombatantRow({
</div>
{/* AC */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-1">
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}

View File

@@ -352,7 +352,7 @@ export function StatBlockPanel({
);
}
if (panelRole === "pinned") return null;
if (panelRole === "pinned" || isCollapsed) return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
}