Inline editable fields (init/hp/maxhp/ac) with editing overlay

Participant card fields now editable in place (D-style): transparent bg,
underline on hover/focus, no spinners. Covers Initiative, Current HP, Max HP,
and AC badge value.

Click any value to edit. Blur/Enter saves. Escape cancels. Status recomputes
on HP change (conscious/dying/dead/down per ruleset).

Editing overlay: when a field is focused, amber ring highlights the card and
a centered pointer-events-none label shows '✎ Editing {field}'. Label floats
over card middle without blocking input — taps pass through to field.

Handlers: handleInlineCurrentHp (recomputes status), handleInlineMaxHp,
handleInlineAc. Keys prefixed to avoid React key collisions when values match.

Tests: selectors updated to use element id (monsterMaxHp) since inline
aria-labels now match form label queries.
This commit is contained in:
david raistrick
2026-07-08 14:05:55 -04:00
parent 41c1e48874
commit ebd91ca42a
5 changed files with 110 additions and 11 deletions
+2 -1
View File
@@ -11,7 +11,7 @@ also better vert tab layout - labelt friendly
x needs AC for players dude
and quick entry hp
x and quick entry hp
hp do not carry from encounter to ecnounter!!!
^feature to add back to campaign character after encounter?
@@ -20,6 +20,7 @@ maybe campaign toggle in charc section (choosing each end is DM overload will be
hp wont go over max and no temp hp support
refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes?
+105 -7
View File
@@ -1085,6 +1085,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
];
const participants = encounter.participants || [];
const [editingField, setEditingField] = useState(null);
const [editingId, setEditingId] = useState(null);
const blurTimeoutRef = useRef(null);
const handleFieldFocus = (id, label) => {
if (blurTimeoutRef.current) { clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = null; }
setEditingId(id);
setEditingField(label);
};
const handleFieldBlur = () => {
blurTimeoutRef.current = setTimeout(() => { setEditingField(null); setEditingId(null); }, 150);
};
useEffect(() => {
if (participantType === 'character' && selectedCharacterId) {
@@ -1283,6 +1294,53 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
}
};
const handleInlineCurrentHp = async (participantId, value) => {
let n = parseInt(value, 10);
if (isNaN(n)) return;
const p = participants.find(pp => pp.id === participantId);
if (!p || n === p.currentHp) return;
try {
const isGeneric = (encounter.ruleset === 'generic');
let patch;
if (!isGeneric && n < 0) n = 0;
if (isGeneric) {
patch = { currentHp: n, status: n > 0 ? 'conscious' : 'down' };
} else {
patch = { currentHp: n, deathSaveSuccesses: 0, deathSaveFailures: 0 };
if (n > 0) { patch.status = 'conscious'; }
else if (p.type === 'monster') { patch.status = 'dead'; patch.isActive = false; }
else { patch.status = 'dying'; }
}
await updateParticipant(encounter, participantId, patch, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update HP.');
}
};
const handleInlineMaxHp = async (participantId, value) => {
const n = parseInt(value, 10);
if (isNaN(n)) return;
const p = participants.find(pp => pp.id === participantId);
if (!p || n === p.maxHp || n <= 0) return;
try {
await updateParticipant(encounter, participantId, { maxHp: n }, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update Max HP.');
}
};
const handleInlineAc = async (participantId, value) => {
const n = parseInt(value, 10);
if (isNaN(n)) return;
const p = participants.find(pp => pp.id === participantId);
if (!p || n === p.ac) return;
try {
await updateParticipant(encounter, participantId, { ac: n }, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update AC.');
}
};
const handleDeathSaveChange = async (participantId, outcome) => {
if (!db) return;
try {
@@ -1602,6 +1660,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>}
<ul className="space-y-2">
{sortedParticipants.map((p) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
@@ -1628,8 +1687,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)}
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`}
>
{editingId === p.id && (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="px-4 py-1 bg-amber-600 rounded-full text-white text-sm font-bold shadow-lg">
Editing {editingField}
</span>
</span>
)}
<div className="flex-1 flex items-start sm:items-center">
{isDraggable && (
<ChevronsUpDown
@@ -1645,7 +1711,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{p.ac != null && (
<span className="ml-2 inline-flex items-baseline gap-1 px-2 py-0.5 bg-sky-900 border border-sky-500 rounded">
<span className="text-[10px] text-sky-300 font-semibold tracking-wide">AC</span>
<span className="text-sky-100 text-lg font-bold leading-none">{p.ac}</span>
<input
type="number"
defaultValue={p.ac}
key={`ac-${p.ac}`}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'AC'); }}
onBlur={(e) => { if (e.target.value !== String(p.ac)) handleInlineAc(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.ac; }}
className="w-6 bg-transparent text-sky-100 text-lg font-bold leading-none text-center focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`AC for ${p.name}`}
/>
</span>
)}
{isCurrentTurn && !encounter.isPaused && (
@@ -1665,19 +1740,42 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
type="number"
id={`init-${p.id}`}
defaultValue={p.initiative}
key={p.initiative}
key={`init-${p.initiative}`}
min="0"
max="99"
disabled={encounter.isStarted && !encounter.isPaused}
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
onFocus={(e) => e.target.select()}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
className="w-8 px-1 py-0.5 bg-stone-800 border border-stone-700 rounded-md shadow-sm text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-amber-600 focus:border-amber-600 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-6 bg-transparent text-white text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`Initiative for ${p.name}`}
/>
</span>
<span>HP: {p.currentHp}/{p.maxHp}</span>
<span className="inline-flex items-baseline">
<input
type="number"
id={`hp-${p.id}`}
defaultValue={p.currentHp}
key={`hp-${p.currentHp}`}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'HP'); }}
onBlur={(e) => { if (e.target.value !== String(p.currentHp)) handleInlineCurrentHp(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.currentHp; }}
className="w-8 bg-transparent text-white text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`Current HP for ${p.name}`}
/>
<span className="text-stone-500 text-sm">/</span>
<input
type="number"
defaultValue={p.maxHp}
key={`maxhp-${p.maxHp}`}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }}
onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }}
className="w-8 bg-transparent text-stone-400 text-sm text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`Max HP for ${p.name}`}
/>
</span>
</div>
{participantStatus === 'dead' && (
+1 -1
View File
@@ -26,7 +26,7 @@ describe('FEAT-3 reslot: inline init change reorders list', () => {
const addOne = async (name, hp, mod, init) => {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
+1 -1
View File
@@ -16,7 +16,7 @@ function lastParticipantsUpdate() {
async function addOne(form, name, hp, mod, init) {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(mod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(hp) } });
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(hp) } });
fireEvent.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
+1 -1
View File
@@ -74,7 +74,7 @@ export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } });
fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } });
fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } });
fireEvent.change(document.getElementById('monsterMaxHp'), { target: { value: String(maxHp) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
const { getCalls } = require('../__mocks__/firebase/_mock-db');
await waitFor(() => {