/ proof-of-concept / plate-dynamics-lab-2
Sign in
proof-of-concept / plate-dynamics-lab-2
plate-dynamics-lab-2 / packages / components / src / params / NumberField.tsx
70 lines · 1.8 KBBlameHistoryRaw
1import { useEffect, useState } from "react";
3export interface NumberFieldProps {
4 label: string;
5 value: number;
6 onChange: (v: number) => void;
7 /** Display = value * scale (e.g. 1000 to show metres as millimetres) */
8 scale?: number;
9 unit?: string;
10 step?: number;
11 min?: number;
12 max?: number;
13 integer?: boolean;
14 invalid?: boolean;
15 disabled?: boolean;
16 title?: string;
19/** A numeric input that tolerates partial typing and commits parsed values. */
20export function NumberField(props: NumberFieldProps) {
21 const {
22 label,
23 value,
24 onChange,
25 scale = 1,
26 unit,
27 step,
28 min,
29 max,
30 integer,
31 invalid,
32 disabled,
33 title,
34 } = props;
35 const shown = formatShown(value * scale);
36 const [text, setText] = useState(shown);
37 useEffect(() => {
38 // Sync from outside unless the text already means the same number
39 if (Number(text) * 1 !== value * scale) setText(shown);
40 // eslint-disable-next-line react-hooks/exhaustive-deps
41 }, [value, scale]);
42 return (
43 <div className={`pl-field${disabled ? " pl-disabled" : ""}`} title={title}>
44 <label>{label}</label>
45 <input
46 type="number"
47 className={invalid ? "pl-invalid" : undefined}
48 value={text}
49 step={step ?? (integer ? 1 : "any")}
50 min={min}
51 max={max}
52 disabled={disabled}
53 onChange={(e) => {
54 setText(e.target.value);
55 const n = Number(e.target.value);
56 if (e.target.value.trim() === "" || !Number.isFinite(n)) return;
57 onChange(integer ? Math.round(n) : n / scale);
58 }}
59 onBlur={() => setText(shown)}
60 />
61 <span className="pl-unit">{unit ?? ""}</span>
62 </div>
63 );
66function formatShown(x: number): string {
67 if (!Number.isFinite(x)) return "";
68 const r = Number(x.toPrecision(10));
69 return String(r);
↑↓ move↵openescclose