Composites
Date Picker
A typed date field with a calendar attached, and a range variant with a preset column for reports and list filters. One locale prop moves both halves of the control: the calendar language and the day–month order the input expects.
Single date
The field is a real <input>, not a button that only opens a calendar. Professional data entry — accounting, HR — types faster than it clicks, and a 1975 birthday behind fifty calendar flips is unusable. Type 03/15/2026 and press Enter, or open the calendar; both paths land in the same onValueChange.
value = 2026-05-21
What typing actually commits
Three rules, and they are the reason the field is safe to hand to a fast typist. Try them in the demo above.
- A half-typed string is never overwritten. Keystrokes only move local text state; the component reformats the input from
valueonly when the value, the pattern or the locale changes from outside. - Commit happens on blur and on Enter —
parse()against the same pattern the placeholder advertises, thenstartOfDay()so two dates picked on different clocks compare equal. - An unparseable or out-of-range entry restores the current value rather than silently emitting
null. Nothing is swallowed; the user sees their old date come back. Clearing the field to empty is a deliberatenull. - Changing
localemid-session re-renders the text through the new pattern. Without that, a string still reading15/03/2026in a field now expectingMM/dd/yyyywould parse as a different day on the next blur.
Locale drives the calendar and the input order
dateFormatForLocale() reads locale.formatLong.date({ width: 'short' }) and widens a lone y to yyyy — not to change the output (y already prints 2026) but because the same string is the placeholder, and a hint reading dd/MM/y teaches people to type wrong. The derived pattern feeds format, parse and the placeholder at once, so the three cannot drift apart.
locale={vi} → dd/MM/yyyy
locale={enUS} → MM/dd/yyyy
locale={ja} → yyyy/MM/dd
| Locale | dateFormatForLocale() | 21 May 2026 renders as |
|---|---|---|
viVietnamese (package default) | dd/MM/yyyy | 21/05/2026 |
enUSEnglish (United States) | MM/dd/yyyy | 05/21/2026 |
jaJapanese | yyyy/MM/dd | 2026/05/21 |
VI_DATE_FORMAT (dd/MM/yyyy) is still exported but is no longer the default. Use it only to pin Vietnamese dates regardless of app language — a printed invoice, a tax export. Everywhere else, let the locale decide.
Presets
A preset is { id, label, getValue } where getValue is called at click time, not at module load — so “Today” is still today in a tab left open overnight. The single picker ships with the column hidden (presets={false}); pass an array to turn it on.
value = null
The two shipped constants are Vietnamese. They are exported so you can spread or reorder them, not so an English app can use them as-is:
- DEFAULT_DATE_PRESETS
Hôm nayHôm qua7 ngày trước- DEFAULT_DATE_RANGE_PRESETS — applied automatically by DateRangePicker
Hôm nay7 ngày qua30 ngày quaTháng nàyTháng trước
Bounds
minDate and maxDate are enforced twice — the calendar disables the days, and a typed date outside the window is rejected on blur. The demo below is pinned to Q1 2026; try typing 06/01/2026 and tabbing away.
value = 2026-02-10 · window = 2026-01-01 → 2026-03-31
Internally the two bounds become an array of react-day-picker matchers ([{ before }, { after }]), never one object. Putting before and after in the same object makes a DateInterval, which means “the days between” — the exact inverse of the intent. Worth knowing if you ever pass matchers to the raw Calendar primitive yourself.
Date range
The range variant leads with presets, because nine times out of ten the answer is “last 7 days” rather than an arbitrary window — charging two calendar clicks for every report view is a tax on repetition. The trigger is a button showing both formatted dates; there is no typing here.
from = — · to = —
Two traps in one component. First, presets defaults to DEFAULT_DATE_RANGE_PRESETS, so an English app that only passes locale still shows a Vietnamese preset column — pass your own array or false. Second, onValueChange fires on the first calendar click, delivering { from } with no to. Guard on range?.to before you issue a query, or you will fetch on every half-picked range.
One month, no presets
In a drawer or a narrow filter rail, drop to numberOfMonths={1} and presets={false}. The preset column already collapses to a wrapping chip row below the sm breakpoint, so turn it off only when the shortcuts genuinely do not apply.
The labels contract
DatePickerLabels has three keys and every one of them is screen-reader-only — none of them shows as visible text. That is exactly why they need overriding: an English app looks correct in a screenshot while still announcing Vietnamese, and nothing goes red. locale cannot fix it — a date-fns locale carries date spelling, not interface strings — so an English app passes both.
| Key | Default (vi) | English override | Read out on |
|---|---|---|---|
clear | Xóa ngày | Clear date | DatePicker — the × button beside the input |
clearRange | Xóa khoảng ngày | Clear date range | DateRangePicker — the × button on the trigger |
openCalendar | Mở lịch | Open calendar | DatePicker — the calendar-icon button |
labelsisPartial<DatePickerLabels>. The component spreads{ ...DEFAULT_DATE_PICKER_LABELS, ...labels }, so overriding one key keeps the other two.- No key here interpolates a number or a unit, so all three are plain strings — unlike
TablePaginationLabels, where such keys are functions. - Standalone props beat labels.
placeholderis its own prop, not a label key, because it is a per-field string (“Invoice date”) rather than a per-language one. ItsDateRangePickerdefault is the VietnameseChọn khoảng ngày, so it needs passing too.
import { enUS } from 'date-fns/locale'
import { DatePicker, DateRangePicker } from '@comitor/ui'
import type { DatePickerLabels } from '@comitor/ui'
// labels is Partial<DatePickerLabels>: the component always spreads
// { ...DEFAULT_DATE_PICKER_LABELS, ...labels }, so one key can be overridden
// without losing the other two.
const EN_LABELS: Partial<DatePickerLabels> = {
clear: 'Clear date',
clearRange: 'Clear date range',
openCalendar: 'Open calendar',
}
// locale does NOT translate these — a date-fns Locale carries date spelling,
// not interface strings. An English app passes BOTH.
<DatePicker value={date} onValueChange={setDate} locale={enUS} labels={EN_LABELS} />
<DateRangePicker
value={range}
onValueChange={setRange}
locale={enUS}
labels={EN_LABELS}
placeholder="Select a date range" // NOT part of labels — its own prop
/>Reading the constants from a Server Component
The pickers themselves are 'use client', but the constants live in a separate module that deliberately is not. That matters: every export of a 'use client' file reaches a Server Component as a client-reference proxy that reads as undefined without throwing and without warning. Plain data — default labels, presets, format helpers — is exactly what server pages read most, so it is kept outside that boundary.
// date-picker-constants has no 'use client' on purpose, so a Server Component
// can read the presets and label defaults directly.
import { enUS } from 'date-fns/locale'
import { DEFAULT_DATE_RANGE_PRESETS, VI_DATE_FORMAT, dateFormatForLocale } from '@comitor/ui'
export default async function Page() {
const pattern = dateFormatForLocale(enUS) // 'MM/dd/yyyy' — safe on the server
const presetIds = DEFAULT_DATE_RANGE_PRESETS.map((preset) => preset.id)
// ...
}
// VI_DATE_FORMAT is still exported, but it is NOT the default any more.
// Reach for it only to pin Vietnamese dates regardless of app language —
// a printed invoice, a tax export file.
VI_DATE_FORMAT // 'dd/MM/yyyy'DatePicker props
| Prop | Type | Default | Description |
|---|---|---|---|
value | Date | null | — | The selected date. Fully controlled — the component stores only the in-progress text of the input, never a date of its own. |
onValueChangerequired | (value: Date | null) => void | — | Called with a startOfDay-normalised date, or null when the field is cleared or emptied. |
placeholder | string | the resolved pattern | Defaults to the format pattern itself (MM/dd/yyyy under enUS), which doubles as a typing hint. |
disabled | boolean | false | Disables the input and the calendar trigger, and hides the clear button. |
minDate | Date | — | Earliest selectable day. Disabled in the calendar and rejected on typed entry. |
maxDate | Date | — | Latest selectable day, same two enforcement points as minDate. |
clearable | boolean | true | Shows the × button when a value is present and the field is enabled. |
presets | DatePreset[] | false | false | Shortcut column to the left of the calendar. Off by default for the single picker. |
locale | Locale (date-fns) | vi | Drives the calendar language AND the input day–month order via dateFormatForLocale(). Pass it in any non-Vietnamese app. |
dateFormat | string | from locale | Escape hatch that pins the typed/displayed pattern. Rarely needed — leaving it out is almost always right. |
labels | Partial<DatePickerLabels> | DEFAULT_DATE_PICKER_LABELS | Screen-reader-only strings for the clear and open-calendar buttons. Vietnamese by default. |
className | string | — | Merged onto the relative wrapper around the input, not onto the input itself. |
id | string | — | Forwarded to the <input>, so an external <Label htmlFor> points at the real control. |
name | string | — | Forwarded to the <input> for uncontrolled form serialisation. |
aria-label | string | — | Forwarded to the <input>. Use it when there is no visible label. |
aria-describedby | string | — | Forwarded to the <input> — point it at your helper or error text. |
aria-invalid | true | — | Literal true only; the type deliberately excludes false and "false". Pass aria-invalid={errors.date ? true : undefined}. |
aria-required | true | — | Literal true only, same shape as aria-invalid. |
DateRangePicker props
| Prop | Type | Default | Description |
|---|---|---|---|
value | DateRange | null | — | The selected range. DateRange is { from: Date | undefined; to?: Date } re-exported from @comitor/ui, so you never import react-day-picker yourself. Note from is a required key whose value may be undefined — {} is not a DateRange. |
onValueChangerequired | (value: DateRange | null) => void | — | Fires on every calendar click, so a half-picked range arrives as { from } with no to. Guard on range?.to before querying. |
placeholder | string | 'Chọn khoảng ngày' | Shown on the trigger while empty. Its default is Vietnamese and it is NOT part of labels — pass it explicitly in an English app. |
disabled | boolean | false | Disables the trigger and hides the clear button. |
minDate | Date | — | Earliest selectable day. |
maxDate | Date | — | Latest selectable day. |
clearable | boolean | true | Shows the × button once a range is present. |
presets | DateRangePreset[] | false | DEFAULT_DATE_RANGE_PRESETS | Shortcut column. ON by default here — and the default labels are Vietnamese. Pass your own array, or false to drop the column. |
numberOfMonths | number | 2 | Months shown side by side. Two is right for report periods; drop to 1 in a narrow drawer. |
locale | Locale (date-fns) | vi | Same dual role as on DatePicker — calendar language and the order of the two formatted dates on the trigger. |
dateFormat | string | from locale | Pins the display pattern regardless of locale. |
labels | Partial<DatePickerLabels> | DEFAULT_DATE_PICKER_LABELS | Only clearRange is read here — the trigger is its own calendar opener, so openCalendar is unused. |
className | string | — | Merged onto the trigger button. |
id | string | — | Forwarded to the trigger button. |
aria-label | string | — | Forwarded to the trigger button. |
aria-describedby | string | — | Forwarded to the trigger button. |
aria-invalid | true | — | Literal true only, matching DatePicker. There is no aria-required here. |
Types and constants
import type {
DatePickerLabels,
DatePreset,
DateRange, // re-exported from react-day-picker so you never depend on it
DateRangePreset,
} from '@comitor/ui'
interface DatePreset { id: string; label: string; getValue: () => Date }
interface DateRangePreset { id: string; label: string; getValue: () => DateRange }
interface DatePickerLabels { clear: string; clearRange: string; openCalendar: string }
// Values
DEFAULT_DATE_PICKER_LABELS // the three Vietnamese sr-only strings
DEFAULT_DATE_PRESETS // 3 single-date shortcuts (vi labels)
DEFAULT_DATE_RANGE_PRESETS // 5 range shortcuts (vi labels), applied by default
VI_DATE_FORMAT // 'dd/MM/yyyy' — no longer the default pattern
dateFormatForLocale(locale) // derive the numeric pattern from a date-fns LocaleAccessibility
- The single picker is a genuine text input — reachable, editable and dictatable without ever opening the calendar. It carries
inputMode="numeric"for the mobile keypad andautoComplete="off"so browser suggestions do not fight the format. - Both trailing icons are real
Buttons carryingsr-onlytext fromlabels; the icons themselves arearia-hidden. They are in the tab order, so a keyboard user can clear the field or open the calendar without a mouse. - The icon row is
pointer-events-nonewithpointer-events-autorestored on each button. Without that, the row's padding would cover the right edge of the input and clicking there would fail to place the caret. - In
DateRangePickerthe clear button is a sibling of the popover trigger, not a child of it. A button inside a button is invalid HTML and unreachable by Tab; as a sibling it gets a real focus ring and a 24px hit area. - The calendar takes
autoFocuswhen the popover opens, so keyboard users land on the day grid; react-day-picker then owns arrow keys, Home/End and PageUp/PageDown. Escape closes the popover and returns focus to the trigger. - The selected day uses the checked-control token pair —
--control-onfor the fill and--control-checkfor the numeral — the same pairing as a checked Checkbox. Measured: the day number reaches 10.19:1 in the default palette and 5.77:1 in high contrast; the cell fill reaches 3.28:1 against the calendar surface in high contrast (1.86:1 in the default palette, an accepted exception in the same class as the Switch track). - Known gap, deliberately open: the today marker and the middle of a selected range use
bg-accent, which is 1.09:1 (light) / 1.17:1 (dark) against the calendar. Pushing the range middle to 3:1 turns a whole week into a solid block, so this is a design question rather than a token swap. Do not rely on the today tint alone to convey anything. aria-invalidandaria-requiredaccept the literaltrueonly — writearia-invalid={errors.date ? true : undefined}rather than passing a boolean expression.- Give every picker a name: an
idpaired with a visibleLabel htmlFor, oraria-labelwhen there is no visible label. Pointaria-describedbyat your error text so a rejected entry is explained rather than merely reverted.