Ir al contenido
Formularios

NumberField

Un número con botones de −/+, topes de verdad y formato por locale. El valor sale como number, no como texto.

import { NumberField } from "sebs7n-ui/number-field"

Ejemplos

Cantidad de usuarios

`min` y `max` son topes de verdad: el botón se apaga al llegar y las flechas tampoco lo pasan. Inicio y Fin saltan a 1 y a 9. Adentro de un `Field` la etiqueta nombra al input sin que nadie escriba un `id`.

import { Field, FieldDescription, FieldLabel } from "sebs7n-ui/field"
import { NumberField } from "sebs7n-ui/number-field"
import { useState } from "react"

function Usuarios() {
  const [usuarios, setUsuarios] = useState<number | null>(2)
  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <Field name="usuarios">
        <FieldLabel>Usuarios</FieldLabel>
        <NumberField
          max={9}
          min={1}
          onValueChange={setUsuarios}
          value={usuarios}
        />
        <FieldDescription>Hasta 9 por cuenta. Los de solo lectura no cuentan.</FieldDescription>
      </Field>
      <p className="text-callout text-label-secondary">
        {usuarios === 1 ? "1 usuario" : `${usuarios ?? 0} usuarios`}
      </p>
    </div>
  )
}

Precio, con moneda

`format` es el de `Intl.NumberFormat` y `locale` decide el separador: se ve «$ 12.500» pero el valor sigue siendo `12500`, y eso es lo que viaja en el submit. `largeStep` con Shift sube de a mil.

import { Field, FieldDescription, FieldLabel } from "sebs7n-ui/field"
import { NumberField } from "sebs7n-ui/number-field"
import { useState } from "react"

function Precio() {
  const [precio, setPrecio] = useState<number | null>(12_500)
  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <Field name="precio">
        <FieldLabel>Precio de lista</FieldLabel>
        <NumberField
          format={{ style: "currency", currency: "ARS", maximumFractionDigits: 0 }}
          largeStep={1_000}
          locale="es-AR"
          min={0}
          onValueChange={setPrecio}
          step={100}
          value={precio}
        />
        <FieldDescription>Sin IVA. Shift + flecha mueve de a $1.000.</FieldDescription>
      </Field>
      <p className="text-mono-callout text-label-secondary">value: {JSON.stringify(precio)}</p>
    </div>
  )
}

Importe con moneda

`currency` con el código ISO arma el formato de moneda y pone `step="any"` para que los centavos pasen la validación. Si el código viene mal (vacío, «dólar»), cae a número con 2 decimales en vez de romper la pantalla. El locale sale de `LabelsProvider` (`numberField.locale`) si no se pasa.

import { Field, FieldDescription, FieldLabel } from "sebs7n-ui/field"
import { Input } from "sebs7n-ui/input"
import { NumberField } from "sebs7n-ui/number-field"
import { useState } from "react"

function Importe() {
  const [importe, setImporte] = useState<number | null>(1_240.5)
  const [moneda, setMoneda] = useState("USD")
  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <Field name="importe">
        <FieldLabel>Importe de la factura</FieldLabel>
        <NumberField currency={moneda} locale="es-AR" min={0} onValueChange={setImporte} value={importe} />
      </Field>
      <Field name="moneda">
        <FieldLabel>Código de moneda</FieldLabel>
        <Input className="w-32" onChange={(event) => setMoneda(event.target.value)} value={moneda} />
        <FieldDescription>Probá «EUR», «ars» o algo inválido.</FieldDescription>
      </Field>
      <p className="text-mono-callout text-label-secondary">value: {JSON.stringify(importe)}</p>
    </div>
  )
}

Stock: tamaños y solo lectura

`sm` (28px) para una fila de tabla o un panel denso, `md` (36px, el default) como el resto de un formulario, `lg` (40px) para un formulario de alta. `readOnly` deja copiar el número y apaga los steppers; `disabled` lo saca del formulario.

import { Field, FieldLabel } from "sebs7n-ui/field"
import { NumberField } from "sebs7n-ui/number-field"

function Stock() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <Field name="stock-deposito">
        <FieldLabel>Stock en depósito</FieldLabel>
        <NumberField defaultValue={140} min={0} size="sm" step={10} />
      </Field>
      <Field name="stock-minimo">
        <FieldLabel>Stock mínimo antes de reponer</FieldLabel>
        <NumberField defaultValue={20} min={0} size="lg" />
      </Field>
      <Field name="stock-reservado">
        <FieldLabel>Reservado por pedidos abiertos</FieldLabel>
        <NumberField defaultValue={12} readOnly />
      </Field>
    </div>
  )
}

Props

Generadas del TypeScript del paquete. Las propias del componente, más las heredadas del primitivo que tienen algo que explicar —marcadas «heredada de Base UI»—. El resto está en la línea «hereda de».

NumberField

Hereda las props de NumberField.Root.

PropTipoPor defectoDescripción
aria-labelstring—Nombre accesible del elemento.
autoFocusboolean—Enfoca el input visible al montar.
currencystring—Un monto: el código ISO 4217 de la moneda («USD», «ars») arma el format de moneda de Intl, y el step pasa a "any" (salvo que se pase uno) para que el submit no rechace los centavos. format se mezcla encima («sin decimales», currencyDisplay). Un código mal formado (vacío, «dólar») no tira como en Intl: cae a número con 2 decimales. Lo que viaja sigue siendo el número crudo.
formatIntl.NumberFormatOptions—Opciones de Intl.NumberFormat. Cambian lo que se ve, nunca el valor. Con currency, se mezclan encima del formato de moneda.
inputClassNamestring—Clases del <input>. Por defecto va centrado y con cifras de ancho fijo.
inputRefRef<HTMLInputElement>—Ref del input visible, el que se enfoca y se selecciona (en Base UI apuntaba al oculto del submit).
labels{ increment?: string; decrement?: string; roleDescription?: string }—increment, decrement y roleDescription: los tres textos que lee el lector de pantalla.
localeIntl.LocalesArgument—El de Intl.NumberFormat: decide los separadores. Sin él, numberField.locale del LabelsProvider, y si tampoco, el del navegador.
placeholderstring—Texto del input vacío. Con format casi nunca hace falta: el formato ya dice qué se espera.
size"sm" | "md" | "lg""md"sm 28 px · md 36 px (default) · lg 40 px. Los mismos altos que Input.
stepnumber | 'any'—Cuánto mueven las flechas y los botones. Con currency, "any" salvo que lo pases.
classNamestring—Clases de la superficie con borde. Acá va el ancho: className="w-32".

Teclado

↑ ↓
Suben y bajan un step.
Shift + ↑ ↓
Un largeStep (10 por defecto).
Alt + ↑ ↓
Un smallStep (0,1 por defecto).
Inicio · Fin
Van al min y al max, pero solo cuando ese tope está definido.
Tab
Una sola parada: el input. Los botones −/+ tienen tabindex="-1" a propósito, porque el teclado ya sube y baja con las flechas.
Re Pág · Av Pág
No hacen nada: Base UI no las ata. Para saltos grandes está Shift + flecha.

Accesibilidad

  • El input es type="text" con inputmode="numeric", no type="number": así el número formateado («$ 12.500») se puede mostrar sin que el navegador lo rechace, y el teclado del celular sigue siendo el numérico.
  • aria-roledescription se lee «Campo numérico» antes del valor; los botones se anuncian «Aumentar» y «Disminuir». Los tres textos se cambian con labels.
  • Adentro de un Field, la etiqueta nombra al input por aria-labelledby, sin htmlFor ni id. Suelto, el aria-label que pases viaja al input, no al grupo.
  • En el tope, el botón queda disabled de verdad: no es solo un gris.
  • El borde rojo sale del aria-invalid del input (has-[input[aria-invalid=true]]), así que el estado inválido lo maneja el Field y no hay que pintarlo a mano.

Reglas de uso

  • Si el número exacto importa, es este componente y no un `Slider`. El slider es para proporciones; acá el dato se tipea, se pega y se verifica.
  • No uses `<input type="number">`. El valor sale como string, el navegador acepta «1e5» y «--3», y no hay forma de mostrar moneda sin romper lo que se envía.
  • format y locale son los de Intl.NumberFormat: cambian lo que se ve, nunca lo que viaja en el submit, que es siempre el número crudo.
  • Montos: `currency` con el código ISO. Arma el formato de moneda, pone step="any" (los centavos pasan la validación) y, si el código viene mal, muestra el número con 2 decimales en vez de romper. format se mezcla encima ({ maximumFractionDigits: 0 } para montos redondos).
  • El locale de una app se pone una vez: numberField.locale en el LabelsProvider. La prop locale gana.
  • Un campo vacío es null, no 0. Distinguir «no cargó nada» de «cargó cero» es casi siempre lo que hace falta.
  • min y max son topes reales: los steppers y las flechas clampean. Si querés que se pueda escribir fuera de rango y que valide el navegador, allowOutOfRange.
  • onValueCommitted para lo caro (pegarle a la API): onValueChange dispara en cada tecla.
  • No trae zona de arrastre (ScrubArea). Es un gesto sin afordancia visible, sin equivalente de teclado y que cambia un dato en silencio: en un formulario es un problema, no una comodidad. Quien la necesite la compone con @base-ui/react/number-field.

Relacionados