# NumberField

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

```tsx
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`.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

### NumberField

Hereda las props de `NumberField.Root`.

| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| `aria-label` | `string` | — | Nombre accesible del elemento. |
| `autoFocus` | `boolean` | — | Enfoca el input visible al montar. |
| `currency` | `string` | — | 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. |
| `format` | `Intl.NumberFormatOptions` | — | Opciones de `Intl.NumberFormat`. Cambian lo que se ve, nunca el valor. Con `currency`, se mezclan encima del formato de moneda. |
| `inputClassName` | `string` | — | Clases del `<input>`. Por defecto va centrado y con cifras de ancho fijo. |
| `inputRef` | `Ref<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. |
| `locale` | `Intl.LocalesArgument` | — | El de `Intl.NumberFormat`: decide los separadores. Sin él, `numberField.locale` del `LabelsProvider`, y si tampoco, el del navegador. |
| `placeholder` | `string` | — | 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`. |
| `step` | `number \| 'any'` | — | Cuánto mueven las flechas y los botones. Con `currency`, `"any"` salvo que lo pases. |
| `className` | `string` | — | Clases de la superficie con borde. Acá va el ancho: `className="w-32"`. |

## Teclado

| Tecla | Qué hace |
|---|---|
| ↑ ↓ | 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

[input](/docs/components/input.md) · [slider](/docs/components/slider.md) · [textarea](/docs/components/textarea.md)
