NumberField draft buffer; surface massive-damage / death-at-0 rules in combat
- NumberField keeps a focus-time string draft so min-bounded fields (point-buy, HP, quantities) no longer snap to the minimum on every keystroke while retyping; the committed value is still always a clamped integer. - Combat tracker now logs the 5e death rules when a downed PC takes damage: instant death from massive damage (leftover >= max HP), otherwise a reminder to mark a death save failure (two on a crit). Surfaced, not auto-applied — the player owns their character's death-save count (consistent with the no-auto-roll design). New tested isMassiveDamageDeath() engine helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** Integer input that never emits NaN — coerces blanks/garbage to a fallback. */
|
||||
/**
|
||||
* Integer input that never emits NaN. While focused it keeps a local string draft
|
||||
* so you can transiently clear or partially type a value (e.g. retype a min-bounded
|
||||
* field) without it snapping to the minimum on every keystroke. The committed value
|
||||
* is always a finite, clamped integer.
|
||||
*/
|
||||
export function NumberField({
|
||||
value,
|
||||
onChange,
|
||||
@@ -16,21 +22,43 @@ export function NumberField({
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const clamp = (n: number) => {
|
||||
let v = Math.trunc(n);
|
||||
if (min !== undefined) v = Math.max(min, v);
|
||||
if (max !== undefined) v = Math.min(max, v);
|
||||
return v;
|
||||
};
|
||||
// While focused (draft !== null) show the raw text; otherwise the controlled value.
|
||||
const display = draft !== null ? draft : Number.isFinite(value) ? String(value) : '0';
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
aria-label={ariaLabel}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
value={display}
|
||||
min={min}
|
||||
max={max}
|
||||
onFocus={(e) => setDraft(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const raw = Number(e.target.value);
|
||||
let n = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
||||
if (min !== undefined) n = Math.max(min, n);
|
||||
if (max !== undefined) n = Math.min(max, n);
|
||||
onChange(n);
|
||||
const raw = e.target.value;
|
||||
setDraft(raw);
|
||||
// Emit a clamped value live (so previews update) only when the text parses;
|
||||
// an empty/"-" intermediate leaves the committed value untouched.
|
||||
if (raw.trim() !== '' && raw !== '-') {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) onChange(clamp(n));
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw.trim() !== '' && raw !== '-') {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) onChange(clamp(n));
|
||||
}
|
||||
setDraft(null); // resume showing the controlled value
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-line bg-surface px-2 py-1.5 text-center text-sm text-ink',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
|
||||
|
||||
Reference in New Issue
Block a user