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 x needs AC for players dude
and quick entry hp x and quick entry hp
hp do not carry from encounter to ecnounter!!! hp do not carry from encounter to ecnounter!!!
^feature to add back to campaign character after encounter? ^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 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 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(() => { useEffect(() => {
if (participantType === 'character' && selectedCharacterId) { 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) => { const handleDeathSaveChange = async (participantId, outcome) => {
if (!db) return; if (!db) return;
try { 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>} {participants.length === 0 && <p className="text-sm text-stone-400">No participants added yet.</p>}
<ul className="space-y-2"> <ul className="space-y-2">
{sortedParticipants.map((p) => { {sortedParticipants.map((p) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId; const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
@@ -1628,8 +1687,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onDragOver={isDraggable ? handleDragOver : undefined} onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined} onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)} 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"> <div className="flex-1 flex items-start sm:items-center">
{isDraggable && ( {isDraggable && (
<ChevronsUpDown <ChevronsUpDown
@@ -1645,7 +1711,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{p.ac != null && ( {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="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-[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> </span>
)} )}
{isCurrentTurn && !encounter.isPaused && ( {isCurrentTurn && !encounter.isPaused && (
@@ -1665,19 +1740,42 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
type="number" type="number"
id={`init-${p.id}`} id={`init-${p.id}`}
defaultValue={p.initiative} defaultValue={p.initiative}
key={p.initiative} key={`init-${p.initiative}`}
min="0" min="0"
max="99" max="99"
disabled={encounter.isStarted && !encounter.isPaused} disabled={encounter.isStarted && !encounter.isPaused}
onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }} onChange={(e) => { if (e.target.value.length > 2) e.target.value = e.target.value.slice(0, 2); }}
onFocus={(e) => e.target.select()} onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); }} 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(); }} 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}`} aria-label={`Initiative for ${p.name}`}
/> />
</span> </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> </div>
{participantStatus === 'dead' && ( {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) => { const addOne = async (name, hp, mod, init) => {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } }); 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(/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.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => { await waitFor(() => {
+1 -1
View File
@@ -16,7 +16,7 @@ function lastParticipantsUpdate() {
async function addOne(form, name, hp, mod, init) { async function addOne(form, name, hp, mod, init) {
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } }); 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(/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.change(form.getByPlaceholderText('auto'), { target: { value: String(init) } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => { await waitFor(() => {
+1 -1
View File
@@ -74,7 +74,7 @@ export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) {
const form = within(getParticipantForm()); const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } }); 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(/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 })); fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
const { getCalls } = require('../__mocks__/firebase/_mock-db'); const { getCalls } = require('../__mocks__/firebase/_mock-db');
await waitFor(() => { await waitFor(() => {