HextaUI

ベータVercel の UI SDK 0.1.0-beta.2 上に構築。API は変わる可能性があります。

Number input

Vercel の UI SDK で作る数値欄。押し続けると繰り返すボタン、あらゆるロケールの通貨・パーセント・単位の書式、ドラッグで値を変えるハンドルを備えます。

依存関係
  • @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

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

UI SDK は ui-sdk というカスケードレイヤーに少しだけ独自の CSS を追加します。自分のクラスが優先されるよう、グローバルスタイルシートの先頭、Tailwind の import より上でこのレイヤーを宣言してください。

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

値は数値で、欄が空のときは null です。欄には書式付きで表示されます。入力した文字が有効な数値になった時点で値が更新されます。Enter を押すか欄を離れると確定し、文字を読み取って min と max の範囲に収め、ステップに合わせて書式を付け直します。

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

ボタンは上限・下限で無効になります。範囲外の値を入力しても、確定すると範囲内に戻ります。

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 が通常の増分です。Shift、Page Up、Page Down は largeStep ずつ、Alt または Option と矢印キーは smallStep ずつ変えます。ボタンを押し続けると繰り返します。

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 は Intl.NumberFormat のオプションを受け取ります。同じ書式で入力でき、パーセントは割合なので 15% は 0.15 です。variant="stacked" のグループは末尾に小さな矢印を置きます。

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 は表示と入力の区切り文字、記号、桁区切りを決めます。指定しない場合、操作できるようになった時点でページの言語に従い、サーバーは英語で描画します。

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 }

デザインツールのように NumberInputScrubArea を横にドラッグして値を変えます。ブラウザーがポインターロックを許可する環境では画面の端を越えてドラッグでき、カーソルは NumberInputScrubAreaCursor が描きます。

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 }

wheel を付けると、フォーカス中の欄の上でスクロールして値を変えられます。既定ではオフなので、ページをスクロールしても値は変わりません。

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 は有効な変更のたびに、onValueCommit は操作の終わりに呼ばれ、保存にはこちらを使います。コントローラーを使えば任意のボタンから値を変えたり設定したりできます。

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

name を付けると、数値は 1250 のように書式なしでそのまま送信されます。required は空欄を防ぎ、フォームをリセットすると既定値に戻ります。

"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 は欄に印を付け、入力に aria-invalid を設定します。在庫などの独自チェックに使います。

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

無効な欄はフォーカスも変更もできず、送信もされません。

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 }

読み取り専用の欄はフォーカスでき送信もされますが、ボタン、ドラッグ、入力では変わりません。

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 }

ボタンの左右が入れ替わり、数字と通貨はロケールで決まります。

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 }
キーアクション
↑↓値を step ずつ変えます。
ShiftPageUpPageDownlargeStep ずつ変えます。既定は 10 ステップです。Shift は矢印キー、ボタン、ホイール、ドラッグで使えます。
Alt矢印キーと一緒に押すと smallStep ずつ変えます。既定は 1 ステップの 10 分の 1 です。
HomeEnd設定されていれば最小値または最大値に移動します。
Enter入力を確定して書式を付けます。

この欄はネイティブのテキスト入力に WAI-ARIA の spinbutton パターンのキー操作を載せたものなので、通常の文字編集もそのまま使えます。ラベルか aria-label で名前を付けてください。ボタンの既定名は「Increase」と「Decrease」で、入力欄を指し、矢印キーで同じ操作ができるためタブ順には入りません。

小数を扱う欄はスマートフォンで小数用のキーパッドを出し、ボタンはタッチ画面で大きくなります。押したときに縮むのは動きが許可されているときだけです。

各パーツは同名の UI SDK パーツをラップし、render やパーツの状態を受け取る関数にもできる className を含むすべての props を受け付けます。

プロパティ型デフォルト
value
number | null–
defaultValue
number | null–
onValueChange
(value: number | null, details) => void–
onValueCommit操作が終わったときに一度呼ばれます。保存向けです。
(value: number | null, details) => void–
min
number–
max
number–
step
number1
largeStep
numberstep × 10
smallStep
numberstep ÷ 10
format
Intl.NumberFormatOptions–
locale既定はページの言語です。
string–
wheel
booleanfalse
invalid
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
name
string–
form
string–
controllercreateNumberInputController() で作ります。
NumberInputController–
属性説明
data-slot="number-input"CSS で数値欄を指定するためのものです。
data-scrubbingドラッグで値を変えている間に付きます。
data-invalid無効なときに付きます。
data-disabled無効のときに存在します。
data-readonly読み取り専用のときに付与されます。
data-required必須のときに付与されます。
プロパティ型デフォルト
variant両側にボタン、末尾に縦並びの矢印、またはドラッグ用ハンドルのためにボタンなし。
"split" | "stacked" | "plain""split"
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="number-input-group"CSS で欄の枠を指定するためのものです。
data-variantバリアント。split、stacked、plain のいずれかです。
プロパティ型デフォルト
id
string–
placeholder
string–
render
ReactElement | (props, state) => ReactElement–
プロパティ型デフォルト
aria-label
string"Increase"
disabledこのボタンだけを無効にします。
boolean–
children既定はプラス記号で、stacked バリアントでは矢印です。
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="number-input-increment"CSS でボタンを指定します。
data-at-bound値が max のときに付きます。
data-disabledボタンで値を変えられないときに付きます。
プロパティ型デフォルト
aria-label
string"Decrease"
disabledこのボタンだけを無効にします。
boolean–
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="number-input-decrement"CSS でボタンを指定します。
data-at-bound値が min のときに付きます。

stacked グループの末尾で、増やすボタンを減らすボタンの上に重ねます。

プロパティ型デフォルト
orientation右と上で値が増えます。
"horizontal" | "vertical""horizontal"
pixelsPerStep1 ステップに必要なポインターの移動量です。
number–
controllerツールバーのアイコンのように、数値欄の外にあるドラッグ領域をつなぎます。
NumberInputController–
属性説明
data-slot="number-input-scrub-area"CSS でドラッグ領域を指定するためのものです。
data-orientationhorizontal または vertical。
data-scrubbingドラッグで値が変わっている間に付きます。
プロパティ型デフォルト
children既定はポインターの中心に置かれた横向きの矢印です。
ReactNode–