Ctrl K
ComunidadGuías…

src/components/unit-number-input.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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}
    />
  );
});