- Hollowcrown: The Rift Between Worlds as its own seed (npm run seed:hollowcrown), built from the campaign book: pools, 58 monsters, 57 NPCs, 28 locations, 72 encounters with rewards, and DM notes. Shared seeding logic moved to prisma/seedCampaign.js. - What players can see: only encounters their own character is in; only the pool entries their character holds (plus starting options while building); only locations their character has been to; no invite code; no gold, inventory or money log of other players' characters. Socket events carry ids only and campaign rooms are members-only. - Encounters have a location; while one is in progress its characters are there, and visits are remembered. - Campaign setting to show players a health bar only for monsters and NPCs. - Initiative is entered as the d20 roll (1-20) and the bonus is added; players can enter their own character's roll. - Number fields enforce their limits when typing, and the API enforces the same limits. - Temp HP shows on HP bars and absorbs damage before real HP. - All packages at version 1.3.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
49 lines
2.0 KiB
TypeScript
49 lines
2.0 KiB
TypeScript
import type { ChangeEvent, FocusEvent, InputHTMLAttributes } from 'react';
|
|
|
|
type NumberInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'min' | 'max' | 'step'> & {
|
|
min?: number;
|
|
max?: number;
|
|
// For a field that always holds a number (a level, a bonus): if it is left
|
|
// empty, it goes back to its starting value instead of being saved blank.
|
|
keepValue?: boolean;
|
|
};
|
|
|
|
// A whole-number field that cannot hold a value outside its limits. A plain
|
|
// <input type="number" max={20}> only limits the arrow buttons; typing 500
|
|
// into it works. This one corrects the text as it is typed: anything above
|
|
// `max` becomes `max`, anything below `min` becomes `min`, decimals are
|
|
// dropped. Works the same whether the field is controlled (`value`) or not
|
|
// (`defaultValue`): the handlers passed in see the corrected value.
|
|
export function NumberInput({ min, max, keepValue, onChange, onBlur, ...rest }: NumberInputProps) {
|
|
function limit(input: HTMLInputElement, leaving: boolean) {
|
|
const raw = input.value;
|
|
if (raw === '' || raw === '-') {
|
|
// Nothing typed yet, or the start of a negative number.
|
|
if (leaving) input.value = keepValue ? String(rest.defaultValue ?? rest.value ?? min ?? 0) : '';
|
|
return;
|
|
}
|
|
let value = Math.trunc(Number(raw));
|
|
if (Number.isNaN(value)) {
|
|
input.value = '';
|
|
return;
|
|
}
|
|
if (max !== undefined && value > max) value = max;
|
|
// While typing, a value below a minimum above 1 may just be unfinished
|
|
// ("1" on the way to "15"), so that case waits until the field is left.
|
|
if (min !== undefined && value < min && (leaving || min <= 1)) value = min;
|
|
if (String(value) !== raw) input.value = String(value);
|
|
}
|
|
|
|
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
|
limit(e.target, false);
|
|
onChange?.(e);
|
|
}
|
|
|
|
function handleBlur(e: FocusEvent<HTMLInputElement>) {
|
|
limit(e.target, true);
|
|
onBlur?.(e);
|
|
}
|
|
|
|
return <input {...rest} type="number" inputMode="numeric" step={1} min={min} max={max} onChange={handleChange} onBlur={handleBlur} />;
|
|
}
|