Combat: condition dropdown picker with tag chips

- System-aware condition list (5e PHB / pf2e) in src/lib/rules/conditions.ts
- Dropdown replaces free-text input; non-valued conditions add instantly,
  valued ones (Exhaustion, Frightened N, etc.) show a value field, plus Custom
- Already-applied conditions disabled in the list; chips remain click-to-remove
- New e2e covering preset + valued add and tag removal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:44:37 +02:00
parent 866f1e1bf1
commit 0b356adf82
5 changed files with 194 additions and 19 deletions
+100 -18
View File
@@ -1,10 +1,10 @@
import { useState } from 'react';
import type { Campaign, Character, Combatant, Encounter } from '@/lib/schemas';
import type { Campaign, Character, Combatant, Condition, Encounter } from '@/lib/schemas';
import { encountersRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { getSystem } from '@/lib/rules';
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
import { useCharacters } from '@/features/characters/hooks';
import {
addCombatant,
@@ -86,6 +86,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
<CombatantRow
key={c.id}
combatant={c}
system={campaign.system}
isCurrent={isActive && idx === encounter.turnIndex}
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
onDamage={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }))}
@@ -204,6 +205,7 @@ function AddCombatantBar({
function CombatantRow({
combatant: c,
system,
isCurrent,
onChange,
onDamage,
@@ -212,6 +214,7 @@ function CombatantRow({
onRemove,
}: {
combatant: Combatant;
system: SystemId;
isCurrent: boolean;
onChange: (patch: Partial<Combatant>) => void;
onDamage: (amt: number) => void;
@@ -220,7 +223,6 @@ function CombatantRow({
onRemove: () => void;
}) {
const [delta, setDelta] = useState(0);
const [conditionText, setConditionText] = useState('');
const dead = c.hp.current <= 0;
return (
@@ -290,23 +292,103 @@ function CombatantRow({
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}></Button>
</div>
<div className="mt-2 flex items-center gap-2">
<Input
className="h-8 max-w-48 text-xs"
value={conditionText}
placeholder="Add condition (e.g. Prone, Frightened 2)"
onChange={(e) => setConditionText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && conditionText.trim()) {
const m = /^(.*?)(?:\s+(\d+))?$/.exec(conditionText.trim());
const nameOnly = (m?.[1] ?? conditionText).trim();
const value = m?.[2] ? Number(m[2]) : undefined;
onChange({ conditions: [...c.conditions, { name: nameOnly, ...(value ? { value } : {}) }] });
setConditionText('');
}
}}
<div className="mt-2">
<ConditionPicker
system={system}
existing={c.conditions}
onAdd={(cond) => onChange({ conditions: [...c.conditions, cond] })}
/>
</div>
</li>
);
}
function ConditionPicker({
system,
existing,
onAdd,
}: {
system: SystemId;
existing: Condition[];
onAdd: (cond: Condition) => void;
}) {
const [sel, setSel] = useState('');
const [value, setValue] = useState(1);
const [custom, setCustom] = useState('');
const conditions = getConditions(system);
const taken = new Set(existing.map((c) => c.name));
const selected = conditions.find((c) => c.name === sel);
const isCustom = sel === '__custom__';
const reset = () => {
setSel('');
setValue(1);
setCustom('');
};
const addCustom = () => {
if (custom.trim() === '') return;
onAdd({ name: custom.trim() });
reset();
};
const addValued = () => {
if (!selected) return;
onAdd({ name: selected.name, value });
reset();
};
// Non-valued presets add immediately on selection for a snappy "tag" feel.
const onSelect = (name: string) => {
setSel(name);
if (name === '' || name === '__custom__') return;
const def = conditions.find((c) => c.name === name);
if (def && !def.valued) {
onAdd({ name: def.name });
reset();
}
};
return (
<div className="flex flex-wrap items-center gap-1.5">
<Select
className="h-8 w-auto py-0 text-xs"
aria-label="Add condition"
value={sel}
onChange={(e) => onSelect(e.target.value)}
>
<option value="">+ Condition</option>
{conditions.map((cd) => (
<option key={cd.name} value={cd.name} disabled={taken.has(cd.name)}>
{cd.name}
{cd.valued ? ' (#)' : ''}
{taken.has(cd.name) ? ' ✓' : ''}
</option>
))}
<option value="__custom__">Custom</option>
</Select>
{selected?.valued && (
<>
<NumberField className="w-14" value={value} min={1} onChange={setValue} aria-label="Condition value" />
<Button size="sm" variant="primary" onClick={addValued}>Add</Button>
</>
)}
{isCustom && (
<>
<Input
className="h-8 w-40 text-xs"
autoFocus
value={custom}
placeholder="Condition name"
onChange={(e) => setCustom(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addCustom()}
/>
<Button size="sm" variant="primary" onClick={addCustom}>Add</Button>
</>
)}
</div>
);
}