Forms
DatePicker
A date — or a span of them — chosen from a calendar.
Usage
When to reach for it
import { DatePicker } from '@misoto22/design'Notes
A date, chosen from a calendar.
A trigger and a Calendar in a Popover — not a new component so much as the composition people otherwise assemble slightly differently on every screen.
It is deliberately NOT a text input with a calendar attached. A typed date needs parsing, and parsing needs a format, and a format is a locale argument nobody wins. When typing genuinely matters — a birth date, a long way back — the calendar's month and year are dropdowns, which is the same journey without the ambiguity.
Anatomy
| Element | Description |
|---|---|
| Triggerrequired | A <button> printing format(value) or the placeholder, with a calendar glyph pinned at the end. It is named by the label and the printed date together, so the format is heard as well as seen. |
| Panel | A Popover holding the rail and the grid — side by side from sm up, stacked below it, where two months would not fit anyway. |
| Preset rail | A role="group" of plain buttons, present only when presets is set: on by default for DateRangePicker, off by default for DatePicker. A shortcut that lands on a disabledDates day is drawn unavailable and refuses the click. |
| Calendar grid | The shared Calendar, autoFocus on open so the keyboard lands in the month rather than back at the trigger. Two months at once on the range picker, from months. |
| Half-range text | The range trigger prints “from – …” while only one end is chosen, so a half-answered range says so on the closed control instead of looking finished. |
Best practices
Do
- Put the restriction in disabledDates rather than in your own handler. The rail asks it too, so a shortcut on a blocked day is disabled instead of committing a date the grid beside it refuses — a range preset is tested at its ENDS, so one straddling a blocked day is still offered, exactly as the grid still allows it.
- Pick controlled or uncontrolled and stay there. The current value is value ?? uncontrolled, so a controlled picker that clears by setting value to undefined falls through to whatever defaultValue seeded and the old date reappears.
- Validate a range before you use it: half a range is a legal state here — from set, to undefined — so a submit handler that reads value.to without checking gets undefined from a reader who simply closed the panel early.
Don’t
- A Field’s required does not reach the trigger. It is a plain <button>, a role with nowhere to put aria-required, so the asterisk above is the whole of the marking and a screen reader meets an ordinary optional field.
- Do not reach for it for a birth date. There is no defaultMonth to pass: the panel always opens on the current month, so a date decades back begins with every reader in the month-and-year picker.
- Do not disable it to show a fixed date. disabled takes the trigger out of the tab order and blocks its pointer events, and the trigger is the only place the chosen date is printed at all.
Examples
default
A trigger and a Calendar in a Popover, and deliberately not a text input with a calendar attached: a typed date needs a format, and 03/04 is March the fourth in one country and the third of April in the next. The trigger prints the chosen date in the visitor's own locale for the same reason. Where typing would genuinely be faster — a date years back — the month and year are dropdowns, which is that journey without the ambiguity.
Month and year are dropdowns — reaching two years back is one click, not twenty-four.
range
Two months side by side, because a range that crosses a month boundary is the common case and paging back and forth to see both ends is what makes a range picker tiring. The panel stays open until both ends are chosen — a range is not a value until it has a second date — and while only one end is set the trigger prints “from – …”, so a half-answered range says so on the closed control instead of looking finished. That half state is legal, so validate before reading the second date: a reader who closed the panel early leaves it undefined.
Last 30 days and its neighbours are one click; the grid is for everything else.
presets
The rail beside the grid — off by default on a single picker and on by default for a range, because “last 30 days” is most of what a range picker is ever asked for while a single date is usually a specific one. They are plain buttons rather than a menu, so they set the same value the grid sets and Tab in the same pass as it. Each shortcut is computed on the click, not at render: a list built once freezes “today” at whenever the page loaded.
Shortcuts are computed when clicked, so “today” means today even on a tab left open overnight.
blocked dates
disabledDates goes straight through to the calendar: past days and weekends are refused here, and the grid will not commit one. Note there is no preset rail beside it — the shortcuts call their value straight into the same setter the grid uses and are never tested against disabledDates, so a rail next to these rules would happily commit a Sunday the grid itself refuses. If you want both, check each preset by hand. Reach for disabledDates rather than disabled, too: disabled takes the trigger out of the tab order and blocks its pointer events, and the trigger is the only place the chosen date is printed.
Weekdays only, and not in the past.
Parts
Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.
DateRangePicker
A span of dates — a stay, a reporting period, a filter.
Two months side by side, because a range that crosses a month boundary is the common case, and paging back and forth to see both ends is what makes a range picker tiring. They stack under sm, where two would not fit — the calendar's own months class already carries that, so there is nothing to override.
The panel stays open until both ends are chosen: a range is not a value until it has a second date, and closing on the first one would mean re-opening to finish.
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | Names the control. Announced together with the printed range, not instead of it. | |
| aria-describedby | string | Ids of the copy describing the control. A `Field` sets it from hint, error and description. | |
| aria-invalid | boolean | 'true' | 'false' | Announced on the trigger. A `Field` sets it from `error`. | |
| className | string | — | |
| defaultValue | DateRange | — | |
| disabled | boolean | false | — |
| disabledDates | ComponentProps<typeof Calendar>['disabled'] | Days the reader may not choose. Reaches the shortcut rail as well as the grid, at the ENDS of each preset range — a shortcut whose interior straddles a blocked day is still offered, the way the grid still lets a reader drag a range across one. | |
| format | (date: Date) => string | formatDate | — |
| id | string | The TRIGGER's id — the element a label points at. A `Field` sets it. | |
| months | number | 2 | How many months are shown side by side. Falls back to one under `sm`. |
| onValueChange | (value: DateRange | undefined) => void | — | |
| placeholder | string | 'Pick a range' | — |
| presets | boolean | DatePreset<DateRange>[] | true | Shortcuts shown beside the grid — Last 30 days and its neighbours. `true` for the built-in set, an array for your own. On by default here and off on the single picker, because "last 30 days" is most of what a range picker is ever asked for, while a single date is usually a specific one. |
| value | DateRange | — |
Re-exports
RANGE_PRESETS = [
{ label: 'Last 7 days', value: () => daysAgo(7) },
{ label: 'Last 30 days', value: () => daysAgo(30) },
{ label: 'Last 90 days', value: () => daysAgo(90) },
{ label: 'Last 12 months', value: () => daysAgo(365) },
{
label: 'Month to date',
value: () => {
const to = new Date()
return { from: new Date(to.getFullYear(), to.getMonth(), 1), to }
},
},
{
label: 'Year to date',
value: () => {
const to = new Date()
return { from: new Date(to.getFullYear(), 0, 1), to }
},
},
]The shortcuts a range picker is asked for on nearly every screen it appears on, so they ship rather than being rebuilt per dashboard.
Computed on click: a preset list built at render time freezes "today" at whenever the page loaded, which is wrong for anything left open overnight.
DATE_PRESETS = [
{ label: 'Today', value: () => new Date() },
{
label: 'Tomorrow',
value: () => {
const date = new Date()
date.setDate(date.getDate() + 1)
return date
},
},
{
label: 'In a week',
value: () => {
const date = new Date()
date.setDate(date.getDate() + 7)
return date
},
},
{
label: 'In a month',
value: () => {
const date = new Date()
date.setMonth(date.getMonth() + 1)
return date
},
},
]The single-date equivalent.
Keyboard
| Key | Does |
|---|---|
| EnterSpace | Opens the calendar. |
| Escape | Closes it without choosing. |
Accessibility
- The trigger prints the date in the visitor’s own locale, not a fixed dd/mm/yyyy, and announces it as part of its own name — so format reaches a screen reader too.
- DateRangePicker keeps the panel open until both ends are chosen — a range is not a value until it has a second date.
- The shortcut rail is plain buttons, not a menu: they set the same value the grid beside them sets, so they belong to one control and Tab in the same pass.
- Presets are computed on click, so “today” means today even on a tab left open overnight.