Datepicker

September 1, 2026 · View on GitHub

Pick a date from a calendar. <Datepicker> is a trigger field showing the formatted value plus a calendar icon; clicking it opens a popover with a fully keyboard-driven day grid. The popover is three drill-down views in one panel — click the "Month Year" header to jump to a year grid (pages of 24), pick a year to reach a month grid (Jan–Dec), pick a month to land back on that month's day grid — so hopping across decades is a couple of clicks, not dozens of month steps. All the date maths runs through a zero-dep date adapter, and the value is a plain local-midnight Date.

:::demo datepicker-basic

Import

import Datepicker from '@weave-framework/ui/datepicker';
@use 'pkg:@weave-framework/ui/datepicker';

Basic usage

Bind a Date | null with value + onChange. placeholder shows when nothing is picked; clearable={{ true }} adds a clear button to reset it:

:::tabs

<Datepicker value={{ date() }} onChange={{ setDate }} label={{ 'Date' }} placeholder={{ 'Pick a date' }} clearable={{ true }} />
import { signal } from '@weave-framework/runtime';
import Datepicker from '@weave-framework/ui/datepicker';

export function setup() {
  const date = signal(null); // Date | null
  return { date, setDate: (v) => date.set(v) };
}

:::

Bounds & filtering

min / max mark the selectable range (inclusive), and dateFilter disables individual days — a weekends-off picker, say:

<Datepicker
  value={{ date() }} onChange={{ setDate }}
  min={{ new Date(2020, 0, 1) }} max={{ new Date() }}
  dateFilter={{ (d) => d.getDay() !== 0 && d.getDay() !== 6 }}
/>

The day view's header ("June 2026") is a button: click it (or press Enter on it) to open a year grid of 24 years — the previous / next buttons page by 24. Choose a year and the panel switches to a month grid (Jan–Dec, no paging needed); choose a month and it opens that month's day calendar to pick the day. Everything stays in the one popover. Years and months that fall entirely outside min / max are disabled in their grids. Each grid is fully keyboard navigable (see Accessibility).

Locale & format

The field's display uses Intl — set displayFormat (default { dateStyle: 'medium' }) and locale for the format, weekday names, and month/year text. Bring your own adapter if you need custom date logic.

<Datepicker value={{ date() }} onChange={{ setDate }} locale={{ 'lt-LT' }} displayFormat={{ { dateStyle: 'long' } }} />

First day of the week

firstDayOfWeek sets the weekday the grid starts on (0 Sunday … 6 Saturday). It defaults to Monday (1) — a deliberate component default, independent of the locale — so override it per instance when you want another:

<Datepicker value={{ date() }} onChange={{ setDate }} firstDayOfWeek={{ 0 }} />

Translating the chrome (labels)

Month, weekday and year text is localized by locale (Intl). The chrome strings — the nav buttons' accessible names, the year-switch header, the dialog name, and the clear / open-calendar buttons — are English by default and overridden via labels (a partial object; unset keys keep their default). Because props are reactive, the values can be t('…') from @weave-framework/i18n and re-render on a locale change:

<Datepicker
  value={{ date() }} onChange={{ setDate }} clearable={{ true }}
  labels={{ {
    prevMonth: t('cal.prevMonth'), nextMonth: t('cal.nextMonth'),
    prevYearRange: t('cal.prevYears'), nextYearRange: t('cal.nextYears'),
    chooseYear: t('cal.chooseYear'), calendarLabel: t('cal.title'),
    clear: t('cal.clear'), openCalendar: t('cal.open'),
  } }}
/>

App-wide defaults

Locale, date format, week start and translated chrome are application decisions, not per-field ones — a picker that omits any of them shows a date in a format used nowhere else, or English chrome inside a translated UI. So set them once at the app root instead of repeating them at every call site:

import { provideDateTimeDefaults } from '@weave-framework/ui';

export function setup() {
  provideDateTimeDefaults({
    // everything may be a GETTER, so a settings or language change flows straight through
    locale: () => locale(),
    firstDayOfWeek: () => weekStartIndex(),
    displayFormat: () => ({ dateStyle: 'medium' }),
    datepickerLabels: () => ({
      prevMonth: t('cal.prevMonth'), nextMonth: t('cal.nextMonth'),
      chooseYear: t('cal.chooseYear'), clear: t('common.clear'), openCalendar: t('cal.open'),
    }),
    timepicker: () => ({ use24: timeFormat() === '24h', step: timeStep() }),
  });
}

Every picker below then needs nothing but its binding:

<Datepicker value={{ from() }} onChange={{ setFrom }} />
<DateRangePicker value={{ stay() }} onChange={{ setStay }} />
<Timepicker value={{ start() }} onChange={{ setStart }} />
  • Resolution order is instance prop → context default → the component's built-in. A single field can still override any one default: <Datepicker firstDayOfWeek={{ 0 }} /> wins over the context.
  • Getters are read at each use, not captured once, so a mounted field follows a live settings change. A plain value works too and simply never changes.
  • datepickerLabels merges shallowly — over the English defaults, under the instance's labels. Passing three of eight keys never blanks the other five. Both date pickers read the same set; <DateRangePicker> ignores openCalendar, which it has no button for.
  • Providing nothing changes nothing: Monday start, English labels, runtime locale — exactly as before.

provideDateTimeDefaults is called in any ancestor scope (normally the root component's setup) and reaches every picker mounted under it, including inside dialogs and lazily-loaded routes.

The three types behind it, if you are typing a settings object of your own:

import type { DateTimeDefaults, DatepickerLabelDefaults, TimepickerDefaults } from '@weave-framework/ui';

DateTimeDefaults is what the call above takes — every field optional, every field allowed to be a getter. DatepickerLabelDefaults is the chrome both date pickers share, and TimepickerDefaults (use24, step, clearLabel) is the part only <Timepicker> reads. Typing your own settings object as DateTimeDefaults is what keeps a renamed key from silently doing nothing.

Typeable field

By default the field is a button that only opens the calendar. Set editable={{ true }} to swap in a typeable input-as-combobox — the user can type a date (parsed via the adapter) or open the calendar; an unparseable entry flags aria-invalid.

Binding & forms

The usual two dialects — value + onChange, or a forms control (Field<Date>) that marks touched on close and drives the error state. Compose with FormField for a label / hint / error line.

<Datepicker control={{ form.controls.dob }} max={{ new Date() }} />

Accessibility

The trigger is a combobox with aria-haspopup; each view is a role="grid" of role="gridcell" buttons. Open with ↓ / Enter / Space. In the day grid: Arrows move by day, Page Up / Down by month, Shift + Page Up / Down by year, Home / End to the week edges. In the year grid: Arrows move within the page (a row is 4 years), Page Up / Down jump a 24-year page, Home / End to the page edges. In the month grid: Arrows move (a row is 3 months), Home / End to Jan / Dec. Everywhere, Enter / Space selects (drilling down a view or committing the day) and Esc closes and returns focus. It's a non-modal popover (click-away also closes).

API reference

Props

PropTypeDefaultDescription
valueDate | nullControlled value. Ignored when control is set.
onChange(value: Date | null) => voidCalled on selection / clear.
controlField<Date>A forms field — two-way + touched-on-close + error. Wins over value.
min / maxDateEarliest / latest selectable date (inclusive).
dateFilter(date: Date) => booleanReturn false to disable a specific date.
adapterDateAdapter(from locale)Bring your own date adapter.
localestring(runtime)Locale for format / parse / month / weekday / year text.
firstDayOfWeeknumber1 (Monday)First weekday of the grid (0 Sunday … 6 Saturday).
labelsPartial<DatepickerLabels>(English)Translated chrome strings (nav, year switch, dialog, clear, open).
displayFormatIntl.DateTimeFormatOptions{ dateStyle: 'medium' }The field's display format.
editablebooleanfalseSwap the button trigger for a typeable input.
placeholderstringShown when nothing is selected.
clearablebooleanfalseShow a clear button when a date is set.
disabledbooleanfalseDisable the control.
requiredbooleanfalseMark required (aria).
labelstringAccessible name (when not wrapped by a FormField).
clearLabelstring'Clear'Clear button's accessible name (superseded by labels.clear).
positionMenuPosition'bottom-start'Popover position relative to the field.
classstringExtra classes forwarded onto the root.

DatepickerLabels

All optional; each defaults to the English string shown. prevMonth ('Previous month'), nextMonth ('Next month'), prevYearRange ('Previous years'), nextYearRange ('Next years'), chooseYear ('Choose year' — the year-switch header), calendarLabel ('Choose date' — the dialog name), clear ('Clear'), openCalendar ('Open calendar' — editable mode icon button).

Types

import type { DatepickerControl, DatepickerLabels, DatepickerProps, DatepickerContext } from '@weave-framework/ui/datepicker';

When it goes wrong

:::callout trap "The date reads in a format that appears nowhere else in your app" The pickers are configured per instance — adapter, locale, first day of week, display format, translated chrome. Configure one field and forget the next and the app grows two date formats.

The fix is not to repeat yourself. provideDateTimeDefaults() at the app root supplies all of them once, and resolution is always instance prop → context default → built-in:

provideDateTimeDefaults({
  locale: () => locale(),                  // a getter, so a language change flows through
  firstDayOfWeek: () => weekStartIndex(),
  datepickerLabels: () => ({ prevMonth: t('datepicker.prevMonth') }),
});

Pass getters rather than values: a plain value is accepted and simply never changes. Labels merge shallowly at each step, so supplying three of eight keys never blanks the other five. :::

English chrome inside a translated UI. The same cause, and the most visible symptom of it — the calendar's own labels come from datepickerLabels, not from your t() calls, unless you connect them.

A date the field will not accept. The picker parses through its adapter, not through new Date(string). A format your users type but the adapter does not read is rejected silently rather than guessed at.