Elements

Calendar

A calendar picks a date, or with range a range of dates, from months of days laid out in weeks: a due date in a form, the nights of a stay, the days of a report. It shows one month or several side by side, turns the months with Previous and Next, and switches to a view of the year’s months or a page of years to go far. It is the calendar in DatePicker’s popup, on its own in the page.

el-date-picker.tsx
el-date-picker.tsxLive
import { type JSX, mount, signal, Text, View, windowSize } from "nsh";
import { type CalendarEntry, DatePicker, type DatePickerValue, type DateRangePreset, type HourCycle, type OpenChangeReason, type TimeFieldSegmentEntry } from "nsh/base";
import { button, desktop, Icon, Log, Panel, Segmented, size, Split, ui, wide } from "./palette";

// Three date pickers in a card: a due date (weekends disabled, presets
// beside the calendar, a clear button), a stay picked as a range over two
// months in a wide window, and a meeting's date and time with the hours
// on the clock the inspector picks. The trigger is the shared button look
// with the value and a chevron that turns while the calendar is open; the
// calendar is a popup at the trigger, a sheet on a touch screen. Every
// item is drawn by `renderItem` (month names, weekday initials, today on a
// quiet fill, the selected days filled, a range's band) and every segment
// of the time by `renderSegment`; the time's label starts its row and the
// field, a well as the number inputs', ends it.

const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];

/** A day's ISO date (`"2026-10-28"`), in local time as the calendar's days are. */
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const addDays = (d: Date, days: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days);
/** An ISO date's month and day: `Oct 28`. */
const monthDay = (value: string) => `${MONTHS[Number(value.slice(5, 7)) - 1].slice(0, 3)} ${Number(value.slice(8, 10))}`;

/** A time `"HH:MM"` on the picked clock: `14:30`, or `2:30 PM`. */
function clock(time: string, cycle: HourCycle) {
  if (cycle === "h23") return time;
  const hour = Number(time.slice(0, 2));
  return `${hour % 12 === 0 ? 12 : hour % 12}:${time.slice(3, 5)} ${hour < 12 ? "AM" : "PM"}`;
}

/** The text of a control: 13 with a pointer, the touch label size otherwise. */
const controlText = desktop ? 13 : size.label;
/** A day's cell: seven across the popup with a pointer, across a phone's sheet otherwise. */
const cell = desktop ? 32 : 42;
/** The gap between the months of a calendar of two. */
const monthGap = 16;
/**
 * On a touch screen the calendar spans the sheet (the window less the sheet's padding), its columns sharing it: the
 * header's titles split what Previous and Next leave, so the month and the year meet in the middle, and each month's
 * title of two is over its month.
 */
const toggleWidth = () => Math.floor((windowSize().width - 2 * 10) / 2 - cell);

/** A popup at the trigger with a pointer, a sheet at the bottom on a touch screen. */
const surface = () =>
  desktop
    ? { ...ui().card, borderRadius: 12, padding: 8 }
    : { ...ui().card, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingHorizontal: 10, paddingTop: 14, paddingBottom: 30 };

const backdrop = { backgroundColor: "rgba(0,0,0,0.45)" };

/** The popup's layout: presets in a column beside the calendar with a pointer, a row of chips above it on a phone. */
const content = desktop ? { flexDirection: "row" as const, gap: 10 } : { gap: 12 };
const presets = () =>
  desktop
    ? { gap: 2, minWidth: 104, paddingRight: 10, borderRightWidth: 1, borderColor: ui().border }
    : { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 6 };
const preset = () =>
  desktop
    ? { height: 28, paddingHorizontal: 8, borderRadius: 6, justifyContent: "center" as const, fontSize: 13, color: ui().text }
    : { height: 34, paddingHorizontal: 12, borderRadius: 17, borderWidth: 1, borderColor: ui().border, justifyContent: "center" as const, fontSize: 14, color: ui().text };
/** The calendar: in the popup with a pointer; across the sheet on a touch screen, as the time's row under it. */
const calendar = desktop ? { padding: 2 } : {};
/** The time's row under a hairline, its label at the start and the field at the end, on the calendar's edges. */
const time = () => ({
  gap: 10,
  marginTop: 8,
  paddingTop: 8,
  borderTopWidth: 1,
  borderColor: ui().border,
  ...(desktop ? { paddingHorizontal: 2 } : {}),
});
/** The time's field: a recessed well as the number inputs', with their hover and the keyboard's ring while it edits. */
const timeField = [
  desktop ? "h-8 rounded-lg px-2" : "h-11 rounded-xl px-3",
  "items-center border border-input bg-field",
  "enabled:hover:border-faint focus-within:outline-2 focus-within:outline-info",
].join(" ");

/** The trigger: the shared button's look, the value at the start and a chevron in a lane as wide as it. */
function Trigger(props: { title?: string; placeholder: string; open: boolean; clearable?: boolean }) {
  const look = button("secondary", {
    style: () => ({
      flexDirection: "row",
      justifyContent: "space-between",
      gap: 8,
      ...(desktop ? { width: 260 } : {}),
      ...(props.open ? { borderColor: ui().faint } : {}),
    }),
  });
  return (
    <View {...look}>
      <Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: props.title ? ui().text : ui().muted }}>
        {props.title ?? props.placeholder}
      </Text>
      {/* With a clear button over the end (`cleanable`), an empty lane as wide as the button in place of the chevron. */}
      <View
        class="transition-transform duration-150 motion-reduce:transition-none"
        style={{
          width: props.clearable ? 20 : 10,
          height: 20,
          alignItems: "center",
          justifyContent: "center",
          opacity: props.clearable ? 0 : 1,
          transform: [{ rotate: props.open ? "270deg" : "90deg" }],
        }}
      >
        <Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
      </View>
    </View>
  );
}

/**
 * The calendar's items (`itemClass`): pressed at 70%; `group`, so the
 * drawing inside follows the item's states.
 */
const itemClass = "group active:opacity-70";

/**
 * An item of the calendar, drawn once from what it is, its states the
 * item's classes: Previous and Next as arrows (faint while they take no
 * press), the month and year toggles (or each month's title, with `months`
 * over one) by name, the open view's toggle in the accent, the weekdays'
 * initials, and the days: today on the theme's quiet `accent` fill
 * (gpui-component's), the selected date and a range's ends filled, the days
 * of a complete range on a band from end to end (`data-range-*`), the other
 * months' days and the disabled ones faint, the keyboard's day ringed.
 */
function Item(props: { item: CalendarEntry; months?: number }) {
  const kind = props.item.kind;
  if (kind === "previous" || kind === "next") {
    return (
      <View class="group-disabled:opacity-30" style={{ width: cell, height: cell, alignItems: "center", justifyContent: "center" }}>
        <Text style={{ fontSize: 20, color: ui().muted }}>{kind === "previous" ? "‹" : "›"}</Text>
      </View>
    );
  }
  if (kind === "monthToggle" || kind === "yearToggle") {
    // A month's title in a calendar of several takes no press: the month and its year, over the month.
    const title = kind === "monthToggle" && (props.months ?? 1) > 1;
    return (
      <Text
        class="text-foreground group-selected:text-brand"
        style={{
          width: !desktop ? toggleWidth() : title ? 6 * cell + monthGap / 2 : kind === "monthToggle" ? 3 * cell : 2 * cell,
          paddingHorizontal: 3,
          textAlign: title ? "center" : kind === "monthToggle" ? "right" : "left",
          fontSize: size.label,
          fontWeight: "600",
        }}
      >
        {title ? `${MONTHS[props.item.value - 1]} ${props.item.year}` : kind === "monthToggle" ? MONTHS[props.item.value - 1] : String(props.item.value)}
      </Text>
    );
  }
  if (kind === "weekday") {
    return <Text style={{ minWidth: cell, paddingVertical: 4, textAlign: "center", fontSize: 11, color: ui().faint }}>{WEEKDAYS[props.item.value]}</Text>;
  }
  if (kind === "month" || kind === "year") {
    // The month view's months (rows of 3) and the year view's years (rows of 5), as wide as a month; the shown one
    // filled.
    return (
      <View
        class="rounded-lg group-selected:bg-brand-fill"
        style={{ minWidth: (7 * cell) / (kind === "month" ? 3 : 5), height: cell + 6, alignItems: "center", justifyContent: "center" }}
      >
        <Text class="text-foreground group-selected:text-on-brand" style={{ fontSize: controlText }}>
          {kind === "month" ? MONTHS[props.item.value - 1].slice(0, 3) : String(props.item.value)}
        </Text>
      </View>
    );
  }
  // A day, as wide as its column (the calendar's columns share its width). The band spans a range's middle days and
  // reaches from its ends' middles; a range of one day has none. The circle fills for the selected date and a
  // range's ends (not its middle days), else for today.
  const own = props.item.outside ? "text-faint" : "text-foreground";
  return (
    <View style={{ minWidth: cell, height: cell, alignItems: "center", justifyContent: "center" }}>
      <View
        class={
          "absolute inset-y-0.5 left-0 right-0 hidden bg-brand-tint group-data-range-middle:flex group-data-range-start:flex " +
          "group-data-range-start:left-1/2 group-data-range-end:flex group-data-range-end:right-1/2 group-data-range-start:group-data-range-end:hidden"
        }
      />
      <View
        class={
          `${props.item.today ? "bg-accent" : ""} border-[1.5px] border-transparent group-focus:border-info ` +
          "group-selected:bg-brand-fill group-data-range-middle:bg-transparent"
        }
        style={{ width: cell - 4, height: cell - 4, borderRadius: (cell - 4) / 2, alignItems: "center", justifyContent: "center" }}
      >
        <Text
          class={
            `${own} ${props.item.today ? "font-semibold" : "font-normal"} group-selected:font-semibold group-selected:text-on-brand ` +
            `group-data-range-middle:${own} group-disabled:text-faint group-disabled:opacity-50`
          }
          style={{ fontSize: controlText, fontVariant: ["tabular-nums"] }}
        >
          {String(props.item.value)}
        </Text>
      </View>
    </View>
  );
}

/**
 * A segment of the time: filled while it has the keys (`focus:` on the
 * segment, `segmentClass`), its digits or AM / PM in the color on the fill
 * (`group-focus:`), so moving between the segments draws nothing.
 */
const segmentClass = "group rounded active:opacity-70 focus:bg-brand-fill";
function Segment(props: { segment: TimeFieldSegmentEntry }) {
  return (
    <Text class="text-foreground group-focus:text-on-brand" style={{ paddingHorizontal: 3, fontSize: controlText, fontVariant: ["tabular-nums"] }}>
      {props.segment.label}
    </Text>
  );
}

/** A label over its control, a quieter line under the label. */
function Field(props: { label: string; detail: string; children: JSX.Element }) {
  return (
    <View style={{ gap: 8 }}>
      <View style={{ gap: 2 }}>
        <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
        <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
      </View>
      {props.children}
    </View>
  );
}

function App() {
  const due = signal<string | null>(null);
  const stay = signal<DatePickerValue>(null);
  const meeting = signal<string | null>(null);
  const cycle = signal<HourCycle>("h12");
  const dueOpen = signal(false);
  const stayOpen = signal(false);
  const meetingOpen = signal(false);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 8));
  const opened = (open: (next: boolean) => void) => ({ open: next }: { open: boolean; reason: OpenChangeReason }) => open(next);
  // The presets from today.
  const today = new Date();
  const monday = addDays(today, ((8 - today.getDay()) % 7) || 7);
  const dues: DateRangePreset[] = [
    { label: "Today", value: iso(today) },
    { label: "Tomorrow", value: iso(addDays(today, 1)) },
    { label: "Next Monday", value: iso(monday) },
  ];
  const stays: DateRangePreset[] = [
    { label: "This week", value: [iso(today), iso(addDays(today, (7 - today.getDay()) % 7))] },
    { label: "Next 7 nights", value: [iso(today), iso(addDays(today, 7))] },
  ];
  const stayTitle = () => {
    const r = stay();
    return Array.isArray(r) && r[0] && r[1] ? `${monthDay(r[0])} – ${monthDay(r[1])}, ${r[1].slice(0, 4)}` : undefined;
  };
  const meetingTitle = () => {
    const v = meeting();
    return v ? `${monthDay(v)}, ${v.slice(0, 4)} · ${clock(v.slice(11, 16), cycle())}` : undefined;
  };
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>date-picker</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Arrows move between days, Return picks; the trigger takes Delete to clear" : "Tap a day; the meeting's time takes digits"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value">
              <Text style={{ fontSize: 13, color: ui().text }}>{`due ${JSON.stringify(due())}`}</Text>
              <Text style={{ fontSize: 13, color: ui().text }}>{`stay ${JSON.stringify(stay())}`}</Text>
              <Text style={{ fontSize: 13, color: ui().text }}>{`meeting ${JSON.stringify(meeting())}`}</Text>
            </Panel>
            <Segmented label="hourCycle" options={["h12", "h23"] as const} value={cycle()} onChange={(v) => cycle.set(v)} />
            <Log title="onChange" lines={events()} minHeight={120} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: 16, gap: 20 }}>
          <Field label="Due" detail="Weekdays only, presets beside the calendar, a clear button">
            <DatePicker
              value={due()}
              onChange={({ value }) => {
                due.set(typeof value === "string" ? value : null);
                log(`Due → ${JSON.stringify(value)}`);
              }}
              onOpenChange={opened((next) => dueOpen.set(next))}
              accessibilityLabel="Due"
              disabledMatcher={[0, 6]}
              presets={dues}
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              contentStyle={content}
              presetsStyle={presets()}
              presetStyle={preset()}
              presetClass="active:opacity-70"
              calendarStyle={calendar}
              headerStyle={{ marginBottom: 4 }}
              itemClass={itemClass}
              renderItem={(item) => <Item item={item} />}
              style={desktop ? { width: 260 } : undefined}
              cleanable
              clearButtonAccessibilityLabel="Clear the due date"
              clearButtonClass="absolute items-center justify-center rounded-full enabled:hover:opacity-70"
              clearButtonStyle={{ top: (size.control - 20) / 2, right: 10, width: 20, height: 20 }}
              clearButton={<Icon name="close" size={14} tint={ui().muted} />}
              trigger={<Trigger title={due() ? `${monthDay(due()!)}, ${due()!.slice(0, 4)}` : undefined} placeholder="No due date" open={dueOpen()} clearable={due() !== null} />}
            />
          </Field>
          <Field label="Stay" detail={wide() ? "A range over two months: the first press starts it" : "A range: the first press starts it, the second ends it"}>
            <DatePicker
              range
              value={stay()}
              onChange={({ value }) => {
                stay.set(value);
                log(`Stay → ${JSON.stringify(value)}`);
              }}
              onOpenChange={opened((next) => stayOpen.set(next))}
              accessibilityLabel="Stay"
              numberOfMonths={wide() ? 2 : 1}
              firstDayOfWeek={1}
              presets={stays}
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              contentStyle={content}
              presetsStyle={presets()}
              presetStyle={preset()}
              presetClass="active:opacity-70"
              calendarStyle={calendar}
              headerStyle={{ marginBottom: 4 }}
              bodyStyle={{ gap: monthGap }}
              itemClass={itemClass}
              renderItem={(item) => <Item item={item} months={wide() ? 2 : 1} />}
              trigger={<Trigger title={stayTitle()} placeholder="Check-in – check-out" open={stayOpen()} />}
            />
          </Field>
          <Field label="Meeting" detail={desktop ? "A day, then its time; the day again closes the calendar" : "A tap picks the day; open it again for the time"}>
            <DatePicker
              value={meeting()}
              onChange={({ value }) => {
                meeting.set(typeof value === "string" ? value : null);
                log(`Meeting → ${JSON.stringify(value)}`);
              }}
              onOpenChange={opened((next) => meetingOpen.set(next))}
              accessibilityLabel="Meeting"
              timePrecision="minute"
              hourCycle={cycle()}
              defaultTime="09:00"
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              calendarStyle={calendar}
              headerStyle={{ marginBottom: 4 }}
              itemClass={itemClass}
              renderItem={(item) => <Item item={item} />}
              timeStyle={time()}
              timeLabel={<Text style={{ fontSize: controlText, color: ui().muted }}>Time</Text>}
              timeAccessibilityLabel="Meeting time"
              timeFieldClass={timeField}
              segmentClass={segmentClass}
              separatorStyle={{ fontSize: controlText, color: ui().faint }}
              renderSegment={(segment) => <Segment segment={segment} />}
              trigger={<Trigger title={meetingTitle()} placeholder="Not scheduled" open={meetingOpen()} />}
            />
          </Field>
        </View>
      </Split>
    </View>
  );
}

mount(App);

The calendar owns the behavior: the value (the app’s, or its own), the shown month, the views, the days that cannot be picked, the keyboard and what screen readers hear. The look is the app’s: it styles the header, the body, each month, each row and each item, and draws the items’ content (the months’ and weekdays’ names, today’s mark, the range’s band). The grid fills the width it is given, as a table’s columns do: the months share it and the items of a row share the row equally, so a calendar in a phone’s sheet spans the screen and one in a popup sized to its content keeps its items’ widths (flex-none in itemClass or monthClass keeps an item or a month at its own width).

use nsh_base::{CalendarEntry, CalendarItemKind, Date, Matcher, calendar};
use nsh_core::prelude::*;

const MONTHS: [&str; 12] = [
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
];
const WEEKDAYS: [&str; 7] = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];

fn due_date() -> impl IntoElement {
    let due = signal(Date::default());
    calendar()
        .value(move || due.get())
        .on_change(move |next: &Date| due.set(*next))
        .disabled_matcher(Matcher::DayOfWeek(vec![0, 6]))
        .previous_accessibility_label("Previous month")
        .next_accessibility_label("Next month")
        .item_class("rounded-md selected:bg-primary selected:text-primary-foreground disabled:opacity-50")
        .render_item(|item: &CalendarEntry| {
            let label = match item.kind {
                CalendarItemKind::Previous => "‹".to_string(),
                CalendarItemKind::Next => "›".to_string(),
                CalendarItemKind::Weekday => WEEKDAYS[item.value as usize].to_string(),
                CalendarItemKind::MonthToggle | CalendarItemKind::Month => {
                    MONTHS[item.value as usize - 1].to_string()
                }
                _ => item.value.to_string(),
            };
            view()
                .w_8()
                .h_8()
                .items_center()
                .justify_center()
                .child(text(label).text_sm())
        })
}

Calendar comes from nsh/base, the nsh-base crate in Rust. In Rust, calendar() makes one with a setter per prop (.value(..), .range(..), .number_of_months(..)) and a listener per event (.on_change(..)). Its value is a Date (Date::Single, Date::Range, from a chrono::NaiveDate or a pair of them), the days it disables a Matcher.

#Value

The value is an ISO date, "2026-10-28", as an HTML <input type="date">’s; with range it is [start, end], and null is no date. With value, the app owns it: the calendar shows it as selected and reports a pick with onChange({ value }); the app sets value to follow it. Left out, the calendar keeps its own value, from defaultValue at first.

onChange reports complete values only, as gpui-base’s Selected: a pressed day, or a range’s end.

  • Without range, a press on a day picks it.
  • With range, the first press starts the range and the second ends it, the days between them in the range (each selected, its ends included, see Items). A second press on an earlier day starts the range over from it, as does a press once the range is complete. A half range shows until it is complete and only then is reported.

The calendar opens on the month of the value (a range’s start), else today’s.

#Months and views

numberOfMonths shows that many months side by side, from the shown one on; Previous and Next turn them one month at a time. firstDayOfWeek is the day the weeks start on, 0 Sunday (the default) to 6 Saturday.

The header holds Previous, two toggles and Next:

  • The day view shows the months’ days, each month a column of its weekdays’ row and its weeks; the days of the months before and after fill the weeks (outside, data-outside).
  • The month toggle (monthToggle) opens the month view: the twelve months, in rows of three. Picking one shows it in the day view.
  • The year toggle (yearToggle) opens the year view: a page of 20 years, in rows of five, Previous and Next turning the page. Picking one shows that year in the day view. yearRange is the years the pages run through, [start, end) (default: 50 years before this one to 50 after).

With several months, the header shows each month’s title instead of the toggles (a monthToggle item that takes no press), as gpui-base’s.

#Disabled dates

disabledMatcher is the days the user cannot pick, gpui-base’s Matcher, in one of four forms:

FormDisables
[0, 6]Those days of the week, 0 Sunday to 6 Saturday.
{ before, after }The days before before and after after (either left out).
{ from, to }The days from from to to, inclusive (either left out is open).
(date) => booleanThe days for which the function returns true, given each shown day’s ISO date.

The function is asked once per shown day, in the next JS turn, and not again for a day it answered: turning the months asks only about the days they bring, so its answer is a pure function of the date. Passing another function asks again. A disabled day is disabled:: it takes no press, and Return and Space do not pick it.

#Items

Every press target and label of the calendar is an item: Previous, the toggles, Next, the weekdays, the days, the months and the years. Each is drawn in a part styled by itemClass and itemStyle, whose variants follow its state:

VariantThe item
selected:Is selected: the date, every day of a complete range (its ends included, as React Aria’s range calendar), a half range’s start, the shown month or year, the open view’s toggle. Screen readers hear it selected.
disabled:Is unavailable: a disabled date, Previous and Next in the month view or at the ends of the years. The weekdays and the months’ titles are labels, which screen readers hear as column headers and headers: they take no press and keep their own look.
focus:Has the keyboard.
data-range-start:, data-range-end:Is a complete range’s first or last day (a range of one day is both).
data-range-middle:Is a day inside a complete range, not an end.
data-today:Is today.
data-outside:Is a day of the month before or after, filling its weeks.

The data-* variants are Tailwind’s (data-range-start: matches [data-range-start]), as shadcn’s calendar styles its days; with group in itemClass, the content follows them with group-selected:, group-data-range-middle:, and so on. selected: means every day of a range, so a range’s ends take their look over it (later variants win: data-* comes after selected:), and its middle days theirs:

<Calendar
  range
  itemClass="group rounded-md selected:bg-primary selected:text-primary-foreground data-range-middle:rounded-none data-range-middle:bg-accent data-range-middle:text-accent-foreground data-range-start:rounded-r-none data-range-end:rounded-l-none data-range-start:data-range-end:rounded-md"
/>

renderItem draws an item’s content from what it is, a CalendarEntry, never from its state: the drawing is made once and kept while the item is selected, focused or put in a range, so the keys, a pick and the focus draw nothing again, and a turn of the months draws the days it brings once each (a day keeps its drawing by its month and date: with several months, the months still shown keep theirs).

FieldMeaning
kind"previous", "monthToggle", "yearToggle", "next", "weekday", "day", "month" or "year".
valueIts number: the day of the month, the month (1 to 12), the year, the day of the week (0 Sunday to 6); 0 for Previous and Next.
dateA day’s ISO date.
year, monthThe year and month it belongs to: a day’s (the month whose weeks show it), a month toggle’s or title’s, a month’s of the month view; a year toggle’s and a year’s own year. 0 for Previous, Next and the weekdays (and month for the year toggle and the years).
todayThe day is today.
outsideThe day is of the month before or after.

Without renderItem, an item shows its number, and Previous and Next show ‹ and ›, as gpui-base’s; a weekday shows its number too, 0 to 6, since the names of the months and weekdays are the app’s words in the app’s language.

#Props

PropRustTypeDefaultDescription
value.value(..)CalendarValuenoneThe value, owned by the app.
defaultValue.default_value(..)CalendarValuenullThe value at first, without value.
range.range(..)booleanfalsePick a range: the first press starts it, the second ends it.
numberOfMonths.number_of_months(..)number1How many months show side by side.
firstDayOfWeek.first_day_of_week(..)number0The day the weeks start on, 0 Sunday to 6 Saturday.
disabledMatcher.disabled_matcher(..)DateMatchernoneThe dates that cannot be picked.
yearRange.year_range(..)[number, number]50 years either wayThe years the year view pages through, [start, end).
previousAccessibilityLabel.previous_accessibility_label(..)stringnoneWhat screen readers say for Previous.
nextAccessibilityLabel.next_accessibility_label(..)stringnoneWhat screen readers say for Next.
headerClass, headerStyle.header_class(..), .header_style(..)string, StyleProp<ViewStyle>noneThe header: Previous, the toggles (or the months’ titles), Next.
bodyClass, bodyStyle.body_class(..), .body_style(..)string, StyleProp<ViewStyle>noneThe body: the months side by side, or the month or year view’s rows.
monthClass, monthStyle.month_class(..), .month_style(..)string, StyleProp<ViewStyle>noneEach month of the day view.
rowClass, rowStyle.row_class(..), .row_style(..)string, StyleProp<ViewStyle>noneEach row: the weekdays, a week, a row of months or years.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>noneEach item: selected:, disabled:, focus:; a day’s data-range-start:, data-range-end:, data-range-middle:, data-today:, data-outside: (Items).
renderItem.render_item(..)(item: CalendarEntry) => JSX.ElementnoneAn item’s content, drawn by the app from what the item is; its number otherwise.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe calendar: the header above the body.

#Events

EventRustDescription
onChange.on_change(|value| ..)The user picked a complete value: { value }, a day or a range. Set value to follow it. A value the app passes is never reported.

#Parts

PartWhereStyled with
The calendarthe rootclass, style
The headerthe calendar, firstheaderClass, headerStyle
The bodythe calendar, after the headerbodyClass, bodyStyle
A monththe body (day view)monthClass, monthStyle
A rowa month, or the body (month and year views)rowClass, rowStyle
An itemthe header and the rowsitemClass, itemStyle, renderItem (group-selected:, group-data-range-middle:, … inside, under group in itemClass)

#Keyboard

The days are one stop of Tab: it lands on the selected day, else today, else the first shown day, passing over disabled days, which take no focus. Then:

KeyAction
Left / RightThe day before or after.
Up / DownThe same day a week before or after.
Home / EndThe first or last day of the week.
Page Up / Page DownThe same day a month before or after.
Shift+Page Up / Shift+Page DownThe same day a year before or after.
Return, SpacePicks the day.

In a right-to-left layout the weeks run from the right, so Left is the day after and Right the day before; Up and Down keep the weeks. A key that reaches a disabled day goes on to the next day that is not, in the direction it moves (Home and End back toward the day they left). Moving past the shown months turns them. Previous, the toggles, Next, the months and the years take presses. gpui-base’s days take presses only; the keys are NSH’s, after the WAI-ARIA date picker dialog.

#Accessibility

  • Each day is a button named by its ISO date, selected while it is the date or a day of the range (its ends included, as React Aria’s range calendar), dimmed while disabled.
  • Previous and Next are buttons named by previousAccessibilityLabel and nextAccessibilityLabel; the toggles, months and years are buttons named by what they show.

See Accessibility for labels, roles and announcements in general.