Skip to main content

Calendar and DatePicker

import { Calendar, DatePicker } from '@react-x11/components/calendar';

A month grid that picks one date or a range, and the same grid on a popup behind a field. Both moved out of react-x11 core, and this package is their owner now — core is expected to drop its copies.

Neither registers a host element. They are compositions of <box>, <text>, <canvas> and core's <Icon>, so importing them has no side effect at import time at all — no registerElement, no JSX augmentation.

<Calendar>

<Calendar value={day} onChange={(ev) => setDay(ev.value)} />
<Calendar
mode="range"
value={range}
onChange={(ev) => setRange(ev.value)}
min={today()}
isDateBlocked={(day) => booked.has(day)}
spanBlocked={false}
/>

Props

CalendarProps is a union of SingleCalendarProps and RangeCalendarProps, discriminated on mode. That is what keeps onChange's parameter honest: a range calendar's ev.value is a DateRange, a single one's is a CalendarDay | null.

PropTypeNotes
mode'single' (default) | 'range'Discriminates the rest of the props.
valueDayInput | DateRangeInput | nullControlled selection. Shape follows mode.
defaultValuesameUncontrolled initial selection.
onChange(ev: WidgetChangeEvent<…>) => voidev.value is a CalendarDay | null or a DateRange.
monthCalendarMonth | nullControlled visible month.
defaultMonthDayInput | CalendarMonthUncontrolled initial month.
onMonthChange(month: CalendarMonth) => voidNav buttons, and keyboard month movement.
min / maxDayInputRange ends, inclusive.
isDateBlocked(day, parts) => booleanPer-day veto. parts is the DayParts breakdown, so "weekends" is a one-liner.
spanBlockedbooleanRange mode: whether a selection may jump over a blocked day. Default false.
dayContent(day, state) => ReactNodeDrawn under the day number — event dots, prices. state carries selected, inRange, today, blocked, outside, preview, focused and the color the cell is painted in.
namestringEchoed on the change event, for form libraries.
localestringBCP-47. Default: the environment's.
weekStartsOn06Default: the locale's first day.
focusablebooleanA calendar is a control; default true.
focusVisiblebooleanForce the focus ring on, for a picker that owns focus elsewhere.
styleStyle | Style[]The root box.
refRef<CalendarHandle>See below.

CALENDAR_WIDTH and CALENDAR_HEIGHT are exported for laying one out before it renders — a popup that needs to know which way it can open.

CalendarHandle

interface CalendarHandle {
handleKey: (ev: KeyboardEvent) => boolean;
}

One method, and it is what <DatePicker> uses: a field that owns keyboard focus forwards arrow keys, PageUp/PageDown, Home/End and Enter into the grid, and false back means "not mine, do your own thing".

dayContent

The keys useDesktopCalendarEvents's byDay map uses are exactly the 'YYYY-MM-DD' days dayContent is handed, so nothing sits between the two:

<Calendar
dayContent={(day, state) =>
(byDay.get(day) ?? []).slice(0, 3).map((ev, i) => (
<box
key={i}
style={{
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: state.selected
? state.color
: (ev.calendar.color ?? '$accent'),
}}
/>
))
}
/>

state.color is the colour the cell is currently painted in. Reading it rather than picking your own is what keeps a marker legible on the selected day, where the background is the accent.

<DatePicker>

<DatePicker value={day} onChange={(ev) => setDay(ev.value)} />

Every <Calendar> prop except focusable, focusVisible, ref and style — the picker owns those — plus:

PropTypeNotes
format(value) => string | nullWhat the trigger shows. Default: the locale's medium date, and Intl's own range format when there are two.
placeholderstringShown when nothing is picked yet.
disabledboolean
styleStyle | Style[]The trigger, not the sheet.

The omit is distributed across the union rather than applied to it, so <DatePicker mode="range" onChange={…}> still knows ev.value is a DateRange. Omit over a union collapses it into one member, which would have cost exactly that.

The wall-calendar glyph on the trigger is a local <canvas>, not a core <Icon>. Core's icon set is affordances — chevrons, checks, a close — and a calendar page is a noun; the set will never have one. The month nav arrows are core's <Icon name="chevronLeft|chevronRight">, so a picker opened from a core <Select> agrees with it.

Date vocabulary

The day arithmetic comes out through the barrel too, because an app that renders a calendar almost always does a little of the same arithmetic to decide what to block or what to mark:

import {
addDays,
addMonths,
clampDay,
dayDate,
dayParts,
firstOfMonth,
formatDay,
formatDayRange,
formatMonth,
localeWeekStart,
monthGrid,
monthOf,
toDay,
toMonth,
today,
weekdayLabels,
} from '@react-x11/components/calendar';

A CalendarDay is a 'YYYY-MM-DD' string and a CalendarMonth is 'YYYY-MM'. Both are plain strings on purpose: they compare, sort and key a Map without a helper, and they carry no timezone, which a Date cannot avoid carrying.

src/calendar/dates.ts and src/calendar/internal.ts are copies of code that is still in react-x11 today. They were copied rather than imported because they are not on core's exports map, and they are pure. Core is expected to drop <Calendar>, so divergence here is intended rather than drift — do not try to keep them in sync.

Example

npm run examples:calendar renders both, with the user's real calendar events dotted onto the grid.