HextaUI

BetaGebaut auf Vercels UI SDK 0.1.0-beta.2. Die API kann sich ändern.

Number input

Ein Zahlenfeld auf Vercels UI SDK mit Schaltern, die beim Halten wiederholen, Währungs-, Prozent- und Einheitenformaten in jeder Locale und einem Ziehgriff.

Abhängig von
  • @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

Fügt die Komponente, die HextaUI-Theme-Tokens und alle HextaUI-Komponenten hinzu, von denen sie abhängt.

UI SDK bringt etwas eigenes CSS in einem Cascade Layer namens ui-sdk mit. Deklariere diesen Layer ganz oben im globalen Stylesheet, über dem Tailwind-Import, damit deine Klassen gewinnen.

@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>

Der Wert ist eine Zahl oder null, wenn das Feld leer ist, und das Feld zeigt ihn formatiert an. Tippen aktualisiert den Wert, sobald der Text eine gültige Zahl ist. Enter oder das Verlassen des Felds übernimmt ihn: Der Text wird gelesen, zwischen min und max gehalten, auf den Schritt gerundet und neu formatiert.

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

Ein Schalter wird an seiner Grenze deaktiviert. Ein eingegebener Wert außerhalb der Grenzen wird beim Übernehmen zurückgeholt.

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 ist der übliche Schritt. Shift, Bild auf und Bild ab ändern um largeStep, Alt oder Option mit einer Pfeiltaste um smallStep. Halte einen Schalter gedrückt, um zu wiederholen.

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 nimmt Optionen von Intl.NumberFormat. Eingaben im selben Format funktionieren, und ein Prozentwert ist ein Bruch, also ist 15 % gleich 0.15. Die Gruppe variant="stacked" setzt kleine Pfeile ans Ende.

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 wählt Trennzeichen, Symbole und Zifferngruppierung für Anzeige und Eingabe. Ohne sie folgt das Feld der Sprache der Seite, sobald es interaktiv ist, und der Server rendert Englisch.

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 }

Ziehe einen NumberInputScrubArea seitwärts, um den Wert zu ändern, wie in einem Designtool. Wo der Browser Pointer Lock erlaubt, kann das Ziehen über den Bildschirmrand hinausgehen, und NumberInputScrubAreaCursor zeichnet den 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 }

Mit wheel ändert Scrollen über dem fokussierten Feld den Wert. Standardmäßig ist es aus, damit Scrollen über die Seite das Feld nie verändert.

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 läuft bei jeder gültigen Änderung und onValueCommit am Ende einer Interaktion, das ist der Moment zum Speichern. Ein Controller ändert oder setzt den Wert von jedem Schalter aus.

"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 }

Mit einem name wird die Zahl unverändert gesendet, etwa 1250, nie formatiert. required blockiert ein leeres Feld, und ein Zurücksetzen des Formulars stellt den Standardwert her.

"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 markiert das Feld und setzt aria-invalid am Eingabefeld, für eigene Prüfungen wie den Lagerbestand.

"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 }

Ein deaktiviertes Feld kann nicht fokussiert oder geändert werden und wird nicht gesendet.

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 }

Ein schreibgeschütztes Feld kann fokussiert werden und wird gesendet, aber Schalter, Ziehen und Tippen ändern es nicht.

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 }

Die Schalter tauschen die Seiten, und die Locale bestimmt Ziffern und Währung.

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 }
TasteAktion
↑↓Ändert den Wert um step.
ShiftPageUpPageDownÄndert um largeStep, standardmäßig zehn Schritte. Shift wirkt bei den Pfeilen, den Schaltern, dem Mausrad und beim Ziehen.
AltMit einer Pfeiltaste um smallStep, standardmäßig ein Zehntel Schritt.
HomeEndSpringt zum Minimum oder Maximum, falls gesetzt.
EnterÜbernimmt und formatiert die Eingabe.

Das Feld folgt der Tastatur des WAI-ARIA-Spinbutton-Musters auf einem nativen Texteingabefeld, sodass normales Bearbeiten weiter funktioniert. Benenne das Feld mit einem Label oder aria-label. Die Schalter heißen standardmäßig „Increase“ und „Decrease“, verweisen auf das Feld und bleiben außerhalb der Tab-Reihenfolge, weil die Pfeiltasten dasselbe tun.

Ein Feld mit Dezimalstellen fordert auf Handys eine Dezimaltastatur an, und die Schalter werden auf Touchscreens größer. Sie schrumpfen beim Drücken nur, wenn Bewegung erlaubt ist.

Jeder Teil umhüllt den gleichnamigen UI-SDK-Teil und nimmt alle seine Props an, auch render und einen className, der eine Funktion des Zustands sein kann.

PropTypStandard
value
number | null–
defaultValue
number | null–
onValueChange
(value: number | null, details) => void–
onValueCommitLäuft, sobald eine Interaktion endet. Zum Speichern.
(value: number | null, details) => void–
min
number–
max
number–
step
number1
largeStep
numberstep × 10
smallStep
numberstep ÷ 10
format
Intl.NumberFormatOptions–
localeStandardmäßig die Sprache der Seite.
string–
wheel
booleanfalse
invalid
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
name
string–
form
string–
controllerAus createNumberInputController().
NumberInputController–
AttributBeschreibung
data-slot="number-input"Zum Ansprechen des Zahlenfelds in CSS.
data-scrubbingVorhanden, während Ziehen den Wert ändert.
data-invalidVorhanden, wenn ungültig.
data-disabledVorhanden, wenn deaktiviert.
data-readonlyVorhanden, wenn schreibgeschützt.
data-requiredVorhanden, wenn erforderlich.
PropTypStandard
variantSchalter auf beiden Seiten, gestapelte Pfeile am Ende oder keine Schalter, für einen Ziehgriff.
"split" | "stacked" | "plain""split"
render
ReactElement | (props, state) => ReactElement–
AttributBeschreibung
data-slot="number-input-group"Zum Ansprechen des Feldrahmens in CSS.
data-variantDie Variante: split, stacked oder plain.
PropTypStandard
id
string–
placeholder
string–
render
ReactElement | (props, state) => ReactElement–
PropTypStandard
aria-label
string"Increase"
disabledDeaktiviert nur diesen Schalter.
boolean–
childrenStandardmäßig ein Plus, in der Variante stacked ein Pfeil.
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
AttributBeschreibung
data-slot="number-input-increment"Den Button per CSS ansprechen.
data-at-boundVorhanden, wenn der Wert bei max liegt.
data-disabledVorhanden, wenn der Schalter nicht weiterzählen kann.
PropTypStandard
aria-label
string"Decrease"
disabledDeaktiviert nur diesen Schalter.
boolean–
render
ReactElement | (props, state) => ReactElement–
AttributBeschreibung
data-slot="number-input-decrement"Den Button per CSS ansprechen.
data-at-boundVorhanden, wenn der Wert bei min liegt.

Stapelt den Plus- über den Minus-Schalter am Ende einer stacked-Gruppe.

PropTypStandard
orientationRechts und oben erhöhen den Wert.
"horizontal" | "vertical""horizontal"
pixelsPerStepWie weit sich der Zeiger für einen Schritt bewegt.
number–
controllerVerbindet einen Ziehbereich außerhalb des Zahlenfelds, etwa ein Symbol in einer Werkzeugleiste.
NumberInputController–
AttributBeschreibung
data-slot="number-input-scrub-area"Zum Ansprechen des Ziehbereichs in CSS.
data-orientationhorizontal oder vertical.
data-scrubbingVorhanden, während eine Ziehbewegung den Wert ändert.
PropTypStandard
childrenStandardmäßig ein waagerechter Pfeil, mittig auf dem Zeiger.
ReactNode–