Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

Date Picker

A typed date field with a calendar attached, and a range variant with a preset column for reports and list filters. One date-fns locale moves both halves of the control: the calendar language and the day–month order the input expects. It comes from ComitorLocaleProvider — English and MM/dd/yyyy without one — and a locale prop overrides it for a single field.

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 value only 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, then startOfDay() 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 deliberate null.
  • Changing the language mid-session — the provider's or the locale prop — re-renders the text through the new pattern. Without that, a string still reading 15/03/2026 in a field now expecting MM/dd/yyyy would parse as a different day on the next blur.

Locale drives the calendar and the input order

The date-fns locale is the locale prop when you pass one; otherwise it is the one the nearest ComitorLocaleProvider carries — vi for a Vietnamese locale, enUS for every other code and when there is no provider, unless the provider was handed its own dateFnsLocale. (Until 3.0.0 the fallback was a hard-coded vi.) dateFormatForLocale() then 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; dateFormat pins it when one screen must ignore the language.

no provider → MM/dd/yyyy

locale="vi" → dd/MM/yyyy

locale="en-GB" + enGB → dd/MM/yyyy

locale="vi" · locale={ja} → yyyy/MM/dd

The prop and the provider are not equals. The provider sets both halves of the language: the date-fns locale and the interface strings — the screen-reader labels, the preset names, the range placeholder. The locale prop is a date-fns Locale, which carries date spelling and nothing else, so it wins for dates only: the last picker above has a Japanese calendar and pattern, and its buttons still announce Mở lịch. Choose the language once, with the provider; keep the prop for the rare field that must differ from it.

LocaledateFormatForLocale()21 May 2026 renders as
enUSno provider, or any locale but viMM/dd/yyyy05/21/2026
viComitorLocaleProvider locale="vi"dd/MM/yyyy21/05/2026
enGBdateFnsLocale={enGB} on the providerdd/MM/yyyy21/05/2026
jaany other date-fns locale, via either routeyyyy/MM/dd2026/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 — dateFormat={VI_DATE_FORMAT} on 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. The demo passes the package's three plus one of its own.

value = null

The two shipped constants carry English labels, but a picker does not print those as they are: it recognises the package's own preset objects by reference and names them from the provider's datePicker.presets table — so a spread copy of the array, or one run through .filter(), still translates. A preset your app builds is shown exactly as written, even when its id is today and even as { ...preset, label } — which is how you rename one, and why your own presets need your own translation. Each constant, with what it reads under locale="vi":

DEFAULT_DATE_PRESETS
Today · Hôm nayYesterday · Hôm qua7 days ago · 7 ngày trước
DEFAULT_DATE_RANGE_PRESETS — applied automatically by DateRangePicker
Today · Hôm nayLast 7 days · 7 ngày quaLast 30 days · 30 ngày quaThis month · Tháng nàyLast month · Thá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, and their names — like the placeholder and the clear button's name — follow the provider, not the locale prop. A picker given locale={vi} with no provider above it shows a Vietnamese calendar beside an English preset column; set the language with the provider instead. 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.

Setting its width

className goes to the outer frame — the relative w-full box that also holds the clear button — so className="w-60" sizes the whole picker and the trigger inside stays w-full. DatePicker has always worked that way; DateRangePicker caught up in 3.13.2. Before that, className reached the trigger, so w-60 shrank the button while its frame still took the whole flex row, and the field beside it wrapped onto a line of its own. The flip side: a border or a colour passed through className now lands on the frame, not on the button.

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 the wrong language here goes unnoticed: a picker looks correct in a screenshot while announcing another language, and nothing goes red. They come from the provider's datePicker namespace, so the provider that sets the calendar sets them too; the locale prop cannot — a date-fns locale carries date spelling, not interface strings. The order is the usual one: labels → provider → English.

KeyDefault (en)Under locale="vi"Read out on
clearClear dateXoá ngàyDatePicker — the × button beside the input
clearRangeClear date rangeXoá khoảng ngàyDateRangePicker — the × button on the trigger
openCalendarOpen calendarMở lịchDatePicker — the calendar-icon button
  • labels is Partial<DatePickerLabels>, merged over the provider's table, so overriding one key keeps the other two — in the provider's language. DEFAULT_DATE_PICKER_LABELS is still exported, but it is the English table for your own use; the component no longer reads it. The Vietnamese one is comitorMessagesVi.datePicker.
  • No key here interpolates a number or a unit, so all three are plain strings — unlike TablePaginationLabels, where such keys are functions.
  • placeholder is not a label. It is its own prop, because it is a per-field string (“Select a report period”) rather than a per-language one. Left unset, DatePicker shows the resolved pattern and DateRangePicker shows the provider's rangePlaceholder — Pick a date range, or Chọn khoảng ngày in Vietnamese.
tsx
import { enGB } from 'date-fns/locale'
import { ComitorLocaleProvider, DatePicker, DateRangePicker } from '@comitor/ui'

// The provider sets both halves at once: the calendar and the pattern (its
// date-fns locale) AND these sr-only strings (its datePicker namespace) —
// here Xóa ngày and Mở lịch.
<ComitorLocaleProvider locale="vi">
  <DatePicker value={date} onValueChange={setDate} />
</ComitorLocaleProvider>

// labels is Partial<DatePickerLabels>, merged over the provider's table, so
// one key can be reworded without losing the other two.
<DatePicker value={date} onValueChange={setDate} labels={{ openCalendar: 'Pick the invoice date' }} />

// The locale prop is a date-fns Locale — date spelling, not interface strings.
// British dates here, but the labels are still whatever the provider says.
<DatePicker value={date} onValueChange={setDate} locale={enGB} />

<DateRangePicker
  value={range}
  onValueChange={setRange}
  labels={{ clearRange: 'Clear report period' }}
  placeholder="Select a report period"   // 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.

tsx
// 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, getComitorMessages } 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)

  // The constants hold the ENGLISH strings. Any language's table is one pure call away:
  const strings = getComitorMessages('vi').datePicker // { clear: 'Xóa ngày', …, presets: { today: 'Hôm nay', … } }
  // ...
}

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

PropKiểuMặc địnhMô tả
valueDate | null—The selected date. Fully controlled — the component stores only the in-progress text of the input, never a date of its own.
onValueChangebắt buộc(value: Date | null) => void—Called with a startOfDay-normalised date, or null when the field is cleared or emptied.
placeholderstringthe resolved patternDefaults to the format pattern itself — MM/dd/yyyy with no provider, dd/MM/yyyy under a Vietnamese one — which doubles as a typing hint.
disabledbooleanfalseDisables the input and the calendar trigger, and hides the clear button.
minDateDate—Earliest selectable day. Disabled in the calendar and rejected on typed entry.
maxDateDate—Latest selectable day, same two enforcement points as minDate.
clearablebooleantrueShows the × button when a value is present and the field is enabled.
presetsDatePreset[] | falsefalseShortcut column to the left of the calendar. Off by default for the single picker; pass DEFAULT_DATE_PRESETS for the package's three, named in the provider's language.
localeLocale (date-fns)from providerOverrides the date-fns locale of ComitorLocaleProvider (enUS, or vi under a Vietnamese provider) for this field: calendar language AND input day–month order via dateFormatForLocale(). Dates only — the sr-only labels and preset names still follow the provider.
dateFormatstringfrom localeEscape hatch that pins the typed/displayed pattern. Rarely needed — leaving it out is almost always right.
labelsPartial<DatePickerLabels>from providerScreen-reader-only strings for the clear and open-calendar buttons, merged over the provider's datePicker table — English without a provider, the same strings as DEFAULT_DATE_PICKER_LABELS.
classNamestring—Merged onto the relative wrapper around the input, not onto the input itself.
idstring—Forwarded to the <input>, so an external <Label htmlFor> points at the real control.
namestring—Forwarded to the <input> for uncontrolled form serialisation.
aria-labelstring—Forwarded to the <input>. Use it when there is no visible label.
aria-describedbystring—Forwarded to the <input> — point it at your helper or error text.
aria-invalidtrue—Literal true only; the type deliberately excludes false and "false". Pass aria-invalid={errors.date ? true : undefined}.
aria-requiredtrue—Literal true only, same shape as aria-invalid.

DateRangePicker props

PropKiểuMặc địnhMô tả
valueDateRange | 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.
onValueChangebắt buộc(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.
placeholderstring'Pick a date range'Shown on the trigger while empty. NOT part of labels: unset, it comes from the provider's rangePlaceholder key (Chọn khoảng ngày in Vietnamese).
disabledbooleanfalseDisables the trigger and hides the clear button.
minDateDate—Earliest selectable day.
maxDateDate—Latest selectable day.
clearablebooleantrueShows the × button once a range is present.
presetsDateRangePreset[] | falseDEFAULT_DATE_RANGE_PRESETSShortcut column, ON by default here. The package's presets are named in the provider's language; presets you build show their labels as written. false drops the column.
numberOfMonthsnumber2Months shown side by side. Two is right for report periods; drop to 1 in a narrow drawer.
localeLocale (date-fns)from providerSame dual role as on DatePicker — calendar language and the order of the two formatted dates on the trigger — and the same limit: dates only.
dateFormatstringfrom localePins the display pattern regardless of locale.
labelsPartial<DatePickerLabels>from providerOnly clearRange is read here — the trigger is its own calendar opener, so openCalendar is unused.
classNamestring—Merged onto the outer relative w-full frame, as on DatePicker, so className="w-60" sizes the whole picker; the trigger inside is always w-full. Until 3.13.2 it reached the trigger instead — a border or colour meant for the button now lands on the frame.
idstring—Forwarded to the trigger button.
aria-labelstring—Forwarded to the trigger button.
aria-describedbystring—Forwarded to the trigger button.
aria-invalidtrue—Literal true only, matching DatePicker. There is no aria-required here.

Types and constants

tsx
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 sr-only strings in English — for your code; the pickers read the provider
DEFAULT_DATE_PRESETS         // 3 single-date shortcuts; English labels, shown in the provider's language
DEFAULT_DATE_RANGE_PRESETS   // 5 range shortcuts, same rule, applied by default
VI_DATE_FORMAT               // 'dd/MM/yyyy' — no longer the default pattern
dateFormatForLocale(locale)  // derive the numeric pattern from a date-fns Locale
comitorMessagesVi.datePicker // the Vietnamese table: clear, clearRange, openCalendar, rangePlaceholder, presets

Accessibility

  • 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 and autoComplete="off" so browser suggestions do not fight the format.
  • Both trailing icons are real Buttons carrying sr-only text from labels or the provider; the icons themselves are aria-hidden. They are in the tab order, so a keyboard user can clear the field or open the calendar without a mouse.
  • The calendar's own screen-reader names — the month buttons, each day cell (“Thursday, May 21st, 2026, selected”) — come from the provider's calendar namespace. Before 3.0.0 a Vietnamese picker still read react-day-picker's built-in English there, because a date-fns locale carries no interface strings.
  • The icon row is pointer-events-none with pointer-events-auto restored 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 DateRangePicker the 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 autoFocus when 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-on for the fill and --control-check for 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-invalid and aria-required accept the literal true only — write aria-invalid={errors.date ? true : undefined} rather than passing a boolean expression.
  • Give every picker a name: an id paired with a visible Label htmlFor, or aria-label when there is no visible label. Point aria-describedby at your error text so a rejected entry is explained rather than merely reverted.
  • Text is 16px below md and 14px from md up — DatePicker because it is an Input, the range trigger because it sets text-base md:text-sm itself, the rule every field shares since 3.13.4. Leave the size to the package; why is on Input.