HextaUI

BetaHecho sobre el UI SDK 0.1.0-beta.2 de Vercel. Su API puede cambiar.

Number input

Un campo numérico sobre el UI SDK de Vercel con botones que repiten al mantenerlos, formatos de moneda, porcentaje y unidades en cualquier idioma, y un control para arrastrar.

Depende de
  • @tabler/icons-react
  • @ui-sdk/core
  • @ui-sdk/react
  • class-variance-authority
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
  NumberInputScrubArea,
  NumberInputScrubAreaCursor,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputDemo() {
  return (
    <NumberInput defaultValue={1} min={1} max={99} className="max-w-36">
      <NumberInputScrubArea pixelsPerStep={6}>
        <Label htmlFor="demo-quantity">Quantity</Label>
        <NumberInputScrubAreaCursor />
      </NumberInputScrubArea>
      <NumberInputGroup>
        <NumberInputDecrement />
        <NumberInputControl id="demo-quantity" />
        <NumberInputIncrement />
      </NumberInputGroup>
    </NumberInput>
  )
}

export { UiSdkNumberInputDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-number-input.json

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

UI SDK añade un poco de CSS propio en una capa de cascada llamada ui-sdk. Declara esa capa al principio de tu hoja de estilos global, encima del import de Tailwind, para que tus clases ganen.

@layer ui-sdk;

@import "tailwindcss";
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"
<NumberInput defaultValue={1} min={0}>
  <NumberInputGroup>
    <NumberInputDecrement />
    <NumberInputControl aria-label="Quantity" />
    <NumberInputIncrement />
  </NumberInputGroup>
</NumberInput>

El valor es un número, o null cuando el campo está vacío, y el campo lo muestra con formato. Escribir actualiza el valor en cuanto el texto es un número válido. Enter o salir del campo lo confirma: el texto se lee, se mantiene entre min y max, se ajusta al paso y se vuelve a formatear.

NumberInput
├── NumberInputScrubArea
│   └── NumberInputScrubAreaCursor
└── NumberInputGroup
    ├── NumberInputDecrement
    ├── NumberInputControl
    ├── NumberInputIncrement
    └── NumberInputStepper
        ├── NumberInputIncrement
        └── NumberInputDecrement

Un botón se desactiva en su límite. Un valor escrito fuera de los límites se ajusta al confirmarlo.

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputMinMax() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="guests">Guests</Label>
      <NumberInput defaultValue={2} min={1} max={8}>
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="guests" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p className="m-0 text-xs text-muted-foreground">
        Tables seat 1 to 8 guests.
      </p>
    </div>
  )
}

export { UiSdkNumberInputMinMax }

step es el incremento habitual. Shift, Re Pág y Av Pág mueven en largeStep, y Alt u Option con una flecha en smallStep. Mantén un botón para repetir.

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputStep() {
  return (
    <div className="grid w-full max-w-sm grid-cols-2 gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="temperature">Temperature</Label>
        <NumberInput
          defaultValue={0.7}
          min={0}
          max={2}
          step={0.1}
          largeStep={0.5}
          smallStep={0.01}
        >
          <NumberInputGroup>
            <NumberInputDecrement />
            <NumberInputControl id="temperature" />
            <NumberInputIncrement />
          </NumberInputGroup>
        </NumberInput>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="max-tokens">Max tokens</Label>
        <NumberInput
          defaultValue={4096}
          min={256}
          max={32768}
          step={256}
          largeStep={4096}
        >
          <NumberInputGroup>
            <NumberInputDecrement />
            <NumberInputControl id="max-tokens" />
            <NumberInputIncrement />
          </NumberInputGroup>
        </NumberInput>
      </div>
    </div>
  )
}

export { UiSdkNumberInputStep }

format acepta opciones de Intl.NumberFormat. Se puede escribir en el mismo formato, y un porcentaje es una fracción, así que 15% es 0.15. El grupo variant="stacked" pone flechas pequeñas al final.

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
  NumberInputStepper,
} from "@/components/ui/ui-sdk-number-input"

const fields = [
  {
    id: "price",
    label: "Price",
    defaultValue: 19.99,
    step: 0.01,
    format: { style: "currency", currency: "USD" },
  },
  {
    id: "discount",
    label: "Discount",
    defaultValue: 0.15,
    step: 0.01,
    format: { style: "percent" },
  },
  {
    id: "weight",
    label: "Weight",
    defaultValue: 2.5,
    step: 0.1,
    format: { style: "unit", unit: "kilogram" },
  },
] satisfies {
  id: string
  label: string
  defaultValue: number
  step: number
  format: Intl.NumberFormatOptions
}[]

function UiSdkNumberInputFormat() {
  return (
    <div className="flex w-full max-w-44 flex-col gap-4">
      {fields.map(({ id, label, ...options }) => (
        <div key={id} className="flex flex-col gap-2">
          <Label htmlFor={id}>{label}</Label>
          <NumberInput {...options} min={0} locale="en-US">
            <NumberInputGroup variant="stacked">
              <NumberInputControl id={id} />
              <NumberInputStepper>
                <NumberInputIncrement />
                <NumberInputDecrement />
              </NumberInputStepper>
            </NumberInputGroup>
          </NumberInput>
        </div>
      ))}
    </div>
  )
}

export { UiSdkNumberInputFormat }

locale elige separadores, símbolos y agrupación de dígitos, para mostrar y para escribir. Sin él, el campo sigue el idioma de la página cuando es interactivo, y el servidor muestra inglés.

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

const fields = [
  {
    id: "preis",
    label: "Preis",
    locale: "de-DE",
    defaultValue: 1234.5,
    step: 0.01,
    format: { style: "currency", currency: "EUR" },
  },
  {
    id: "budget",
    label: "Budget",
    locale: "en-IN",
    defaultValue: 1234567,
    step: 1000,
    format: { style: "currency", currency: "INR", maximumFractionDigits: 0 },
  },
  {
    id: "kakaku",
    label: "価格",
    locale: "ja-JP",
    defaultValue: 2980,
    step: 10,
    format: { style: "currency", currency: "JPY" },
  },
] satisfies {
  id: string
  label: string
  locale: string
  defaultValue: number
  step: number
  format: Intl.NumberFormatOptions
}[]

function UiSdkNumberInputLocale() {
  return (
    <div className="flex w-full max-w-52 flex-col gap-4">
      {fields.map(({ id, label, ...options }) => (
        <div key={id} className="flex flex-col gap-2">
          <Label htmlFor={id}>{label}</Label>
          <NumberInput {...options} min={0}>
            <NumberInputGroup>
              <NumberInputDecrement />
              <NumberInputControl id={id} />
              <NumberInputIncrement />
            </NumberInputGroup>
          </NumberInput>
        </div>
      ))}
    </div>
  )
}

export { UiSdkNumberInputLocale }

Arrastra un NumberInputScrubArea hacia los lados para cambiar el valor, como en una herramienta de diseño. Donde el navegador permite bloquear el puntero, el arrastre puede pasar el borde de la pantalla, y NumberInputScrubAreaCursor dibuja el cursor.

import {
  NumberInput,
  NumberInputControl,
  NumberInputGroup,
  NumberInputScrubArea,
  NumberInputScrubAreaCursor,
} from "@/components/ui/ui-sdk-number-input"

const fields = [
  { label: "X position", handle: "X", defaultValue: 120 },
  { label: "Y position", handle: "Y", defaultValue: 48 },
  { label: "Width", handle: "W", defaultValue: 320, min: 0 },
  { label: "Height", handle: "H", defaultValue: 240, min: 0 },
]

function UiSdkNumberInputScrub() {
  return (
    <div
      role="group"
      aria-labelledby="scrub-frame"
      className="flex w-full max-w-64 flex-col gap-2"
    >
      <p id="scrub-frame" className="m-0 text-xs font-medium">
        Frame
      </p>
      <div className="grid grid-cols-2 gap-2">
        {fields.map(({ label, handle, ...options }) => (
          <NumberInput key={label} {...options}>
            <NumberInputGroup variant="plain">
              <NumberInputScrubArea pixelsPerStep={1}>
                <span aria-hidden="true">{handle}</span>
                <NumberInputScrubAreaCursor />
              </NumberInputScrubArea>
              <NumberInputControl aria-label={label} />
            </NumberInputGroup>
          </NumberInput>
        ))}
      </div>
    </div>
  )
}

export { UiSdkNumberInputScrub }

Con wheel, desplazar sobre el campo enfocado cambia el valor. Está desactivado por defecto, así que una página que se desplaza por encima del campo nunca lo cambia.

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputWheel() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="hover-delay">Hover delay</Label>
      <NumberInput
        defaultValue={300}
        min={0}
        max={2000}
        step={50}
        wheel
        format={{ style: "unit", unit: "millisecond" }}
        locale="en-US"
      >
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="hover-delay" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p className="m-0 text-xs text-muted-foreground">
        Focus it, then scroll over it.
      </p>
    </div>
  )
}

export { UiSdkNumberInputWheel }

onValueChange se ejecuta en cada cambio válido, y onValueCommit al terminar una interacción, que es en el que conviene guardar. Un controlador cambia o fija el valor desde cualquier botón.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
  createNumberInputController,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputControlled() {
  const [seats, setSeats] = React.useState<number | null>(5)
  const [saved, setSaved] = React.useState<number | null>(5)
  const [controller] = React.useState(createNumberInputController)

  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label htmlFor="seats">Seats</Label>
      <NumberInput
        controller={controller}
        value={seats}
        min={1}
        max={50}
        onValueChange={(value) => setSeats(value)}
        onValueCommit={(value) => setSaved(value)}
      >
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="seats" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <div className="flex items-center gap-2 pt-1">
        <Button
          size="sm"
          variant="outline"
          onClick={() => controller.increment({ large: true })}
        >
          Add 10
        </Button>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => controller.setValue(1)}
        >
          Reset to 1
        </Button>
      </div>
      <p className="m-0 text-xs text-muted-foreground tabular-nums">
        {`Showing ${seats ?? "nothing"}, saved ${saved ?? "nothing"}.`}
      </p>
    </div>
  )
}

export { UiSdkNumberInputControlled }

Con un name, el número se envía tal cual, como 1250, nunca con formato. required bloquea un campo vacío, y restablecer el formulario vuelve al valor por defecto.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputForm() {
  const [submitted, setSubmitted] = React.useState<string | null>(null)

  return (
    <form
      className="flex w-full max-w-48 flex-col gap-2"
      onSubmit={(event) => {
        event.preventDefault()
        setSubmitted(String(new FormData(event.currentTarget).get("amount")))
      }}
      onReset={() => setSubmitted(null)}
    >
      <Label htmlFor="amount">Amount</Label>
      <NumberInput
        name="amount"
        required
        min={1}
        step={0.01}
        defaultValue={1250}
        locale="en-US"
        format={{ style: "currency", currency: "USD" }}
      >
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="amount" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <div className="flex items-center gap-3 pt-1">
        <Button type="submit" size="sm">
          Submit
        </Button>
        <Button type="reset" size="sm" variant="ghost">
          Reset
        </Button>
      </div>
      <p className="m-0 text-xs text-muted-foreground tabular-nums">
        {submitted === null
          ? "Nothing submitted yet."
          : `Submitted amount=${submitted}`}
      </p>
    </form>
  )
}

export { UiSdkNumberInputForm }

invalid marca el campo y pone aria-invalid en el input, para comprobaciones propias como el stock.

"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

const stock = 12

function UiSdkNumberInputInvalid() {
  const [quantity, setQuantity] = React.useState<number | null>(14)
  const invalid = quantity !== null && quantity > stock

  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="order-quantity">Quantity</Label>
      <NumberInput
        defaultValue={14}
        min={1}
        invalid={invalid}
        onValueChange={(value) => setQuantity(value)}
      >
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl
            id="order-quantity"
            aria-describedby="order-quantity-message"
          />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p
        id="order-quantity-message"
        className={
          invalid
            ? "m-0 text-xs text-destructive"
            : "m-0 text-xs text-muted-foreground"
        }
      >
        {invalid ? `Only ${stock} left in stock.` : `${stock} in stock.`}
      </p>
    </div>
  )
}

export { UiSdkNumberInputInvalid }

Un campo desactivado no se puede enfocar ni cambiar, y no se envía.

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputDisabled() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="sold-out">Quantity</Label>
      <NumberInput defaultValue={3} disabled>
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="sold-out" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p className="m-0 text-xs text-muted-foreground">Sold out.</p>
    </div>
  )
}

export { UiSdkNumberInputDisabled }

Un campo de solo lectura se puede enfocar y se envía, pero los botones, el arrastre y la escritura no lo cambian.

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputReadOnly() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label htmlFor="included-seats">Seats included</Label>
      <NumberInput defaultValue={5} readOnly>
        <NumberInputGroup>
          <NumberInputDecrement />
          <NumberInputControl id="included-seats" />
          <NumberInputIncrement />
        </NumberInputGroup>
      </NumberInput>
      <p className="m-0 text-xs text-muted-foreground">Set by your plan.</p>
    </div>
  )
}

export { UiSdkNumberInputReadOnly }

Los botones cambian de lado, y el idioma fija los dígitos y la moneda.

import { Label } from "@/components/ui/label"
import {
  NumberInput,
  NumberInputControl,
  NumberInputDecrement,
  NumberInputGroup,
  NumberInputIncrement,
} from "@/components/ui/ui-sdk-number-input"

function UiSdkNumberInputRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-44 flex-col gap-2">
      <Label htmlFor="rtl-amount">المبلغ</Label>
      <NumberInput
        defaultValue={250}
        min={0}
        step={5}
        locale="ar-EG"
        format={{ style: "currency", currency: "EGP" }}
      >
        <NumberInputGroup>
          <NumberInputDecrement aria-label="إنقاص" />
          <NumberInputControl id="rtl-amount" />
          <NumberInputIncrement aria-label="زيادة" />
        </NumberInputGroup>
      </NumberInput>
    </div>
  )
}

export { UiSdkNumberInputRtl }
KeyAcción
↑↓Cambia el valor en step.
ShiftPageUpPageDownCambia en largeStep, diez pasos por defecto. Shift funciona con las flechas, los botones, la rueda y el arrastre.
AltCon una flecha, cambia en smallStep, una décima de paso por defecto.
HomeEndVa al mínimo o al máximo, si están definidos.
EnterConfirma y formatea lo escrito.

El campo sigue el teclado del patrón spinbutton de WAI-ARIA sobre un input de texto nativo, así que la edición de texto normal sigue funcionando. Nombra el input con una etiqueta o aria-label. Los botones se llaman “Increase” y “Decrease” por defecto, apuntan al input y quedan fuera del orden de tabulación porque las flechas hacen lo mismo.

Un campo con decimales pide un teclado decimal en los móviles, y los botones crecen en pantallas táctiles. Solo se encogen al pulsarlos si se permite el movimiento.

Cada parte envuelve la parte de UI SDK del mismo nombre y acepta todas sus props, incluidas render y un className que puede ser una función del estado de la parte.

PropTipoPredeterminado
value
number | null–
defaultValue
number | null–
onValueChange
(value: number | null, details) => void–
onValueCommitSe ejecuta al terminar una interacción. Para guardar.
(value: number | null, details) => void–
min
number–
max
number–
step
number1
largeStep
numberstep × 10
smallStep
numberstep ÷ 10
format
Intl.NumberFormatOptions–
localePor defecto, el idioma de la página.
string–
wheel
booleanfalse
invalid
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
name
string–
form
string–
controllerDe createNumberInputController().
NumberInputController–
AtributoDescripción
data-slot="number-input"Para apuntar al campo numérico en CSS.
data-scrubbingPresente mientras un arrastre cambia el valor.
data-invalidPresente cuando no es válido.
data-disabledPresente cuando está deshabilitado.
data-readonlyPresente cuando es de solo lectura.
data-requiredPresente cuando es obligatorio.
PropTipoPredeterminado
variantBotones a ambos lados, flechas apiladas al final o sin botones, para un control de arrastre.
"split" | "stacked" | "plain""split"
render
ReactElement | (props, state) => ReactElement–
AtributoDescripción
data-slot="number-input-group"Para apuntar a la caja del campo en CSS.
data-variantLa variante: split, stacked o plain.
PropTipoPredeterminado
id
string–
placeholder
string–
render
ReactElement | (props, state) => ReactElement–
PropTipoPredeterminado
aria-label
string"Increase"
disabledDesactiva solo este botón.
boolean–
childrenPor defecto, un signo más, o una flecha en la variante stacked.
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
AtributoDescripción
data-slot="number-input-increment"Apunta al botón en CSS.
data-at-boundPresente cuando el valor está en max.
data-disabledPresente cuando el botón no puede cambiar el valor.
PropTipoPredeterminado
aria-label
string"Decrease"
disabledDesactiva solo este botón.
boolean–
render
ReactElement | (props, state) => ReactElement–
AtributoDescripción
data-slot="number-input-decrement"Apunta al botón en CSS.
data-at-boundPresente cuando el valor está en min.

Apila el botón de aumentar sobre el de disminuir al final de un grupo stacked.

PropTipoPredeterminado
orientationDerecha y arriba aumentan el valor.
"horizontal" | "vertical""horizontal"
pixelsPerStepCuánto se mueve el puntero por cada paso.
number–
controllerConecta un área de arrastre fuera del campo numérico, como un icono en una barra de herramientas.
NumberInputController–
AtributoDescripción
data-slot="number-input-scrub-area"Para apuntar al área de arrastre en CSS.
data-orientationhorizontal o vertical.
data-scrubbingPresente mientras un arrastre cambia el valor.
PropTipoPredeterminado
childrenPor defecto, una flecha horizontal centrada en el puntero.
ReactNode–