Files
initiative/apps/web/src/components/condition-picker.tsx
T
Lukas 4b1c1deda2
CI / check (push) Successful in 2m39s
CI / build-image (push) Successful in 19s
Add PF2e persistent damage condition tags
Persistent damage displayed as compact tags with damage type icon and
formula (e.g., Flame + "2d6"). Supports fire, bleed, acid, cold,
electricity, poison, and mental types. One instance per type, added via
sub-picker in the condition picker. PF2e only, persists across reload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:09:31 +02:00

277 lines
7.9 KiB
TypeScript

import {
type ConditionEntry,
type ConditionId,
getConditionDescription,
getConditionsForEdition,
type PersistentDamageEntry,
type PersistentDamageType,
} from "@initiative/domain";
import { Check, Flame, Minus, Plus } from "lucide-react";
import React, { useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useClickOutside } from "../hooks/use-click-outside.js";
import { cn } from "../lib/utils";
import {
CONDITION_COLOR_CLASSES,
CONDITION_ICON_MAP,
} from "./condition-styles.js";
import { PersistentDamagePicker } from "./persistent-damage-picker.js";
import { Tooltip } from "./ui/tooltip.js";
interface ConditionPickerProps {
anchorRef: React.RefObject<HTMLElement | null>;
activeConditions: readonly ConditionEntry[] | undefined;
activePersistentDamage?: readonly PersistentDamageEntry[];
onToggle: (conditionId: ConditionId) => void;
onSetValue: (conditionId: ConditionId, value: number) => void;
onAddPersistentDamage?: (
damageType: PersistentDamageType,
formula: string,
) => void;
onClose: () => void;
}
export function ConditionPicker({
anchorRef,
activeConditions,
activePersistentDamage,
onToggle,
onSetValue,
onAddPersistentDamage,
onClose,
}: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{
top: number;
left: number;
maxHeight: number;
} | null>(null);
const [editing, setEditing] = useState<{
id: ConditionId;
value: number;
} | null>(null);
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
useLayoutEffect(() => {
const anchor = anchorRef.current;
const el = ref.current;
if (!anchor || !el) return;
const anchorRect = anchor.getBoundingClientRect();
const menuHeight = el.scrollHeight;
const pad = 8;
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
const spaceAbove = anchorRect.top - pad;
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
const top = openBelow
? anchorRect.bottom + 4
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
setPos({ top, left: anchorRect.left, maxHeight });
}, [anchorRef]);
useClickOutside(ref, onClose);
const { edition } = useRulesEditionContext();
const conditions = getConditionsForEdition(edition);
const activeMap = new Map(
(activeConditions ?? []).map((e) => [e.id, e.value]),
);
const showPersistentDamageEntry =
edition === "pf2e" && !!onAddPersistentDamage;
const persistentDamageInsertIndex = showPersistentDamageEntry
? conditions.findIndex(
(d) => d.label.localeCompare("Persistent Damage") > 0,
)
: -1;
const persistentDamageEntry = showPersistentDamageEntry ? (
<React.Fragment key="persistent-damage">
<div
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
showPersistentDamage && "bg-card/50",
)}
>
<button
type="button"
className="flex flex-1 items-center gap-2"
onClick={() => setShowPersistentDamage((prev) => !prev)}
>
<Flame
size={14}
className={
showPersistentDamage ? "text-orange-400" : "text-muted-foreground"
}
/>
<span
className={
showPersistentDamage ? "text-foreground" : "text-muted-foreground"
}
>
Persistent Damage
</span>
</button>
</div>
{!!showPersistentDamage && (
<PersistentDamagePicker
activeEntries={activePersistentDamage}
onAdd={onAddPersistentDamage}
onClose={() => setShowPersistentDamage(false)}
/>
)}
</React.Fragment>
) : null;
return createPortal(
<div
ref={ref}
className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
style={
pos
? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
: { visibility: "hidden" as const }
}
>
{conditions.map((def, index) => {
const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null;
const isActive = activeMap.has(def.id);
const activeValue = activeMap.get(def.id);
const isEditing = editing?.id === def.id;
const colorClass =
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
const handleClick = () => {
if (def.valued && edition === "pf2e") {
const current = activeMap.get(def.id);
setEditing({
id: def.id,
value: current ?? 1,
});
} else {
onToggle(def.id);
}
};
return (
<React.Fragment key={def.id}>
{index === persistentDamageInsertIndex && persistentDamageEntry}
<Tooltip
content={getConditionDescription(def, edition)}
className="block"
>
<div
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
(isActive || isEditing) && "bg-card/50",
)}
>
<button
type="button"
className="flex flex-1 items-center gap-2"
onClick={handleClick}
>
<Icon
size={14}
className={
isActive || isEditing
? colorClass
: "text-muted-foreground"
}
/>
<span
className={
isActive || isEditing
? "text-foreground"
: "text-muted-foreground"
}
>
{def.label}
</span>
</button>
{isActive && def.valued && edition === "pf2e" && !isEditing && (
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
{activeValue}
</span>
)}
{isEditing && (
<div className="flex items-center gap-0.5">
<button
type="button"
className="rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (editing.value > 1) {
setEditing({
...editing,
value: editing.value - 1,
});
}
}}
>
<Minus className="h-3 w-3" />
</button>
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
{editing.value}
</span>
{(() => {
const atMax =
def.maxValue !== undefined &&
editing.value >= def.maxValue;
return (
<button
type="button"
className={cn(
"rounded p-0.5",
atMax
? "cursor-not-allowed text-muted-foreground opacity-50"
: "text-foreground hover:bg-accent/40",
)}
disabled={atMax}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
if (!atMax) {
setEditing({
...editing,
value: editing.value + 1,
});
}
}}
>
<Plus className="h-3 w-3" />
</button>
);
})()}
<button
type="button"
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onSetValue(editing.id, editing.value);
setEditing(null);
}}
>
<Check className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</Tooltip>
</React.Fragment>
);
})}
{persistentDamageInsertIndex === -1 && persistentDamageEntry}
</div>,
document.body,
);
}