On this pageOverview
Date Picker
An accessible date picker that wraps Calendar in a Popover. Consumers provide the trigger button face and the calendar grid layout — DatePicker handles focus choreography (opening focuses the grid, closing returns focus to the trigger), open/close state, and an optional hidden form input for native form submission.
DatePicker uses the Submodel pattern — initialize with DatePicker.init(), store the Model in your parent, delegate Messages via DatePicker.update(), and render with DatePicker.view(). The update function returns [Model, Commands, Option<OutMessage>] — the OutMessage fires when the user commits a date or clears the selection.
A date picker constrained to the next three months via maybeMinDate and maybeMaxDate. Click the trigger to open, pick a date or navigate with arrow keys, then press Enter to commit or Escape to dismiss.
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt — fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect, Match as M, Option } from 'effect'
import { Calendar, Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { Class, Id, button, div, h2, span } from './html'
// Add a field to your Model for the DatePicker Submodel:
const Model = S.Struct({
datePickerDemo: Ui.DatePicker.Model,
// ...your other fields
})
// Fetch `today` once at the app boundary via flags so init stays pure:
const Flags = S.Struct({
today: Calendar.CalendarDate,
// ...your other flags
})
const flags = Effect.gen(function* () {
const today = yield* Calendar.today.local
return { today /* ...your other flags */ }
})
// In your init function, pass the flags-resolved today into DatePicker.init.
// Optional: constrain the selectable range with minDate / maxDate.
const init = (flags: Flags) => [
{
datePickerDemo: Ui.DatePicker.init({
id: 'date-picker-demo',
today: flags.today,
minDate: flags.today,
maxDate: Calendar.addMonths(flags.today, 3),
}),
// ...your other fields
},
[],
]
// Embed the DatePicker Message in your parent Message. DatePicker handles
// Calendar + Popover routing internally — you only need one wrapper:
const GotDatePickerMessage = m('GotDatePickerMessage', {
message: Ui.DatePicker.Message,
})
// Add a domain Message for selection events. The DatePicker view dispatches
// this via the `onSelectedDate` callback — your update handler decides what
// to do with the date (validate, save, navigate, etc.), then writes it back
// via `DatePicker.selectDate` to sync internal state.
const SelectedDate = m('SelectedDate', {
date: Calendar.CalendarDate,
})
// Inside your update function's M.tagsExhaustive({...}), handle both paths.
// `GotDatePickerMessage` delegates navigation, focus, and popover messages
// to DatePicker's own update:
GotDatePickerMessage: ({ message }) => {
const [nextDatePicker, commands] = Ui.DatePicker.update(
model.datePickerDemo,
message,
)
return [
evo(model, { datePickerDemo: () => nextDatePicker }),
commands.map(
Command.mapEffect(
Effect.map(message => GotDatePickerMessage({ message })),
),
),
]
}
// `SelectedDate` is dispatched by the view's `onSelectedDate` callback when
// the user commits a date. Write the selection back to DatePicker via
// `DatePicker.selectDate` so its internal state stays in sync:
SelectedDate: ({ date }) => {
const [nextDatePicker, commands] = Ui.DatePicker.selectDate(
model.datePickerDemo,
date,
)
return [
evo(model, { datePickerDemo: () => nextDatePicker }),
commands.map(
Command.mapEffect(
Effect.map(message => GotDatePickerMessage({ message })),
),
),
]
}
// Inside your view function, render the date picker. The `onSelectedDate`
// callback converts a committed date into your parent Message. The
// `toCalendarView` callback lays out the calendar grid — same shape as
// Calendar.view's `toView`:
Ui.DatePicker.view({
model: model.datePickerDemo,
toParentMessage: message => GotDatePickerMessage({ message }),
onSelectedDate: date => SelectedDate({ date }),
anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
triggerContent: maybeDate =>
Option.match(maybeDate, {
onNone: () => span([], ['Pick a date']),
onSome: date => span([], [`${date.year}-${date.month}-${date.day}`]),
}),
toCalendarView: attributes =>
div(
[...attributes.root, Class('flex flex-col gap-3 p-4')],
[
div(
[Class('flex items-center justify-between')],
[
button(
[...attributes.previousMonthButton, Class('rounded px-2')],
['‹'],
),
h2(
[Id(attributes.heading.id), Class('text-sm font-semibold')],
[attributes.heading.text],
),
button(
[...attributes.nextMonthButton, Class('rounded px-2')],
['›'],
),
],
),
div(
[...attributes.grid, Class('flex flex-col gap-1 outline-none')],
[
div(
[...attributes.headerRow, Class('grid grid-cols-7 gap-1')],
attributes.columnHeaders.map(header =>
div(
[
...header.attributes,
Class('text-center text-xs uppercase'),
],
[header.name],
),
),
),
...attributes.weeks.map(week =>
div(
[...week.attributes, Class('grid grid-cols-7 gap-1')],
week.cells.map(cell =>
div(
[
...cell.cellAttributes,
Class('group flex items-center justify-center'),
],
[
button(
[
...cell.buttonAttributes,
Class(
'h-9 w-9 rounded-full text-sm group-data-[today]:ring-1 group-data-[selected]:bg-accent-600 group-data-[selected]:text-white group-data-[outside-month]:text-gray-400 group-data-[disabled]:opacity-40',
),
],
[cell.label],
),
],
),
),
),
),
],
),
],
),
// Optional: enable hidden form input for native <form> submission:
name: 'appointment-date',
})DatePicker is headless. You control the trigger button via triggerContent and triggerClassName, the popover panel via panelClassName, and the calendar grid via the toCalendarView callback. Data attributes on day cells let you style state variants with CSS selectors like group-data-[selected]: and group-data-[disabled]:.
| Attribute | Condition |
|---|---|
data-today | Present on the cell for today. |
data-selected | Present on the cell for the selected date. |
data-focused | Present on the cell for the keyboard cursor position while the grid has DOM focus. |
data-outside-month | Present on cells that fall outside the currently-viewed month (leading/trailing grid rows). |
data-disabled | Present on cells disabled by min/max, disabledDaysOfWeek, or disabledDates. |
data-open | Present on the trigger button and wrapper while the popover is open. |
The trigger button opens the popover on Enter, Space, or ArrowDown. Inside the popover, the calendar grid handles the full WAI-ARIA grid keyboard pattern. Escape closes the popover from both the trigger and the grid.
| Key | Description |
|---|---|
| Enter / Space / ArrowDown | Open the popover when the trigger button is focused. |
| Escape | Close the popover from the trigger button or from inside the calendar grid. |
| ArrowLeft / ArrowRight | Move focus by one day inside the calendar grid. |
| ArrowUp / ArrowDown | Move focus by one week inside the calendar grid. |
| Home / End | Move focus to the start / end of the current week (based on locale.firstDayOfWeek). |
| PageUp / PageDown | Move focus by one month inside the calendar grid. |
| Shift + PageUp / Shift + PageDown | Move focus by one year inside the calendar grid. |
| Enter / Space | Commit the focused date as the selection and close the popover. |
The trigger button uses aria-expanded and aria-controls to announce the popover relationship. Inside the popover, the calendar grid renders with the full WAI-ARIA grid pattern: role="grid" with aria-activedescendant for cursor tracking, role="row" and role="gridcell" with aria-selected on the chosen date. Day buttons carry full accessible names via aria-label and disabled days get aria-disabled="true". When a hidden form input is enabled via the name prop, the selected date is encoded as an ISO string (YYYY-MM-DD) for native form submission.
Configuration object passed to DatePicker.init(). Calendar constraints (min/max, disabled dates) are forwarded to the embedded Calendar submodel.
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the date picker instance. |
today | CalendarDate | - | The current calendar date. Typically fetched at the app boundary via Calendar.today.local and threaded through flags. |
initialSelectedDate | CalendarDate | - | Pre-selected date. When set, the calendar grid starts on the month containing this date. |
isAnimated | boolean | false | Enables CSS transition coordination on the popover panel (enter/leave animations). |
locale | LocaleConfig | defaultEnglishLocale | Month and day names plus the first day of the week. Import from foldkit/calendar. |
minDate | CalendarDate | - | Earliest selectable date. Dates before minDate are marked disabled and skipped by keyboard navigation. |
maxDate | CalendarDate | - | Latest selectable date. Dates after maxDate are marked disabled and skipped by keyboard navigation. |
disabledDaysOfWeek | ReadonlyArray<DayOfWeek> | [] | Days of the week to disable (e.g. ["Saturday", "Sunday"] for weekday-only selection). |
disabledDates | ReadonlyArray<CalendarDate> | [] | Explicit list of disabled dates (e.g. holidays). Pre-compute for complex rules. |
Configuration object passed to DatePicker.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | DatePicker.Model | - | The date picker state from your parent Model. |
toParentMessage | (message: DatePicker.Message) => ParentMessage | - | Wraps DatePicker Messages in your parent Message type for Submodel delegation. |
onSelectedDate | (date: CalendarDate) => ParentMessage | - | Optional. When provided, committing a date dispatches this callback directly (controlled mode). When omitted, DatePicker manages its own selection (uncontrolled mode). In controlled mode, use DatePicker.selectDate(model, date) to write the selection back. |
anchor | AnchorConfig | - | Popover positioning config (placement, gap, offset, padding). Controls where the calendar panel floats relative to the trigger. |
triggerContent | (maybeDate: Option<CalendarDate>) => Html | - | Renders the trigger button face. Receives the current selection so you can show the formatted date or a placeholder. |
toCalendarView | (attributes: CalendarAttributes<ParentMessage>) => Html | - | Renders the calendar grid layout inside the popover panel. Same callback shape as Calendar.view toView — lay out the attribute groups (grid, header, weeks, cells) however you like. |
isDisabled | boolean | false | Disables the trigger button, preventing the popover from opening. |
name | string | - | When provided, renders a hidden <input> with this name and the selected date encoded as an ISO string (YYYY-MM-DD) for native form submission. |
triggerClassName / triggerAttributes | string / ReadonlyArray<Attribute<Message>> | - | Class name and additional attributes spread onto the trigger button. |
panelClassName / panelAttributes | string / ReadonlyArray<Attribute<Message>> | - | Class name and additional attributes spread onto the popover panel. |
backdropClassName / backdropAttributes | string / ReadonlyArray<Attribute<Message>> | - | Class name and additional attributes spread onto the click-outside backdrop. |
Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.
| Name | Type | Default | Description |
|---|---|---|---|
ChangedViewMonth | { year: number; month: number } | - | Emitted when navigation changes the visible month inside the calendar grid. Date selection goes through the onSelectedDate ViewConfig callback, not OutMessage. |