src/components/unit-number-input.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { useCoarsePointer } from "@/hooks/useCoarsePointer";
import { decimalEntryMessage, parseDecimal } from "@/lib/decimal-entry";
import {
displayBound,
displayNumber,
isIntegerDimension,
toCommittedMetric,
type EntryDimension,
type UnitSystem,
withoutFloatNoise,
} from "@/lib/units";
export interface UnitNumberInputProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "min" | "max" | "type" | "placeholder" | "step"
> {
/** Which measurement this box holds, which is what decides its unit and its parsing. */
dimension: EntryDimension;
/** The system the diver is typing in. */
units: UnitSystem;
/** The **metric** value held in form state, or this field's cleared sentinel. */
value: number | null | "" | undefined;
/** Emits the **metric** value, or `emptyValue` when the box is cleared. */
onChange: (value: number | null | "") => void;
/**
* What "cleared" is spelled as for this field.
*
* Both sentinels are real and in use: every dive and gear scalar clears to `null`,
* while the two mixture pressures clear to `""` (see DECISIONS.md - the rule is
* anti-`undefined`, not `""`-everywhere, because react-hook-form re-displays a
* field's default the moment its value resolves to `undefined`). A component that
* only spoke one of them would silently change what the other's form submits.
*/
emptyValue?: null | "";
/** Native lower bound, declared in **metric** and converted for display. */
min?: number;
/** Native upper bound, declared in **metric** and converted for display. */
max?: number;
/** A **metric** example value; rendered as "e.g. …" in whichever system is on. */
placeholderValue?: number;
}
/**
* A number box that shows and accepts one measurement in the diver's own units,
* while the value it reads and writes stays metric.
*
* This is one of the two edges where units convert at all - the formatters in
* `lib/units.ts` are the other. Everything upstream of this box is metric: the form
* state, the Zod schema validating it, the live MOD/END/EAD maths reading it, the
* prefill that seeds it and the request that submits it. None of them know which
* system the diver picked, which is the point: a conversion can only be forgotten in
* a place that has to remember it.
*
* Renders the `<input>` and nothing else, so the callers' `<div className="relative">`
* icon wrappers and their `FormControl` placement inside them are untouched - and so
* the id and `aria-*` that `FormControl` forwards through render composition land on a labelable
* element rather than on a wrapper (DECISIONS.md, "`FormControl` only labels what it
* can reach").
*
* On touch, a box that cannot go below zero is text with a keypad rather than a
* number input - see "A finger types numbers on a keypad, with either separator"
* in DECISIONS.md. Its bounds are then checked here and reported as the box's own
* validity, so the form refuses a save exactly as a number input's would.
*/
export const UnitNumberInput = React.forwardRef<
HTMLInputElement,
UnitNumberInputProps
>(function UnitNumberInput(
{
dimension,
units,
value,
onChange,
emptyValue = null,
min,
max,
placeholderValue,
onBlur,
...props
},
ref,
) {
const keypad = useCoarsePointer() && min != null && min >= 0;
const inputRef = React.useRef<HTMLInputElement | null>(null);
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
inputRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
// What the diver has actually typed, while they are typing it.
//
// Without it, every keystroke would put the *committed* value straight back under
// the cursor: 50 ft commits 15 m, and 15 m is 49 ft, so the "0" of "50" would turn
// into a "9" as it was typed. The same happens in metric the moment entry rounding
// bites - "30.526" would become "30.53" mid-word. `null` means "nothing in
// progress, show the value", and blur is where the correction belongs.
//
// Nothing watching `value` may reset this, deliberately - blur is where that
// correction belongs. An effect resetting the draft whenever `value` changed
// would fire on every keystroke - this box commits on each one, so it causes
// most of the `value` changes it would be watching - and that is precisely the
// reformat-under-the-cursor it exists to stop. Telling a self-made change from
// an external one needs the last committed value tracked as well, and nothing
// needs it yet: every path that writes one of these fields programmatically
// (loading a gear set, applying a parsed file, seeding the edit form) runs from
// a click or a mount, both of which blur the box first.
//
// The `units` prop is the one other reset, and it is a different question - see
// just below.
const [draft, setDraft] = React.useState<string | null>(null);
// The one exception, and it is not `value` changing but `units` changing.
//
// A units flip has to discard the draft: what is under the cursor was typed in
// the old system, and leaving it there shows a psi number under a bar label,
// with the next keystroke committing it through the new units. Blur cannot be
// relied on to do this - on macOS Safari and Firefox, clicking a `<button>`
// does not move focus, so the box holding the draft is never blurred. jsdom's
// `userEvent.click` *does* focus, which is why this needs its own test rather
// than a click-the-toggle one: that would pass with this guard deleted.
//
// Adjusting state during render, comparing against the previous prop, rather
// than in an effect: React documents this shape for exactly this case, it
// re-renders before anything is painted, and the effect form is a lint error
// here (`react-hooks/set-state-in-effect`).
const [previousUnits, setPreviousUnits] = React.useState(units);
if (previousUnits !== units) {
setPreviousUnits(units);
setDraft(null);
}
const isEmpty = value == null || value === "";
const displayed =
draft ??
(isEmpty
? ""
: // Metric is not rounded: a value imported at three decimals stays
// legible as the three decimals it is. Only an exporter's float noise is
// dropped ("21.8000011" shows as 21.8), and only from the box - the form
// keeps the figure it was given until the diver edits it. Imperial is a
// converted number and has to be rounded to be typeable at all.
units === "imperial"
? displayNumber(value as number, dimension, units)
: withoutFloatNoise(value as number));
const placeholder =
placeholderValue == null
? undefined
: `e.g. ${
units === "imperial"
? displayNumber(placeholderValue, dimension, units)
: placeholderValue
}`;
const displayMin =
min == null ? undefined : displayBound(min, dimension, units, "min");
const displayMax =
max == null ? undefined : displayBound(max, dimension, units, "max");
const validity = keypad
? decimalEntryMessage(displayed, displayMin, displayMax)
: "";
React.useLayoutEffect(() => {
inputRef.current?.setCustomValidity(validity);
}, [validity]);
const commit = (raw: string) => {
setDraft(raw);
const parsed = parseDecimal(raw);
const typed = isIntegerDimension(dimension) ? Math.trunc(parsed) : parsed;
if (Number.isNaN(typed)) {
onChange(emptyValue);
return;
}
onChange(toCommittedMetric(typed, dimension, units));
};
if (keypad) {
return (
<Input
ref={setRefs}
type="text"
inputMode={isIntegerDimension(dimension) ? "numeric" : "decimal"}
autoComplete="off"
placeholder={placeholder}
value={displayed}
onChange={(e) => commit(e.target.value)}
onBlur={(e) => {
setDraft(null);
onBlur?.(e);
}}
{...props}
/>
);
}
return (
<Input
ref={setRefs}
type="number"
// Derived from the dimension, never passed in, because `step` is a native
// *constraint* and not just a spinner increment: a value that is not a
// multiple of it makes the box `stepMismatch` and the browser cancels the
// submit. A caller declaring one was declaring a precision on the column
// behind it, and the two disagreed - see DECISIONS.md, "`step` is a claim
// about the column".
//
// Whole units in imperial, because that is what the box displays: a diver
// stepping a depth in feet gets 99, 100, 101, not 99.99. In metric it is
// the same question `isIntegerDimension` already answers for parsing and
// committing just above - whole for an `Integer` column, unconstrained for
// a `Float` one, which is every depth, temperature, pressure and weight
// the importers can write at full precision.
step={units === "imperial" || isIntegerDimension(dimension) ? 1 : "any"}
min={displayMin}
max={displayMax}
placeholder={placeholder}
value={displayed}
onChange={(e) => commit(e.target.value)}
onBlur={(e) => {
// Hand the box back to the formatter, so a committed 29.87 m re-renders
// as the 98 ft it is rather than as whatever got it there.
setDraft(null);
onBlur?.(e);
}}
{...props}
/>
);
});