HextaUI

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

Time input

Vercel の UI SDK で作る時刻欄。部分ごとに入力し、12 時間制か 24 時間制、任意の秒、最小値と最大値、フォーム向けの 24 時間表記の値を備えます。

依存関係
  • @ui-sdk/core
  • @ui-sdk/react
import { IconClock } from "@tabler/icons-react"

import { Label } from "@/components/ui/label"
import { TimeInput, TimeInputSegments } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputDemo() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label id="meeting-time">Meeting time</Label>
      <TimeInput aria-labelledby="meeting-time" defaultValue="09:30">
        <IconClock aria-hidden="true" />
        <TimeInputSegments />
      </TimeInput>
    </div>
  )
}

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

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

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

@layer ui-sdk;

@import "tailwindcss";
import { TimeInput } from "@/components/ui/ui-sdk-time-input"
<Label id="meeting-time">Meeting time</Label>
<TimeInput aria-labelledby="meeting-time" defaultValue="09:30" />

値は "14:30" のような 24 時間表記の時刻で、欄がどの時計で表示していてもネイティブの時刻入力と同じです。すべての部分が埋まるまでは null のままです。各部分は小さな入力欄で、数字を入力すると次へ進み、A か P で午前・午後を選べます。

この欄はグループなので、aria-labelledby か aria-label で名前を付けてください。枠内のどこをクリックしても、いちばん近い部分にフォーカスします。

TimeInput
└── TimeInputSegments
    └── TimeInputSegment

TimeInput は部分を自動で描画します。横にアイコンなどを置くときは、TimeInputSegments を自分で追加してください。

欄はロケールの時計で表示します。hourCycle で 12 時間制か 24 時間制を選び、granularity で秒を加えたり時だけにしたりできます。どれも 24 時間表記の値を送信します。

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputClock() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label id="clock-12">12-hour clock</Label>
        <TimeInput
          aria-labelledby="clock-12"
          hourCycle={12}
          defaultValue="18:45"
          locale="en-US"
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label id="clock-24">24-hour clock</Label>
        <TimeInput
          aria-labelledby="clock-24"
          hourCycle={24}
          defaultValue="18:45"
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label id="clock-seconds">With seconds</Label>
        <TimeInput
          aria-labelledby="clock-seconds"
          granularity="second"
          hourCycle={24}
          defaultValue="18:45:30"
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label id="clock-hour">Hour only</Label>
        <TimeInput
          aria-labelledby="clock-hour"
          granularity="hour"
          hourCycle={24}
          defaultValue="18:00"
        />
      </div>
    </div>
  )
}

export { UiSdkTimeInputClock }

min より前、または max より後の時刻は欄を無効にし、フォームは送信されません。

"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

const opens = "09:00"
const closes = "17:30"

function UiSdkTimeInputMinMax() {
  const [time, setTime] = React.useState<string | null>("16:00")
  const outside = time !== null && (time < opens || time > closes)

  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label id="pickup-time">Pickup</Label>
      <TimeInput
        aria-labelledby="pickup-time"
        aria-describedby="pickup-time-message"
        defaultValue="16:00"
        min={opens}
        max={closes}
        hourCycle={24}
        onValueChange={(value) => setTime(value)}
      />
      <p
        id="pickup-time-message"
        className={
          outside
            ? "m-0 text-xs text-destructive"
            : "m-0 text-xs text-muted-foreground"
        }
      >
        {outside ? "We’re closed then." : "Open 09:00 to 17:30."}
      </p>
    </div>
  )
}

export { UiSdkTimeInputMinMax }

invalid は、終了時刻が開始より前といった独自チェックのために欄へ印を付けます。

"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputRange() {
  const [start, setStart] = React.useState<string | null>("13:00")
  const [end, setEnd] = React.useState<string | null>("12:30")
  const invalid = start !== null && end !== null && end <= start

  return (
    <div className="flex w-full max-w-72 flex-col gap-2">
      <div className="grid grid-cols-2 gap-2">
        <div className="flex flex-col gap-2">
          <Label id="shift-start">Starts</Label>
          <TimeInput
            aria-labelledby="shift-start"
            defaultValue="13:00"
            onValueChange={(value) => setStart(value)}
          />
        </div>
        <div className="flex flex-col gap-2">
          <Label id="shift-end">Ends</Label>
          <TimeInput
            aria-labelledby="shift-end"
            aria-describedby="shift-message"
            defaultValue="12:30"
            invalid={invalid}
            onValueChange={(value) => setEnd(value)}
          />
        </div>
      </div>
      <p
        id="shift-message"
        className={
          invalid
            ? "m-0 text-xs text-destructive"
            : "m-0 text-xs text-muted-foreground"
        }
      >
        {invalid ? "The shift has to end after it starts." : "One shift a day."}
      </p>
    </div>
  )
}

export { UiSdkTimeInputRange }

locale は順序、区切り文字、午前・午後の名前を決めます。指定しない場合、操作できるようになった時点でページの言語に従います。

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

const locales = [
  { locale: "en-US", label: "Time (en-US)" },
  { locale: "de-DE", label: "Uhrzeit (de-DE)" },
  { locale: "ja-JP", label: "時刻 (ja-JP)" },
  { locale: "ko-KR", label: "시간 (ko-KR)" },
]

function UiSdkTimeInputLocale() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-4">
      {locales.map(({ locale, label }) => (
        <div key={locale} lang={locale} className="flex flex-col gap-2">
          <Label id={`time-${locale}`}>{label}</Label>
          <TimeInput
            aria-labelledby={`time-${locale}`}
            locale={locale}
            defaultValue="18:45"
          />
        </div>
      ))}
    </div>
  )
}

export { UiSdkTimeInputLocale }

隠し入力が時刻を name で送信します。required は空や途中までの時刻を止め、リセットすると既定値に戻ります。

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

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

  return (
    <form
      className="flex w-full max-w-40 flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        setSubmitted(String(data.get("reminder")))
      }}
    >
      <div className="flex flex-col gap-2">
        <Label id="reminder-time">Remind me at</Label>
        <TimeInput
          aria-labelledby="reminder-time"
          name="reminder"
          defaultValue="08:15"
          required
        />
      </div>
      <div className="flex items-center gap-3">
        <Button type="submit" size="sm">
          Save
        </Button>
        <Button type="reset" size="sm" variant="ghost">
          Reset
        </Button>
      </div>
      <p className="m-0 text-sm text-muted-foreground tabular-nums">
        {submitted ? `Submitted ${submitted}` : "Nothing submitted yet."}
      </p>
    </form>
  )
}

export { UiSdkTimeInputForm }

createTimeInputController() で、どこからでも欄の値を設定・消去したりフォーカスしたりできます。部分は "minute" のように単位で指定します。

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
  TimeInput,
  createTimeInputController,
} from "@/components/ui/ui-sdk-time-input"

function now() {
  const date = new Date()
  return `${String(date.getHours()).padStart(2, "0")}:${String(
    date.getMinutes()
  ).padStart(2, "0")}`
}

function UiSdkTimeInputController() {
  const [controller] = React.useState(createTimeInputController)
  const [value, setValue] = React.useState<string | null>(null)

  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="log-time">Logged at</Label>
      <TimeInput
        aria-labelledby="log-time"
        controller={controller}
        onValueChange={(next) => setValue(next)}
      />
      <div className="flex items-center gap-2 pt-1">
        <Button
          size="sm"
          variant="outline"
          onClick={() => controller.setValue(now())}
        >
          Now
        </Button>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => controller.focus("minute")}
        >
          Edit minutes
        </Button>
        <Button size="sm" variant="ghost" onClick={() => controller.clear()}>
          Clear
        </Button>
      </div>
      <p className="m-0 text-xs text-muted-foreground tabular-nums">
        {value ? `Value ${value}` : "Value null"}
      </p>
    </div>
  )
}

export { UiSdkTimeInputController }

無効な欄はフォーカスで飛ばされ、送信もされません。

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputDisabled() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label id="closed-time">Last entry</Label>
      <TimeInput aria-labelledby="closed-time" defaultValue="22:00" disabled />
    </div>
  )
}

export { UiSdkTimeInputDisabled }

読み取り専用の欄はフォーカスでき送信もされますが、キー操作や入力では変わりません。

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputReadOnly() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label id="checkout-time">Checkout</Label>
      <TimeInput
        aria-labelledby="checkout-time"
        defaultValue="11:00"
        readOnly
      />
    </div>
  )
}

export { UiSdkTimeInputReadOnly }

部分はロケールの順序を保ち、矢印キーは画面上の並びに従います。

import { IconClock } from "@tabler/icons-react"

import { Label } from "@/components/ui/label"
import { TimeInput, TimeInputSegments } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputRtl() {
  return (
    <div dir="rtl" lang="ar" className="flex w-full max-w-40 flex-col gap-2">
      <Label id="rtl-time">وقت الاجتماع</Label>
      <TimeInput aria-labelledby="rtl-time" locale="ar-EG" defaultValue="18:45">
        <IconClock aria-hidden="true" />
        <TimeInputSegments />
      </TimeInput>
    </div>
  )
}

export { UiSdkTimeInputRtl }
キーアクション
0–9部分を埋めて、次の部分に移ります。
↑↓部分を 1 ずつ変え、端で一周します。空の部分は現在時刻から始まります。
PageUpPageDown15 分のように大きく変えます。
HomeEnd部分の最初または最後の値に移動します。
←→前または次の部分に移動します。
Backspace最後の数字を消し、次に部分を空にし、さらに前の部分へ戻ります。
AP12 時間制で午前か午後を選びます。
Enter入力した内容でフォームを送信します。

ルートはグループです。各部分は「hour」のように単位で名前が付いた spinbutton で、値と範囲を持ち、空の部分は「Empty」と読み上げられます。部分の間の文字はスクリーンリーダーから隠れます。

各部分は実際の入力欄なので、スマートフォンでは数字キーパッドが出て、ページのスクリプトが動く前から入力できます。タッチ画面ではズームされないよう 16px に大きくなり、フォーカス中の部分はキャレットの代わりにハイライトされます。

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

プロパティ型デフォルト
value
string | null–
defaultValue
string | null–
onValueChangeすべての部分が埋まったときに呼ばれ、どれかが空になると null で呼ばれます。
(value: string | null, details) => void–
hourCycle既定はロケールの時計です。
12 | 24–
granularity
"hour" | "minute" | "second""minute"
min
string–
max
string–
locale既定はページの言語です。
string–
invalid
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
name
string–
form
string–
controllercreateTimeInputController() で作ります。
TimeInputController–
children既定は TimeInputSegments です。
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="time-input"CSS で時刻欄を指定するためのものです。
data-invalid時刻が min と max の範囲外のとき、または invalid が設定されたときに付きます。
data-emptyすべての部分が空の間に付きます。
data-disabled無効のときに存在します。
data-readonly読み取り専用のときに付与されます。
data-required必須のときに付与されます。

各部分の TimeInputSegment を、ロケールの順に間の文字とともに描画します。

プロパティ型デフォルト
segmentルートの状態にある segments の 1 つです。
TimeInputSegmentData–
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="time-input-segment"CSS で部分を指定するためのものです。
data-typehour、minute、second、dayPeriod、または部分の間の文字を表す literal です。
data-empty部分が空の間に付きます。