Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
Composites
Time Picker
A segmented time-of-day field with a column picker attached — the meeting time beside a date, the start of a shift, a payroll cut-off. Whatever clock the reader sees, 02:30 PM or 14:30, your app receives the same 24-hour string.
- Import
import { TimePicker, … } from '@comitor/ui'— the main entry, with no optional peer to install. Every part is a client component, and so is theuseTimePickerhook.- Origin
- Added in
2.1.0, ported from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Popover and tokens. Where it behaves differently from upstream is listed under What the port guarantees. - Changed in 3.0.0
- The clock follows the locale —
enshows 12-hour AM/PM,vi24-hour — and every label defaults to English. 2.1.0 was always 24-hour with SA/CH. To keep that, wrap the app in<ComitorLocaleProvider locale="vi">, or passlocale="vi-VN"to this picker. Details in the changelog. - Changed in 3.1.0
minandmaxare enforced. An app that already passed them, to no effect, now sees greyed-out items and clamped values; a value the app sets itself is still shown as given. Tab in the panel now reaches a Clear placed there. Details in the changelog.
Anatomy
Twelve parts, and none of them appears or disappears on its own — you decide which segments the field has, which columns the panel has, and where Clear goes. The one exception is the period: its segment and its column render nothing on a 24-hour clock, so the same markup serves an English and a Vietnamese reader.
import {
TimePicker,
TimePickerClear,
TimePickerContent,
TimePickerHour,
TimePickerInput,
TimePickerInputGroup,
TimePickerLabel,
TimePickerMinute,
TimePickerPeriod,
TimePickerSecond,
TimePickerSeparator,
TimePickerTrigger,
} from '@comitor/ui'
<TimePicker value={time} onValueChange={setTime}> {/* store · clock · hidden form input */}
<TimePickerLabel>Start time</TimePickerLabel> {/* <label> that names the group */}
<TimePickerInputGroup> {/* role="group": the bordered field */}
<TimePickerInput segment="hour" />
<TimePickerSeparator /> {/* ":" — aria-hidden */}
<TimePickerInput segment="minute" />
<TimePickerSeparator />
<TimePickerInput segment="second" /> {/* only with showSeconds */}
<TimePickerInput segment="period" /> {/* renders nothing on a 24-hour clock */}
<TimePickerTrigger /> {/* clock button that opens the panel */}
</TimePickerInputGroup>
<TimePickerClear /> {/* anywhere inside TimePicker */}
<TimePickerContent> {/* the panel: one column per unit */}
<TimePickerHour />
<TimePickerMinute />
<TimePickerSecond /> {/* only with showSeconds */}
<TimePickerPeriod /> {/* renders nothing on a 24-hour clock */}
</TimePickerContent>
</TimePicker>Beside a date
The field is built to stand next to DatePicker — same h-9 height, border roles, focus ring and error border — and the two together make one appointment. Neither knows about the other, or about time zones: the picker hands you wall-clock time, and composing the instant in the time zone of the person entering it is the app’s job. Clear sits in the label row, where it works without opening the panel. Inside the panel it is just as reachable: since 3.1.0, Tab moves on to it after the columns.
time = "14:30" · startsAt = 2026-10-02T14:30
What the value looks like
Five rules — clear the field above and type to watch each one. The first is the reason the picker is safe to store from; the others are what your validation has to expect.
- Always 24-hour, zero-padded.
HH:mm, orHH:mm:sswithshowSeconds. The 02:30 PM on screen is"14:30"inonValueChange, in the hidden form input and in your database. No time at all is an empty string. - It is emitted while the user types, blanks included. A segment commits as soon as it is complete, and the picker emits the whole string each time — with
--for a segment that is still empty:"09:--"while only the hour is set,"--:30"after the hour is emptied with Backspace. Treat anything that does not match^\d{2}:\d{2}$as unfinished. - Leaving a typed segment fills the blanks from the clock. That includes the automatic jump to the next segment: on a 24-hour clock at 15:47, type 9 and the value is
09:47— the current minute — until a minute is typed; tab away without typing one and that is what stays. Upstream behaviour, kept; withminormaxset, the current time is first pulled into the window. The field shows the filled minute, so the user can see it and correct it. - On a 12-hour clock an empty field borrows AM/PM from now. Typing 9 at three in the afternoon gives
21:…, with PM showing in the period segment — press A to change it. - Impossible digits are never kept. Hours clamp to 1–12 or 0–23 and minutes to 0–59, and a first digit that cannot start a pair — 7 in the minute — commits as
07at once.
With seconds
showSeconds changes the value format to HH:mm:ss — and nothing else. The second segment and the seconds column are ordinary parts you render yourself; leave them out and seconds are never set, render them without the flag and whatever is typed there is dropped. A payroll cut-off is the typical case: the last second of the day, not the last minute. Four columns need room — the panel is never wider than the field it hangs from, so this one is w-56.
value = "23:59:59"
Between min and max
min and max keep what the user picks or types inside a window — 06:00–22:00 below, the hours a shift may run. Type 5 into the start hour and the value becomes 06:00; open the panel and every hour before 6 AM is greyed out. The rules:
- An item with no allowed time is disabled, given the larger units already chosen: an hour when all sixty of its minutes are outside the window, a minute when that minute is, AM or PM when the whole half-day is. Disabled items carry
aria-disabled, ignore clicks, and ↑ / ↓ skip them. - What the user picks or types lands inside. A pick, or a typed segment as it commits — two digits, Tab, Enter, leaving it, an arrow key — that would fall outside becomes the nearest allowed time, keeping what was just chosen if it fits: with
max="17:00"at 09:45, choosing the hour 17 gives17:00— the hour kept, the minutes pulled back — and typing a later hour gives17:00too. Blanks are filled from the current time, pulled into the window, soonValueChangenever reports a time the user took outside. minlater thanmaxcrosses midnight, as on<input type="time">:min="22:00" max="06:00"is a night shift. One bound alone runs to the start or the end of the day. WithoutshowSeconds, a bound with seconds is rounded inward to the minute.- Not clamped: a value the app sets through
valueordefaultValue— shown as given, with its items greyed out — or one that a newminormaxleaves outside. Backspace andTimePickerClearempty a segment or the field without filling it back in.
The window is not the whole check, so a small function still runs on every change: it turns HH:mm into minutes, rejects unfinished values, keeps the end after the start and checks the window again for a shift the app loads, then sets invalid and points aria-describedby at the message. Try an end time of 7:00 AM, or empty a segment with Backspace.
Both pickers carry a name, so each posts its 24-hour value through a hidden input. Press Save shift without touching anything and shiftStart=08:00 and shiftEnd=17:30 arrive. Before 2.1.0 an untouched picker posted an empty string: the hidden input only wrote its value when the value changed, so the first one never reached it. minuteStep={15} here thins the minute column to quarter hours — it does not stop anyone typing 17:37.
Language and region
Two separate axes meet in this component. The labels — segment names, the clock button, the Clear text, the AM/PM words — follow the provider’s language. The clock — 12 or 24-hour — follows the full locale, which is why en-GB matters here: the same English words, a 24-hour clock. The picker’s own locale prop overrides the clock only; the fourth option keeps a Vietnamese interface and forces a 12-hour clock, which shows SA / CH — CLDR’s own Vietnamese abbreviations, what Intl.DateTimeFormat('vi', { hour12: true }) prints.
- value
- "14:30"
- clock
- 12-hour
- segment names
- Hour · Minute · AM/PM
- period text
- AM / PM
- clock button
- Open time picker
- Set the language once, at the root.
ComitorLocaleProviderin the root layout, with the user’s own language — installation covers it in Set the language. Without a provider the picker is English with a 12-hour clock. - Precedence, per string: the
labelsprop, then the nearest provider’stimePickernamespace, then English.DEFAULT_TIME_PICKER_LABELSis the English table since 3.0.0 — the component no longer reads it, it is there for you to spread. The Vietnamese table iscomitorMessagesVi.timePicker. - The clock is regional, and so is the time zone. 12 or 24-hour comes from the region part of the tag —
en-USanden-GBdisagree — so if your user profile stores a region, compose the full tag rather than passing the bare language. The time zone is not the picker’s business at all: the value is wall-clock time, and the time zone of the person entering it — a profile setting, not a workspace one — decides which instant it means.
| Key | English (default) | comitorMessagesVi | Used for |
|---|---|---|---|
hour | Hour | Giờ | Name of the hour segment and of the hour column |
minute | Minute | Phút | Name of the minute segment and column |
second | Second | Giây | Name of the second segment and column |
period | AM/PM | Buổi | Name of the AM/PM segment and column — 12-hour clock only |
am | AM | SA | Visible morning text; its first letter is also a shortcut key |
pm | PM | CH | Visible afternoon text; same shortcut rule |
openPicker | Open time picker | Mở bảng chọn giờ | sr-only name of the clock button |
clear | Clear | Xoá | Visible text of TimePickerClear |
import { DEFAULT_TIME_PICKER_LABELS, comitorMessagesVi } from '@comitor/ui'
// One key, one picker — the other seven still follow the provider.
<TimePicker labels={{ clear: 'Reset' }} value={time} onValueChange={setTime}>…</TimePicker>
// Every key is a plain string, so labels can come straight from a Server Component.
DEFAULT_TIME_PICKER_LABELS.openPicker // 'Open time picker' — the English table since 3.0.0
comitorMessagesVi.timePicker.openPicker // 'Mở bảng chọn giờ'What the port guarantees
Behaviour that differs from Dice UI, each one a bug in the upstream component. The port keeps upstream’s structure and part names so it can still be diffed against it.
- A 24-hour clock has no AM/PM segment. Upstream still drew one — “PM” beside 14:30 — and typing A into it turned 14:30 into 02:30, while its own period column hid itself. Segment and column now follow one rule.
- Read-only means read-only. With
readOnly(ordisabled) the columns ignore clicks, as Clear already did. Upstream’s columns never looked at either flag — and the clock button still opens the panel when read-only — so a read-only time could be changed with the mouse. - The label names the field.
TimePickerLabelcarries the id that the group’saria-labelledbypoints at. Upstream pointed at an id no element had, so screen readers announced an unnamed group. - Every part has a name. Segments are named Hour, Minute, Second and AM/PM; each column is a named listbox whose options carry
aria-selected; the clock button has an sr-only name;invalidreaches every segment asaria-invalid. Upstream had none of these. - Server and browser draw the same clock. Upstream followed the runtime’s locale, so a Node server (
en-US) printed “02” and a Vietnamese browser “14” for the same 14:30 — a hydration mismatch. The clock now comes from the provider or thelocaleprop, identical on both sides. - The initial value is submitted. The hidden form input writes its first value up front; before 2.1.0 an untouched picker posted an empty string.
minandmaxmean what they say. Upstream stored them and nothing else: nothing greyed out, nothing clamped, and 05:30 went through for a shift that may not start before six. The rules are under Between min and max.- Tab reaches everything in the panel. Upstream’s Tab cycled between the columns, so a Clear placed inside
TimePickerContentcould only be clicked. Tab now runs through the columns, landing on each one’s selected value, and on to the rest of the panel. - It lines up with Input and DatePicker.
h-9instead of upstream’sh-10, the package’s border roles, a focus ring without offset and a destructive-ink error border. The selected item uses the checked-control pair,--control-onand--control-check, like the selected day in Calendar.
Known limitations
minandmaxonly fence in the user. A value the app sets throughvalueordefaultValue— a shift saved before the window changed — is shown as given, and so is a value a new window leaves outside; only its items grey out. Check those in your form layer, as the shift demo does.- Steps only thin the columns. A typed value off the grid is accepted and leaves no item highlighted. If only quarter hours are allowed, reject or round in validation.
- Unfinished values reach your handler, and blanks get filled from the current clock when a typed segment is left. Check for a complete time before saving, and do not autosave on every change.
requireddoes not block submission. The flag only reaches the hiddentype="hidden"input, which browsers exclude from constraint validation, and noaria-requiredis set. Mark the field visibly and check it in your submit handler.- The panel is never wider than the field. It is capped at the width of the input group it is anchored to. Hour, minute and period need about 10rem; with seconds on a 12-hour clock, about 13rem. A narrower field lets the columns spill past the panel’s edge.
- Inside react-hook-form’s
FormControl, the Slot putsaria-invalidandaria-describedbyon the root div and itsidnowhere at all — none of it reaches the segments. Passinvalidto the picker andaria-describedbytoTimePickerInputGroup, and name it withTimePickerLabel.
Props
TimePicker
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
value | string | — | Controlled value, always 24-hour: HH:mm, or HH:mm:ss with showSeconds — whatever the clock on screen shows. An empty string means no time. |
defaultValue | string | — | Uncontrolled initial value, same format. |
onValueChange | (value: string) => void | — | Fires with the whole string each time a segment commits. Mid-edit values can carry -- for an empty segment (09:--, --:30); clearing every segment emits an empty string. |
open | boolean | — | Controlled open state of the panel. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
onOpenChange | (open: boolean) => void | — | Fires when the panel opens or closes. |
openOnFocus | boolean | false | Open the panel whenever a segment receives focus. Focus stays in the segment, and clicks inside the field do not count as clicks outside the panel. |
inputGroupClickAction | 'focus' | 'open' | 'focus' | What a click on the empty part of the field does: focus the first segment, or open the panel. |
min | string | — | Earliest allowed time, HH:mm or HH:mm:ss. Panel items with no allowed time are disabled, and a pick or a typed segment that lands outside becomes the nearest allowed time. Later than max, the window crosses midnight (22:00–06:00); without max, it runs to the end of the day. A value the app sets through value or defaultValue is shown as given, never clamped. Enforced since 3.1.0 — before, only stored. |
max | string | — | Latest allowed time, with the same format and rules as min. Without min, the window starts at 00:00. |
hourStep | number | 1 | Interval of the hours listed in the panel. Typing and the arrow keys still move one hour at a time. |
minuteStep | number | 1 | Interval of the minutes listed in the panel (15 lists 00, 15, 30, 45). A typed 14:37 is still accepted, and then no item is highlighted. |
secondStep | number | 1 | Interval of the seconds listed in the panel. |
segmentPlaceholder | string | { hour?, minute?, second?, period? } | '--' | Text of an empty segment, for all segments or one by one. Its length also sets each segment's width in ch. Emitted values always use -- for a blank, whatever you pass here. |
locale | string | provider locale | BCP 47 tag that decides the clock and nothing else: en, en-US, en-AU and ko give 12-hour; en-GB, vi, de, fr and ja give 24-hour. It does not translate the labels. Without a provider it is en. 2.1.0 hard-coded vi-VN. |
labels | Partial<TimePickerLabels> | timePicker namespace | Overrides single keys; the rest follow ComitorLocaleProvider, then English. All eight keys are plain strings. |
name | string | — | Name of the hidden input that carries the 24-hour value when the picker sits inside a form. Outside a form no hidden input is rendered. |
disabled | boolean | false | Disables every segment, the clock button and Clear. A disabled picker's value is not submitted. |
readOnly | boolean | false | Segments cannot be edited, and the columns and Clear ignore clicks. The clock button still opens the panel, so the time can be read there. |
required | boolean | false | Only reaches the hidden input, and browsers skip constraint validation for type=hidden — it neither blocks submission nor announces anything. Validate and mark the field yourself. |
invalid | boolean | false | Destructive-ink border on the field and aria-invalid on every segment. |
showSeconds | boolean | false | Switches the value format to HH:mm:ss. Render the second segment and TimePickerSecond yourself — they do not appear on their own. |
asChild | boolean | false | Render the root as your own element (Radix Slot) instead of a div. |
TimePickerInput
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
segmentbắt buộc | 'hour' | 'minute' | 'second' | 'period' | — | The unit this input edits. A period segment renders nothing on a 24-hour clock. |
aria-label | string | labels[segment] | The segment's accessible name. It comes from labels; pass your own to override one segment. |
disabled | boolean | false | Per segment, combined with the root flag. |
readOnly | boolean | false | Per segment, combined with the root flag. |
onKeyDown | KeyboardEventHandler | — | Runs before the built-in handling. Call event.preventDefault() to take over a key — the segment then skips its own behaviour for that event. |
...rest | Omit<ComponentProps<'input'>, 'type' | 'value'> | — | className, style and the other handlers. Width comes from a CSS variable per segment, derived from segmentPlaceholder. |
TimePickerContent
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Preferred side. |
align | 'start' | 'center' | 'end' | 'start' | Aligned to the field's start. |
sideOffset | number | 6 | Gap between the field and the panel, in px. |
onOpenAutoFocus | (event: Event) => void | — | Runs first. Without preventDefault, opening focuses the selected value of the first column — or leaves focus in the segment when openOnFocus opened the panel. |
...rest | PopoverContent props | — | className, collisionPadding, onInteractOutside and the rest. The panel is w-auto but capped at the width of the input group it is anchored to. |
TimePickerHour · TimePickerMinute · TimePickerSecond · TimePickerPeriod
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
format (TimePickerHour) | 'numeric' | '2-digit' | 'numeric' | How hour items print: 9 or 09. |
format (Minute, Second) | 'numeric' | '2-digit' | '2-digit' | How minute and second items print: 05 or 5. |
aria-label | string | labels.hour / minute / second / period | Name of the column's listbox. Each column is a listbox of options with aria-selected. |
TimePickerPeriod | — | — | No props of its own beyond div props. Renders nothing on a 24-hour clock, like the period segment. |
asChild | boolean | false | Render the column as your own element. |
TimePickerTrigger · TimePickerClear · TimePickerLabel · TimePickerSeparator
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
TimePickerTrigger children | ReactNode | clock icon + sr-only labels.openPicker | Pass text and that text becomes the button's name — the sr-only span only comes with the default icon. Disabled by the root disabled flag, not by readOnly. |
TimePickerClear children | ReactNode | labels.clear | Sets the value to an empty string. Ignores clicks when the root is disabled or readOnly. |
TimePickerLabel | ComponentProps<'label'> & { asChild? } | — | Renders a label carrying the id that the group's aria-labelledby points at. Pass htmlFor only if you need it for something else. |
TimePickerSeparator children | ReactNode | ':' | Decorative, aria-hidden. |
useTimePicker
import { useTimePicker } from '@comitor/ui'
// Reads the picker's store. Call it from a component rendered INSIDE <TimePicker> —
// anywhere else it throws.
function SelectedTime() {
const value = useTimePicker((state) => state.value) // also state.open
return <span className="tabular-nums">{value || 'No time yet'}</span>
}Accessibility
| Keys | Where | Does |
|---|---|---|
| 0–9 | Segment | Types the segment. It commits and moves on after two digits, or after one digit that cannot start a pair (hour 2–9 on a 12-hour clock, 3–9 on a 24-hour clock, minute 6–9). |
| ↑ / ↓ | Segment | Steps by one and wraps. On an empty segment ↑ starts at 12 (12-hour) or 00, and ↓ at 12, 23 or 59. On the period segment, toggles AM/PM. A step outside min / max lands on the nearest allowed time. |
| ← / → | Segment | Previous / next segment. |
| A · P · 1 · 2 | Period segment | Sets AM or PM. The first letter of labels.am / labels.pm works too — S and C for SA and CH. |
| Backspace · Delete | Segment | With the segment selected (it is, on focus), empties it: 14:30 becomes --:30. |
| Enter | Segment | Commits the typed text and stays. Never submits the form. |
| Escape | Segment | Drops the segment's edit and leaves the field. |
| Enter · Space | Clock button | Opens the panel and focuses the selected value of the first column. |
| ↑ / ↓ | Panel | Moves within a column and selects as it moves, skipping values outside min / max; wraps. |
| ← / → | Panel | Previous / next column, wrapping. |
| Tab / Shift+Tab | Panel | Next / previous column, then on to anything else focusable in the panel — a Clear placed there — in DOM order, wrapping at the ends. Each column is one stop, its selected value. Tab never leaves the panel. |
| Enter · Space | Panel | Selects the focused value. |
| Escape | Panel | Closes the panel; focus returns to the clock button. |
- The field is a
role="group"named byTimePickerLabelthrougharia-labelledby. Each segment is a text input named fromlabels, withinputMode="numeric"(text for the period), autocomplete, autocorrect and spellcheck off, andtranslate="no"so page translation leaves the digits alone. - The clock button is a real button whose name is the sr-only
labels.openPicker; its icon isaria-hidden. It carries the package’s focus-visible outline. - In the panel each column is a named
listboxand each value anoptionwitharia-selected. Selection follows focus on ↑ / ↓ — the arrows move and choose at once, andaria-selectedmoves with them. Values outsidemin/maxcarryaria-disabledand the arrows pass over them. - Tab does not leave the panel. It goes through the columns — one stop each, on the selected value — then any other control in the panel, and wraps; Escape is the way out, and focus then returns to the clock button.
- Forms: the value travels in a hidden input named by
name, rendered only when the picker is inside a<form>. Its initial value is posted (since 2.1.0); a disabled picker posts nothing;requiredis not enforced. Enter in a segment commits the segment and never submits the form. - Errors:
invalidsetsaria-invalidon every segment and a destructive-ink border on the field. The message is yours — pointaria-describedbyonTimePickerInputGroupat it. - Still the app’s job: naming the field, translating that name, checking that the value is complete — and inside its window, when the app set it — marking it required, and deciding which time zone it belongs to.
- The field and its segments are 16px below
mdand 14px frommdup, like every field since 3.13.4 — 14px at every width before. Leave the size to the package; Input explains why.
Related
- Date Picker — the other half of an appointment; same height, borders and focus ring, and its date format follows the same provider.
- Form Section — lays out forms of these. Name the picker with
TimePickerLabelrather thanFormField’s label: a<label for>cannot point at a group. - HR — the attendance week prints clock-in spans like 08:45 – 17:30. Setting a shift, or correcting a missed clock-in by hand, is a job for this field and the window check above.
- Tasks and CRM — due dates and follow-ups that need a time of day beside the date.
- Mask Input and Phone Input — the other two form fields from the same port, where region matters as much as language.