PF2e uses action-based spell sustaining, not damage-triggered concentration checks. The Brain icon, purple border accent, and damage pulse animation are now hidden when PF2e is active, and the freed gutter column is reclaimed for row content. Concentration state is preserved so switching back to D&D restores it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
659 lines
17 KiB
TypeScript
659 lines
17 KiB
TypeScript
import {
|
|
type CombatantId,
|
|
type ConditionEntry,
|
|
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 { useRulesEditionContext } from "../contexts/rules-edition-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 ConditionEntry[];
|
|
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 pointer-coarse:w-auto w-0 shrink-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible 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 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 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 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,
|
|
isPf2e: boolean,
|
|
): string {
|
|
const showConcentration = isConcentrating && !isPf2e;
|
|
if (isActive && showConcentration)
|
|
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 (showConcentration)
|
|
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 pointer-coarse:opacity-50 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,
|
|
setConditionValue,
|
|
decrementCondition,
|
|
toggleConcentration,
|
|
} = useEncounterContext();
|
|
const { selectedCreatureId, showCreature, toggleCollapse } =
|
|
useSidePanelContext();
|
|
const { handleRollInitiative } = useInitiativeRollsContext();
|
|
const { edition } = useRulesEditionContext();
|
|
const isPf2e = edition === "pf2e";
|
|
|
|
// 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 &&
|
|
!isPf2e
|
|
) {
|
|
setIsPulsing(true);
|
|
clearTimeout(pulseTimerRef.current);
|
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
|
}
|
|
}, [currentHp, combatant.tempHp, combatant.isConcentrating, isPf2e]);
|
|
|
|
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, isPf2e),
|
|
isPulsing && "animate-concentration-pulse",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"grid items-center gap-1.5 py-3 sm:gap-3 sm:py-2",
|
|
isPf2e
|
|
? "grid-cols-[3rem_auto_1fr_auto_2rem] pl-3 sm:grid-cols-[3.5rem_auto_1fr_auto_2rem]"
|
|
: "grid-cols-[2rem_3rem_auto_1fr_auto_2rem] sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem]",
|
|
)}
|
|
>
|
|
{/* Concentration — hidden in PF2e mode */}
|
|
{!isPf2e && (
|
|
<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)}
|
|
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
|
/>
|
|
</div>
|
|
{!!pickerOpen && (
|
|
<ConditionPicker
|
|
anchorRef={conditionAnchorRef}
|
|
activeConditions={combatant.conditions}
|
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
|
onSetValue={(conditionId, value) =>
|
|
setConditionValue(id, conditionId, value)
|
|
}
|
|
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>
|
|
);
|
|
}
|