Components

Date field

A single calendar date, typed in whatever form comes to hand or stepped with the keys.

Markdown

A date field takes a single calendar date, typed in whatever form comes to hand, stepped with the keys, or chosen from a calendar.

A stay

Type 12/24, dec 24 or tomorrow; step a day with the arrows, a week with Shift, the month with the caret in it. Leaving cannot be before arriving. The day of the week is shown with each date, short or long, or hidden.

Day of the week
Stay3 nights
Code
"use client";

import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { DateField } from "@publictheta/ui/components/date-field/date-field.tsx";
import { dateOfDay, dayNumber } from "@publictheta/ui/primitives/calendar.ts";
import { localToday } from "@publictheta/ui/primitives/date-type.ts";
import { useId, useState } from "react";
import { Controls } from "../../ui/controls.tsx";

const plus = (date: string, count: number) => dateOfDay(dayNumber(date) + count) ?? date;

type Weekday = "none" | "short" | "long";

export default function Stay() {
  const ids = { arrive: useId(), leave: useId() };
  const [today] = useState(localToday);
  const [arrive, setArrive] = useState<string | null>(() => plus(today, 7));
  const [leave, setLeave] = useState<string | null>(() => plus(today, 10));
  const [shown, setShown] = useState<Weekday>("short");
  const nights = arrive !== null && leave !== null ? dayNumber(leave) - dayNumber(arrive) : null;
  // The day of the week, shown with the date: what a stay is planned around.
  const weekday = shown === "none" ? undefined : shown;

  return (
    <div className="w-full">
      <Controls>
        <RadioGroup label="Day of the week" size="small" value={shown} onValueChange={setShown}>
          <Radio value="none">Hidden</Radio>
          <Radio value="short">Short</Radio>
          <Radio value="long">Long</Radio>
        </RadioGroup>
      </Controls>
      <div className="mx-auto grid w-full max-w-sm grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2 text-sm">
        <label htmlFor={ids.arrive} className="text-fg-muted">
          Arrive
        </label>
        <DateField
          id={ids.arrive}
          locale="en-US"
          weekday={weekday}
          minimum={today}
          value={arrive}
          onValueChange={(next) => {
            setArrive(next);
            // Leaving must stay after arriving; keep the length of the stay.
            if (next !== null && leave !== null && leave <= next)
              setLeave(plus(next, Math.max(nights ?? 1, 1)));
          }}
        />
        <label htmlFor={ids.leave} className="text-fg-muted">
          Leave
        </label>
        <DateField
          id={ids.leave}
          locale="en-US"
          weekday={weekday}
          minimum={arrive === null ? today : plus(arrive, 1)}
          value={leave}
          onValueChange={setLeave}
        />
        <span className="text-fg-muted">Stay</span>
        <output className="font-medium text-fg-strong">
          {nights === null ? "—" : `${nights} ${nights === 1 ? "night" : "nights"}`}
        </output>
      </div>
    </div>
  );
}

A deadline

Open the calendar from the button, or with Alt and Down Arrow: it opens on the date in the field. Move with the arrows, Page Up and Page Down; only the next ninety days can be chosen. Change the language to change the week.

Language
in 3 days
Code
"use client";

import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { DateField } from "@publictheta/ui/components/date-field/date-field.tsx";
import type { DateFieldMessages } from "@publictheta/ui/components/date-field/date-field.tsx";
import { dateOfDay, dayNumber } from "@publictheta/ui/primitives/calendar.ts";
import { localToday } from "@publictheta/ui/primitives/date-type.ts";
import { useId, useState } from "react";
import { Controls } from "../../ui/controls.tsx";

interface Language {
  readonly name: string;
  readonly label: string;
  readonly direction: "ltr" | "rtl";
  /** What the calendar says; English when left out. */
  readonly messages?: DateFieldMessages;
}

const LANGUAGES = {
  "en-US": { name: "English (US)", label: "Due", direction: "ltr" },
  "en-GB": { name: "English (UK)", label: "Due", direction: "ltr" },
  "ja-JP": {
    name: "日本語",
    label: "期限",
    direction: "ltr",
    messages: {
      // The field's name comes first in Japanese: the sentence is the translator's to order.
      chooseDate: (field) => (field === undefined ? "日付を選択" : `${field}の日付を選択`),
      previousMonth: "前の月",
      nextMonth: "次の月",
    },
  },
  "ar-EG": {
    name: "العربية",
    label: "تاريخ الاستحقاق",
    direction: "rtl",
    messages: {
      chooseDate: (field) => (field === undefined ? "اختيار التاريخ" : `اختيار التاريخ، ${field}`),
      previousMonth: "الشهر السابق",
      nextMonth: "الشهر التالي",
    },
  },
} satisfies Record<string, Language>;

type Locale = keyof typeof LANGUAGES;

const plus = (date: string, days: number) => dateOfDay(dayNumber(date) + days) ?? date;

export default function Deadline() {
  const id = useId();
  const [today] = useState(localToday);
  const [locale, setLocale] = useState<Locale>("en-US");
  const [due, setDue] = useState<string | null>(() => plus(today, 3));
  const language: Language = LANGUAGES[locale];
  const away = due === null ? null : dayNumber(due) - dayNumber(today);

  return (
    <div className="w-full">
      <Controls>
        <RadioGroup label="Language" size="small" value={locale} onValueChange={setLocale}>
          {(Object.keys(LANGUAGES) as Locale[]).map((key) => (
            <Radio key={key} value={key}>
              {LANGUAGES[key].name}
            </Radio>
          ))}
        </RadioGroup>
      </Controls>
      <div
        lang={locale}
        dir={language.direction}
        className="mx-auto grid max-w-xs grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2 text-sm"
      >
        <label htmlFor={id} className="text-fg-muted">
          {language.label}
        </label>
        <DateField
          id={id}
          locale={locale}
          weekday="short"
          minimum={today}
          maximum={plus(today, 90)}
          value={due}
          onValueChange={setDue}
          {...(language.messages !== undefined && { messages: language.messages })}
        />
        <span />
        <output aria-live="polite" className="text-fg-muted">
          {away === null ? "—" : new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(away, "day")}
        </output>
      </div>
    </div>
  );
}

When to use

Use a date field when people know the date — a birthday, a deadline they were given — and typing is quickest. Typing stays the first way to give a date; the calendar beside the field is for when the choice depends on the calendar itself: which day of the week, how far from another date.

Best practices

Accept dates as people write them rather than demanding a format. A field that reads 10/20, oct 20 and 2026-10-20 alike needs no instructions; see Dates for what should be read.

Show the date in full once it is committed, in the locale's form, so a partial or ambiguous entry is confirmed back to the person who typed it. Where the day of the week matters, show it with the date.

State a range only where there is one. A date outside the range stays as typed and is marked invalid, with whether it is too early or too late, so people see the limit and no date is chosen for them.

Label the field with what the date is, not with a format.

Offer a calendar, and keep it out of the way. A button at the end of the field opens it; nothing opens by itself when the field takes focus, so typing is never interrupted. Name the button for the field it belongs to — a form with two dates has two such buttons.

Open the calendar where the field is. On the field's date; on a date that is typed and not yet committed; with neither, on today. See Calendars for how the month is laid out and moved through.

Choosing a day is giving the date. It commits at once, as Enter does on a typed date, and closes the calendar; whatever was in the field — text half typed, text that could not be read — is replaced. Moving through the days chooses nothing, and Escape or a press outside leaves the field as it was.

Put people back where the date is. After a day is chosen, focus returns to the field, which now reads the date out and can be adjusted with the keys. After the calendar is dismissed, focus returns to the button that opened it.

A field that cannot be changed offers no calendar. Read-only or disabled, its button is unavailable and is passed over by Tab.

Send a form the date, not the text. A named field submits the date in the one form every system shares, 2026-10-13, whatever the locale shows. The calendar's button never submits the form.

Interaction

IntentPointerKeyboardTouch
Give a date—Type it; Return, or leaving the field, commitsType it
Step the date—Up and Down Arrow; Shift for a week; Page Up and Down—
Open the calendarClick the buttonAlt-Down Arrow in the field; Return or Space on the buttonTap the button
Move through the days—Arrow keys, Home, End, Page Up and Down, with Shift—
Show another monthClick the buttons by the titlePage Up, Page DownTap the buttons by the title
Choose a dayClick itReturn or SpaceTap it
Close without choosingClick outside, or the buttonEscape; Tab also moves onTap outside

Platform considerations

Keyboard. Up and Down Arrow move the date by a day, or move the month or the year when the caret is in that part; Shift moves by a week; Page Up and Page Down by a month. With a part selected, Left and Right Arrow move to the part beside it. An empty field's first step is to today. Alt with Down Arrow opens the calendar, as it opens a platform's own date fields. Editing follows Editing values.

Pointer. The calendar opens beneath the field as a popover, aligned with the field's start, and a press outside it both closes it and acts on what was pressed.

Touch. The field and its button are at least 44 CSS pixels tall. The button opens the platform's own date picker, given the field's date and its range; the date chosen there becomes the field's date. Where the platform has no picker to show, the calendar opens instead — on a phone, as a sheet along the bottom edge. After a day is chosen focus goes to the button, not the field, so the keyboard does not rise over the date just chosen.

Assistive technology. The field is a text field with its name; the date is its text, and an entry that is not a date marks it invalid. The button states that it opens a dialog and whether it is open; the calendar is a dialog of the same name, holding a grid of days named by its month. The platform's picker is reached only through the button: it is not a second field to tab to or to have read out.

See also Calendars, Dates and Overlays.

Behaviour

Semantics

Is a text field with its name, showing the date as the locale writes it
const screen = await render(<Due />);
await expect.element(field(screen)).toHaveValue("Oct 13, 2026");
Has no accessibility violations, valid or invalid
const screen = await render(<Due />);
await expectAccessible(screen.container);
await commit(screen, "2/30");
await expect.element(field(screen)).toHaveAttribute("aria-invalid", "true");
await expectAccessible(screen.container);
Beside the field is a button named for it, which states that it opens a dialog and whether it is open
const screen = await render(<Due />);
await expect.element(button(screen)).toHaveAttribute("aria-haspopup", "dialog");
await expect.element(button(screen)).toHaveAttribute("aria-expanded", "false");
await open(screen);
await expect.element(button(screen)).toHaveAttribute("aria-expanded", "true");
A field named by a label gives its button the label's name
const screen = await render(
  <label>
    Due
    <DateField locale="en-US" today={today} />
  </label>,
);
await expect.element(button(screen)).toBeVisible();
The calendar is a dialog with the button's name, holding a grid of days named for its month
const screen = await render(<Due />);
await open(screen);
await expect.element(calendar(screen)).toBeVisible();
await expect.element(screen.getByRole("grid", { name: "October 2026" })).toBeVisible();
expect(grid(screen).querySelectorAll("[data-date]")).toHaveLength(42);
Each day is named in full, with its day of the week
const screen = await render(<Due />);
await open(screen);
await expect
  .element(screen.getByRole("gridcell", { name: "Tuesday, October 13, 2026" }))
  .toHaveTextContent("13");
The day chosen is marked as selected, and today as the current date
const screen = await render(<Due defaultValue="2026-10-20" />);
await open(screen);
const marked = (attribute: string) =>
  Array.from(grid(screen).querySelectorAll<HTMLElement>(`[${attribute}]`), (cell) => cell.dataset.date);
expect(marked('aria-selected="true"')).toEqual(["2026-10-20"]);
expect(marked("aria-current")).toEqual(["2026-10-13"]);
expect(day(screen, "2026-10-13").getAttribute("aria-current")).toBe("date");
A change of month is said where focus does not say it: from a month button, not from a day, whose name has its month
const screen = await render(<Due />);
await open(screen);
const said = () => calendar(screen).element().querySelector('[role="status"]')?.textContent;
await userEvent.keyboard("{PageDown}");
await expect.poll(focusedDay).toBe("2026-11-13");
expect(day(screen, "2026-11-13").getAttribute("aria-label")).toMatch(/November/);
expect(said()).toBe("");
await userEvent.click(screen.getByRole("button", { name: "Next month" }));
await expect.poll(said).toBe("December 2026");
// The grid is named by the month's title, so the month is also what the grid is called.
const title = document.getElementById(grid(screen).getAttribute("aria-labelledby")!);
expect(title?.textContent).toBe("December 2026");
The calendar has no accessibility violations
const screen = await render(<Due minimum="2026-10-05" defaultValue="2026-10-20" />);
await open(screen);
await expectAccessible(screen.container);

Typing and committing

A date typed in any of the forms the type reads commits as that date
const onValueChange = vi.fn();
const screen = await render(<Due onValueChange={onValueChange} />);
for (const [typed, shown, iso] of [
  ["10/20", "Oct 20, 2026", "2026-10-20"],
  ["2027-01-05", "Jan 5, 2027", "2027-01-05"],
  ["3 march", "Mar 3, 2026", "2026-03-03"],
  ["tomorrow", "Oct 14, 2026", "2026-10-14"],
] as const) {
  await commit(screen, typed);
  await expect.element(field(screen)).toHaveValue(shown);
  expect(onValueChange).toHaveBeenLastCalledWith(iso);
}
A Japanese field reads dates as Japanese writes them, full-width or not
const screen = await render(<Due locale="ja-JP" dateStyle="long" />);
await commit(screen, "11月3日");
await expect.element(field(screen)).toHaveValue("2026年11月3日");
await commit(screen, "明日");
await expect.element(field(screen)).toHaveValue("2026年10月14日");
A date beyond the range stays as typed, marked invalid, with which end it is beyond
const onValueChange = vi.fn();
const onInvalidChange = vi.fn();
const screen = await render(
  <Due
    minimum="2026-10-01"
    maximum="2026-10-31"
    onValueChange={onValueChange}
    onInvalidChange={onInvalidChange}
  />,
);
await commit(screen, "12/25");
await expect.element(field(screen)).toHaveValue("12/25");
await expect.element(field(screen)).toHaveAttribute("aria-invalid", "true");
expect(onInvalidChange).toHaveBeenLastCalledWith("maximum");
await commit(screen, "9/25");
expect(onInvalidChange).toHaveBeenLastCalledWith("minimum");
expect(onValueChange).not.toHaveBeenCalled();
A date that does not exist stays as typed, marked invalid
const onValueChange = vi.fn();
const screen = await render(<Due onValueChange={onValueChange} />);
await commit(screen, "2/30");
await expect.element(field(screen)).toHaveValue("2/30");
await expect.element(field(screen)).toHaveAttribute("aria-invalid", "true");
expect(onValueChange).not.toHaveBeenCalled();
The field follows the document's language when given none
document.documentElement.lang = "de-DE";
try {
  const screen = await render(<DateField aria-label="Due" defaultValue="2026-10-03" />);
  await expect.element(field(screen)).toHaveValue("03.10.2026");
} finally {
  document.documentElement.lang = "";
}

Stepping

Up and Down Arrow move the date by a day
const screen = await render(<Due />);
await userEvent.click(field(screen));
element(screen).select();
await userEvent.keyboard("{ArrowUp}{ArrowUp}{ArrowDown}");
await expect.element(field(screen)).toHaveValue("Oct 14, 2026");
With the caret in the month or the year, they move that part
const screen = await render(<Due />);
await userEvent.click(field(screen));
element(screen).setSelectionRange(9, 9);
await userEvent.keyboard("{ArrowUp}");
await expect.element(field(screen)).toHaveValue("Oct 13, 2027");
element(screen).setSelectionRange(0, 0);
await userEvent.keyboard("{ArrowDown}");
await expect.element(field(screen)).toHaveValue("Sep 13, 2027");
With Shift they move by a week; Page Up and Page Down move by a month
const screen = await render(<Due />);
await userEvent.click(field(screen));
element(screen).select();
await userEvent.keyboard("{Shift>}{ArrowUp}{/Shift}");
await expect.element(field(screen)).toHaveValue("Oct 20, 2026");
element(screen).select();
await userEvent.keyboard("{PageDown}");
await expect.element(field(screen)).toHaveValue("Sep 20, 2026");
An empty field's first step is to today
const screen = await render(<Due defaultValue={null} />);
await userEvent.click(field(screen));
await userEvent.keyboard("{ArrowUp}");
await expect.element(field(screen)).toHaveValue("Oct 13, 2026");
await userEvent.keyboard("{ArrowUp}");
await expect.element(field(screen)).toHaveValue("Oct 14, 2026");
With a part selected, the arrows to either side move from part to part
const screen = await render(<Due />);
await userEvent.click(field(screen));
element(screen).setSelectionRange(1, 1);
await userEvent.keyboard("{ArrowUp}{ArrowRight}{ArrowUp}");
await expect.element(field(screen)).toHaveValue("Nov 14, 2026");
await userEvent.keyboard("{ArrowRight}{ArrowDown}{ArrowLeft}{ArrowLeft}{ArrowDown}");
await expect.element(field(screen)).toHaveValue("Oct 14, 2025");
A caret at the very end of the text is in no part: the arrows step the day
const screen = await render(<Due />);
await userEvent.click(field(screen));
element(screen).setSelectionRange(12, 12);
await userEvent.keyboard("{ArrowUp}");
await expect.element(field(screen)).toHaveValue("Oct 14, 2026");

Opening the calendar

It opens on the field's date, with focus on that day
const screen = await render(<Due defaultValue="2026-12-25" />);
await open(screen);
await expect.element(screen.getByRole("grid", { name: "December 2026" })).toBeVisible();
expect(focusedDay()).toBe("2026-12-25");
With no date in the field, it opens on today, with no day chosen
const screen = await render(<Due defaultValue={null} />);
await open(screen);
expect(focusedDay()).toBe("2026-10-13");
expect(grid(screen).querySelector('[aria-selected="true"]')).toBeNull();
With a date typed and not yet committed, it opens on the date typed, which is then committed
const screen = await render(<Due />);
if (hasTouch) withoutPlatformPicker();
await userEvent.fill(field(screen), "11/5");
// From the field itself, where nothing has yet read what was typed.
await userEvent.keyboard("{Alt>}{ArrowDown}{/Alt}");
await expect.poll(() => isOpen(screen)).toBe(true);
expect(focusedDay()).toBe("2026-11-05");
await expect.element(field(screen)).toHaveValue("Nov 5, 2026");
A date beyond the range opens it on the end of the range nearest that date
const screen = await render(<Due defaultValue="2026-12-25" maximum="2026-10-20" />);
await open(screen);
expect(focusedDay()).toBe("2026-10-20");
Alt with Down Arrow in the field opens it, and does not step the date
const screen = await render(<Due />);
if (hasTouch) withoutPlatformPicker();
await userEvent.click(field(screen));
await userEvent.keyboard("{Alt>}{ArrowDown}{/Alt}");
await expect.poll(() => isOpen(screen)).toBe(true);
expect(focusedDay()).toBe("2026-10-13");
await expect.element(field(screen)).toHaveValue("Oct 13, 2026");
Pressing the button again closes itChromium, Firefox, WebKit

Not yet written.

Escape closes it and chooses nothing; focus returns to the button
const onValueChange = vi.fn();
const screen = await render(<Due onValueChange={onValueChange} />);
await open(screen);
await userEvent.keyboard("{ArrowDown}{Escape}");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(field(screen)).toHaveValue("Oct 13, 2026");
expect(onValueChange).not.toHaveBeenCalled();
await expect.element(button(screen)).toHaveFocus();

The month shown

The days of the week head the columns, the locale's first day first
const heads = (screen: RenderResult) =>
  Array.from(grid(screen).querySelectorAll("thead th"), (head) => head.textContent);
const american = await render(<Due />);
await open(american);
expect(heads(american)).toEqual(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]);
expect(grid(american).querySelector("tbody td")).toBe(day(american, "2026-09-27"));
await american.unmount();

const british = await render(<Due locale="en-GB" />);
await open(british);
expect(heads(british)).toEqual(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]);
expect(grid(british).querySelector("tbody td")).toBe(day(british, "2026-09-28"));
The days of the weekend are marked, as the locale counts its weekend
// Thursday to Sunday.
const weekend = (screen: RenderResult) =>
  ["2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"].filter(
    (date) => day(screen, date).dataset.weekend !== undefined,
  );
const american = await render(<Due />);
await open(american);
expect(weekend(american)).toEqual(["2026-10-17", "2026-10-18"]);
await american.unmount();

// In Israel the weekend is Friday and Saturday.
const israeli = await render(<Due locale="he-IL" />);
await open(israeli);
expect(weekend(israeli)).toEqual(["2026-10-16", "2026-10-17"]);
The days of the months either side fill out the weeks, marked as outside the month, and can be chosen
const onValueChange = vi.fn();
const screen = await render(<Due onValueChange={onValueChange} />);
await open(screen);
expect(day(screen, "2026-09-30").dataset.outside).toBe("");
expect(day(screen, "2026-10-01").dataset.outside).toBeUndefined();
expect(day(screen, "2026-11-01").dataset.outside).toBe("");
await commands.clickSelector('[data-date="2026-09-30"]');
await expect.element(field(screen)).toHaveValue("Sep 30, 2026");
expect(onValueChange).toHaveBeenLastCalledWith("2026-09-30");
The buttons either side of the title show the month before and the month after
const screen = await render(<Due />);
await open(screen);
await userEvent.click(screen.getByRole("button", { name: "Next month" }));
await expect.element(screen.getByRole("grid", { name: "November 2026" })).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Previous month" }));
await userEvent.click(screen.getByRole("button", { name: "Previous month" }));
await expect.element(screen.getByRole("grid", { name: "September 2026" })).toBeVisible();
// The same day of the month shown is the one Tab would reach.
expect(grid(screen).querySelector<HTMLElement>('[tabindex="0"]')?.dataset.date).toBe("2026-09-13");
expect(isOpen(screen)).toBe(true);
Where text reads right to left, the days and the month buttons run from the right
const screen = await render(
  <div dir="rtl">
    <Due />
  </div>,
);
await open(screen);
const left = (cell: Element) => cell.getBoundingClientRect().left;
expect(left(day(screen, "2026-10-11"))).toBeGreaterThan(left(day(screen, "2026-10-12")));
expect(left(screen.getByRole("button", { name: "Previous month" }).element())).toBeGreaterThan(
  left(screen.getByRole("button", { name: "Next month" }).element()),
);
With a mouse, each day is at least 32 pixels squareChromium, Firefox, WebKit

Not yet written.

Moving through the days

The arrow keys move focus by a day and by a week, choosing nothing
const onValueChange = vi.fn();
const screen = await render(<Due onValueChange={onValueChange} />);
await open(screen);
await userEvent.keyboard("{ArrowRight}");
expect(focusedDay()).toBe("2026-10-14");
await userEvent.keyboard("{ArrowDown}");
expect(focusedDay()).toBe("2026-10-21");
await userEvent.keyboard("{ArrowLeft}{ArrowLeft}{ArrowUp}");
expect(focusedDay()).toBe("2026-10-12");
expect(onValueChange).not.toHaveBeenCalled();
await expect.element(field(screen)).toHaveValue("Oct 13, 2026");
Where text reads right to left, the arrows to either side are mirrored
const screen = await render(
  <div dir="rtl">
    <Due />
  </div>,
);
await open(screen);
await userEvent.keyboard("{ArrowLeft}");
expect(focusedDay()).toBe("2026-10-14");
await userEvent.keyboard("{ArrowRight}{ArrowRight}");
expect(focusedDay()).toBe("2026-10-12");
Moving past the end of a month shows the month moved into
const screen = await render(<Due defaultValue="2026-10-30" />);
await open(screen);
await userEvent.keyboard("{ArrowDown}");
expect(focusedDay()).toBe("2026-11-06");
await expect.element(screen.getByRole("grid", { name: "November 2026" })).toBeVisible();
// Laid out afresh: 1 November 2026 is a Sunday, and begins the grid.
expect(grid(screen).querySelector("tbody td")).toBe(day(screen, "2026-11-01"));
Home and End move to the first and last days of the week
const screen = await render(<Due />);
await open(screen);
await userEvent.keyboard("{Home}");
expect(focusedDay()).toBe("2026-10-11");
await userEvent.keyboard("{End}");
expect(focusedDay()).toBe("2026-10-17");
Page Down and Page Up move by a month; with Shift, by a year
const screen = await render(<Due />);
await open(screen);
await userEvent.keyboard("{PageDown}{PageDown}");
expect(focusedDay()).toBe("2026-12-13");
await userEvent.keyboard("{PageUp}");
expect(focusedDay()).toBe("2026-11-13");
await userEvent.keyboard("{Shift>}{PageDown}{/Shift}");
expect(focusedDay()).toBe("2027-11-13");
await userEvent.keyboard("{Shift>}{PageUp}{PageUp}{/Shift}");
expect(focusedDay()).toBe("2025-11-13");
await expect.element(screen.getByRole("grid", { name: "November 2025" })).toBeVisible();
The grid is one stop in the Tab order: the day in focus, and no otherChromium, Firefox, WebKit

Not yet written.

Choosing a day

A click on a day chooses it: the field shows it, and the calendar closes
const screen = await render(<Due />);
await open(screen);
await commands.clickSelector('[data-date="2026-10-20"]');
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(field(screen)).toHaveValue("Oct 20, 2026");
Enter chooses the day in focus, closes the calendar, and returns focus to the field
const onValueChange = vi.fn();
const screen = await render(<Due onValueChange={onValueChange} />);
await open(screen);
await userEvent.keyboard("{ArrowDown}{Enter}");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(field(screen)).toHaveValue("Oct 20, 2026");
expect(onValueChange.mock.calls).toEqual([["2026-10-20"]]);
// Under a finger, focus in the field would raise the keyboard: it goes to the button.
await expect.element(hasTouch ? button(screen) : field(screen)).toHaveFocus();
Space chooses the day in focus, as Enter does
const screen = await render(<Due />);
await open(screen);
await userEvent.keyboard("{ArrowLeft}{ }");
await expect.poll(() => isOpen(screen)).toBe(false);
// The day, and nothing of the key that chose it.
await expect.element(field(screen)).toHaveValue("Oct 12, 2026");
Days outside the range are marked unavailable, and a click on one chooses nothing
const onValueChange = vi.fn();
const screen = await render(
  <Due minimum="2026-10-10" maximum="2026-10-20" onValueChange={onValueChange} />,
);
await open(screen);
const unavailable = (date: string) => day(screen, date).getAttribute("aria-disabled");
expect([unavailable("2026-10-09"), unavailable("2026-10-10")]).toEqual(["true", null]);
expect([unavailable("2026-10-20"), unavailable("2026-10-21")]).toEqual([null, "true"]);
day(screen, "2026-10-21").click();
day(screen, "2026-10-09").click();
expect(onValueChange).not.toHaveBeenCalled();
expect(isOpen(screen)).toBe(true);
await commands.clickSelector('[data-date="2026-10-10"]');
expect(onValueChange.mock.calls).toEqual([["2026-10-10"]]);
The keys do not leave the range: a move past its end stops at the end
const screen = await render(<Due minimum="2026-10-10" maximum="2026-10-20" />);
await open(screen);
await userEvent.keyboard("{ArrowDown}{ArrowDown}");
expect(focusedDay()).toBe("2026-10-20");
await userEvent.keyboard("{PageUp}");
expect(focusedDay()).toBe("2026-10-10");
await userEvent.keyboard("{ArrowLeft}{Enter}");
await expect.element(field(screen)).toHaveValue("Oct 10, 2026");
A month with no day in the range cannot be gone to: the button towards it is unavailable
const screen = await render(<Due minimum="2026-10-10" maximum="2026-11-20" />);
await open(screen);
const previous = screen.getByRole("button", { name: "Previous month" });
const next = screen.getByRole("button", { name: "Next month" });
await expect.element(previous).toHaveAttribute("aria-disabled", "true");
await expect.element(next).not.toHaveAttribute("aria-disabled");
(previous.element() as HTMLElement).click();
await expect.element(screen.getByRole("grid", { name: "October 2026" })).toBeVisible();
await userEvent.click(next);
await expect.element(screen.getByRole("grid", { name: "November 2026" })).toBeVisible();
await expect.element(next).toHaveAttribute("aria-disabled", "true");
Text that could not be read as a date is replaced by the day chosen, even the day the field held
const onInvalidChange = vi.fn();
const screen = await render(<Due onInvalidChange={onInvalidChange} />);
await commit(screen, "2/30");
await expect.element(field(screen)).toHaveAttribute("aria-invalid", "true");
await open(screen);
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("Oct 13, 2026");
await expect.element(field(screen)).not.toHaveAttribute("aria-invalid");
expect(onInvalidChange).toHaveBeenLastCalledWith(null);

Language

In a Japanese field the calendar is in Japanese throughout, in Japanese word order
const screen = await render(
  <DateField
    aria-label="期限"
    locale="ja-JP"
    today={today}
    defaultValue="2026-10-13"
    messages={{
      chooseDate: (name) => (name === undefined ? "日付を選択" : `${name}の日付を選択`),
      previousMonth: "前の月",
      nextMonth: "次の月",
    }}
  />,
);
await expect.element(screen.getByRole("button", { name: "期限の日付を選択" })).toBeVisible();
await open(screen);
await expect.element(screen.getByRole("dialog", { name: "期限の日付を選択" })).toBeVisible();
await expect.element(screen.getByRole("grid", { name: "2026年10月" })).toBeVisible();
expect(Array.from(grid(screen).querySelectorAll("thead th"), (head) => head.textContent)).toEqual([
  "日",
  "月",
  "火",
  "水",
  "木",
  "金",
  "土",
]);
// Engines differ over a space before the weekday.
await expect.element(screen.getByRole("gridcell", { name: /^2026年10月13日 ?火曜日$/ })).toHaveFocus();
await userEvent.click(screen.getByRole("button", { name: "次の月" }));
await expect.element(screen.getByRole("grid", { name: "2026年11月" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "前の月" })).toBeVisible();
Days are numbered in the digits the locale writes
const screen = await render(<Due locale="ar-EG-u-nu-arab" />);
await open(screen);
expect(day(screen, "2026-10-13").textContent).toBe("١٣");

Forms

With a name, a form is sent the date in the form all share, whatever the locale shows
const screen = await render(
  <form aria-label="Task">
    <DateField aria-label="Due" name="due" locale="en-US" today={today} defaultValue="2026-10-13" />
  </form>,
);
const form = screen.getByRole("form").element() as HTMLFormElement;
expect(new FormData(form).getAll("due")).toEqual(["2026-10-13"]);
A day chosen from the calendar is the date a form is sent
const screen = await render(
  <form aria-label="Task">
    <Due name="due" />
  </form>,
);
await open(screen);
await userEvent.keyboard("{ArrowRight}{Enter}");
const form = screen.getByRole("form").element() as HTMLFormElement;
await expect.poll(() => new FormData(form).getAll("due")).toEqual(["2026-10-14"]);
Neither the calendar's button nor choosing a day submits a form
const onSubmit = vi.fn((event: { preventDefault: () => void }) => event.preventDefault());
const screen = await render(
  <form aria-label="Task" onSubmit={onSubmit}>
    <Due name="due" />
    <button type="submit">Save</button>
  </form>,
);
await expect.element(button(screen)).toHaveAttribute("type", "button");
await open(screen);
await userEvent.keyboard("{ArrowRight}{Enter}");
await expect.element(field(screen)).toHaveValue("Oct 14, 2026");
expect(onSubmit).not.toHaveBeenCalled();
A field that is disabled offers no calendar: its button cannot be pressed, and Tab passes it by
const screen = await render(
  <>
    <input aria-label="Before" />
    <Due disabled />
  </>,
);
await expect.element(button(screen)).toBeDisabled();
(button(screen).element() as HTMLElement).click();
expect(isOpen(screen)).toBe(false);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await expect.element(screen.getByRole("textbox", { name: "After" })).toHaveFocus();
A field that is read-only offers no calendar either, by its button or by the keyboard
const screen = await render(<Due readOnly />);
await expect.element(button(screen)).toBeDisabled();
await userEvent.click(field(screen));
await userEvent.keyboard("{Alt>}{ArrowDown}{/Alt}");
expect(isOpen(screen)).toBe(false);
// Nor did one open and close again, taking focus with it.
await expect.element(field(screen)).toHaveFocus();
await userEvent.tab();
await expect.element(screen.getByRole("textbox", { name: "After" })).toHaveFocus();
A calendar that is open closes when its field is disabled
const screen = await render(<Due />);
await open(screen);
await screen.rerender(<Due disabled />);
await expect.poll(() => isOpen(screen)).toBe(false);

Touch

On a touchscreen, the field is at least 44 pixels tallChromium · touch, WebKit · touch

Not yet written.

On a touchscreen, the button is at least 44 pixels squareChromium · touch, WebKit · touch

Not yet written.

On a touchscreen, the button opens the platform's own date picker, and no calendarChromium · touch, WebKit · touch

Not yet written.

The platform's picker is given the field's date and its range, and is kept from sight, from the keyboard and from assistive technologyChromium · touch, WebKit · touch

Not yet written.

A date chosen in the platform's picker becomes the field's date, in place of anything typedChromium · touch, WebKit · touch

Not yet written.

What was being typed is read before the platform's picker is shown, so the picker opens on itChromium · touch, WebKit · touch

Not yet written.

The platform's picker is no field of a form the date field is inChromium · touch, WebKit · touch

Not yet written.

A field that is disabled or read-only has no platform pickerChromium · touch, WebKit · touch

Not yet written.

Where the platform has no picker to show, the calendar opens instead, as a sheet along the bottom edgeChromium · touch, WebKit · touch

Not yet written.

On a touchscreen, each day of the calendar is at least 44 pixels square, and a tap chooses itChromium · touch, WebKit · touch

Not yet written.

With a mouse there is no platform picker: the button opens the calendarChromium, Firefox, WebKit

Not yet written.

API

onValueChange hears a day chosen from the calendar once, as an ISO date
const onValueChange = vi.fn();
const screen = await render(<Due onValueChange={onValueChange} />);
await open(screen);
await commands.clickSelector('[data-date="2026-10-20"]');
await expect.poll(() => onValueChange.mock.calls).toEqual([["2026-10-20"]]);
A controlled field shows the day chosen only once its owner has taken it
const onValueChange = vi.fn();
const screen = await render(
  <DateField
    aria-label="Due"
    locale="en-US"
    today={today}
    value="2026-10-13"
    onValueChange={onValueChange}
  />,
);
await open(screen);
await commands.clickSelector('[data-date="2026-10-20"]');
await expect.poll(() => onValueChange.mock.calls).toEqual([["2026-10-20"]]);
await expect.element(field(screen)).toHaveValue("Oct 13, 2026");
messages replaces what the calendar says; what is not given stays as it was
const screen = await render(<Due messages={{ nextMonth: "Month after" }} />);
await open(screen);
await expect.element(screen.getByRole("button", { name: "Month after" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "Previous month" })).toBeVisible();
weekday shows the day of the week with the date
const screen = await render(<Due weekday="short" />);
await expect.element(field(screen)).toHaveValue("Tue, Oct 13, 2026");
className and the other properties of a text field reach the field itself
const screen = await render(<Due className="text-right" placeholder="Any day" />);
await expect.element(field(screen)).toHaveClass("text-right");
await expect.element(field(screen)).toHaveAttribute("placeholder", "Any day");

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/date-field.json
Source

src/components/date-field/date-field.tsx

"use client";

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ComponentProps, ReactElement } from "react";
import { createPortal, flushSync } from "react-dom";
import { fieldClassName } from "../../lib/field-class.ts";
import { focusRingInsetClassName } from "../../lib/focus-class.ts";
import { useLocale } from "../../lib/locale.ts";
import { mergeProps } from "../../lib/merge-props.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { overlaySurfaceClassName } from "../../lib/overlay-class.ts";
import { useCoarsePointer } from "../../lib/pointer.ts";
import { cn } from "../../lib/utils.ts";
import {
  calendarMoveForKey,
  dayName,
  dayText,
  holdWithin,
  isWeekend,
  isWithin,
  monthOf,
  monthTitle,
  monthWeeks,
  moveDate,
  weekdayNames,
  weekOf,
} from "../../primitives/calendar.ts";
import type { CalendarMove, Week } from "../../primitives/calendar.ts";
import { useAnnouncer } from "../../primitives/announcements.ts";
import { dateType, isDate, localToday } from "../../primitives/date-type.ts";
import type { DateTypeOptions } from "../../primitives/date-type.ts";
import { anchoredClassName, useOverlay } from "../../primitives/overlay.ts";
import { useField } from "../../primitives/use-field.ts";

/** What the calendar says that is not a date; pass `messages` to say it in another language. */
export interface DateFieldMessages {
  /**
   * Names the button that opens the calendar, and the calendar: given the
   * field's own name where it has one, to be put where the language puts it.
   */
  readonly chooseDate: (field: string | undefined) => string;
  readonly previousMonth: string;
  readonly nextMonth: string;
}

export const DATE_FIELD_MESSAGES: DateFieldMessages = {
  chooseDate: (field) => (field === undefined ? "Choose date" : `Choose date, ${field}`),
  previousMonth: "Previous month",
  nextMonth: "Next month",
};

export type DateFieldProps = Omit<
  ComponentProps<"input">,
  "value" | "defaultValue" | "onChange" | "type" | "min" | "max"
> &
  DateTypeOptions & {
    /** The date as an ISO string, `2026-10-13`. */
    value?: string | null;
    defaultValue?: string | null;
    onValueChange?: (value: string | null) => void;
    /** Hears why what was committed is not a value, and `null` once it is. */
    onInvalidChange?: (reason: string | null) => void;
    messages?: Partial<DateFieldMessages>;
  };

/** Where focus goes when the calendar opens: the one day that is its tab stop. */
function activeDay(content: HTMLElement): HTMLElement[] {
  return Array.from(content.querySelectorAll<HTMLElement>('[data-date][tabindex="0"]'));
}

/** What names the field: its own label, the elements it says label it, or its `<label>`. */
function nameOf(input: HTMLInputElement): string | undefined {
  const own = input.getAttribute("aria-label");
  if (own !== null) return own;
  const labels = (input.getAttribute("aria-labelledby") ?? "")
    .split(/\s+/)
    .map((id) => (id === "" ? null : document.getElementById(id)));
  const text = [...labels, ...(input.labels ?? [])]
    .map((label) => label?.textContent.trim() ?? "")
    .filter((part) => part !== "")
    .join(" ");
  return text === "" ? undefined : text;
}

const iconButtonClassName = cn(
  "inline-flex shrink-0 items-center justify-center text-fg-muted select-none [-webkit-tap-highlight-color:transparent]",
  "transition-colors duration-75 motion-reduce:transition-none",
  focusRingInsetClassName,
);

/**
 * A field for a calendar date: typed in whatever form comes to hand, committed
 * on Enter or on leaving, and stepped with the arrow keys — the day, month or
 * year, whichever the caret is in. A button beside it opens a calendar, for a
 * date that is easier found than typed; under a finger it opens the platform's
 * own date picker.
 *
 * `className` and the other properties of an input go to the text field. The
 * field and its button fill the width of what contains them.
 */
export function DateField({
  value,
  defaultValue,
  onValueChange,
  locale: localeProp,
  minimum,
  maximum,
  dateStyle,
  weekday,
  today,
  required,
  onInvalidChange,
  messages: messagesProp,
  name,
  form,
  className,
  ...props
}: DateFieldProps): ReactElement {
  const locale = useLocale(localeProp);
  const type = useMemo(
    () => dateType({ locale, minimum, maximum, dateStyle, weekday, today }),
    [locale, minimum, maximum, dateStyle, weekday, today],
  );
  const field = useField({ type, value, defaultValue, onValueChange, required, onInvalidChange });
  const messages = { ...DATE_FIELD_MESSAGES, ...messagesProp };
  const week = useMemo(() => weekOf(locale), [locale]);
  const overlay = useOverlay({ focusable: activeDay });
  const coarse = useCoarsePointer();
  const input = useRef<HTMLInputElement>(null);
  const picker = useRef<HTMLInputElement>(null);
  // The day the keyboard is on. Null until the calendar is first opened: until then there is none to draw.
  const [active, setActive] = useState<string | null>(null);
  const unavailable = props.disabled === true || props.readOnly === true;
  const chosen = field.value !== null && isDate(field.value) ? field.value : null;

  const [fieldName, setFieldName] = useState<string>();
  const { id, "aria-label": label, "aria-labelledby": labelledBy } = props;
  useLayoutEffect(() => {
    // oxlint-disable-next-line react/set-state-in-effect -- read from the DOM, where the field's name is
    if (input.current !== null) setFieldName(nameOf(input.current));
    // Not read: they are what the name is found by, so a change in them is a change of name.
    // oxlint-disable-next-line react/exhaustive-effect-dependencies
  }, [id, label, labelledBy]);
  const buttonName = messages.chooseDate(fieldName);

  const { open, setOpen } = overlay;
  // A calendar cannot stay open over a field that can no longer be changed.
  useEffect(() => {
    if (unavailable && open) setOpen(false);
  }, [unavailable, open, setOpen]);

  /** Put a date in the field that was not typed: in place of whatever was there. */
  function fill(date: string): void {
    field.set(date);
  }

  function show(): void {
    if (unavailable) return;
    if (picker.current !== null && typeof picker.current.showPicker === "function") {
      // What was being typed is read first, and the keyboard put away from under the picker.
      if (document.activeElement === input.current) input.current?.blur();
      try {
        picker.current.showPicker();
        return;
      } catch {
        // The platform has a picker and would not show it: the calendar is still there.
      }
    }
    // On what is typed, when it reads as a date; else the field's date; else today.
    const typed = field.editing ? type.parse(field.text) : null;
    setActive(holdWithin(typed?.ok ? typed.value : (chosen ?? type.origin), minimum, maximum));
    setOpen(true, "first");
  }

  function choose(date: string): void {
    flushSync(() => {
      fill(date);
      setOpen(false);
    });
    // Back to the field, which now reads the date out — except under a finger, where focus there raises the keyboard.
    if (!coarse) input.current?.focus();
  }

  const { ref: fieldRef, onKeyDown: onFieldKeyDown } = field.inputProps;
  const inputRef = useMemo(() => mergeRefs(fieldRef, input, props.ref), [fieldRef, props.ref]);
  const { ref: triggerRef, "aria-controls": controls, style: anchor } = overlay.triggerProps;
  const { ref: contentRef, ...contentProps } = overlay.contentProps;

  return (
    <>
      <span className="relative inline-flex w-full min-w-0" style={anchor}>
        <input
          type="text"
          enterKeyHint="done"
          autoComplete="off"
          // A month's name is not a sentence to capitalise, nor a word to correct.
          autoCapitalize="off"
          autoCorrect="off"
          spellCheck={false}
          aria-required={required || undefined}
          data-invalid={field.invalid === null ? undefined : ""}
          form={form}
          {...mergeProps(field.inputProps, props)}
          ref={inputRef}
          onKeyDown={(event) => {
            props.onKeyDown?.(event);
            if (event.defaultPrevented) return;
            // As a platform's own date fields and comboboxes open; the field does not also step.
            if (event.key === "ArrowDown" && event.altKey) {
              event.preventDefault();
              show();
            } else onFieldKeyDown(event);
          }}
          className={cn(fieldClassName, "pe-8 pointer-coarse:pe-11", className)}
        />
        <button
          type="button"
          aria-label={buttonName}
          aria-haspopup="dialog"
          aria-expanded={open}
          aria-controls={controls}
          ref={triggerRef}
          disabled={unavailable}
          onClick={() => {
            if (open) setOpen(false);
            else show();
          }}
          className={cn(
            iconButtonClassName,
            // Within the field's edge; under a finger, over it, to be the full 44 pixels.
            "absolute inset-y-px end-px w-8 rounded-e-[5px]",
            "pointer-coarse:inset-y-0 pointer-coarse:end-0 pointer-coarse:w-11 pointer-coarse:rounded-e-md",
            "enabled:hover:bg-surface-muted enabled:hover:text-fg-strong enabled:active:bg-surface-strong",
            "aria-expanded:bg-surface-strong aria-expanded:text-fg-strong disabled:text-fg-disabled",
          )}
        >
          <svg
            aria-hidden
            viewBox="0 0 16 16"
            className="size-4"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <rect x="2.25" y="3.25" width="11.5" height="10.5" rx="2" />
            <path d="M2.25 6.75h11.5M5.5 1.75v3M10.5 1.75v3" />
          </svg>
        </button>
      </span>
      <div
        role="dialog"
        aria-label={buttonName}
        {...contentProps}
        ref={contentRef}
        className={cn(
          overlaySurfaceClassName,
          "mt-1.5 p-3 pointer-coarse:max-sm:pb-[max(0.75rem,env(safe-area-inset-bottom))]",
        )}
      >
        {active !== null && (
          <Calendar
            locale={locale}
            week={week}
            active={active}
            onActiveChange={setActive}
            chosen={chosen}
            today={(today ?? localToday)()}
            minimum={minimum}
            maximum={maximum}
            messages={messages}
            onChoose={choose}
          />
        )}
      </div>
      {coarse &&
        !unavailable &&
        // Outside any form the field is in: there it would be a second field, and Enter would no longer submit the form.
        createPortal(
          <input
            ref={picker}
            type="date"
            tabIndex={-1}
            aria-hidden
            min={minimum}
            max={maximum}
            value={chosen ?? ""}
            onChange={(event) => fill(event.target.value)}
            // At the field, for a platform that opens its picker beside what asked for it.
            style={contentProps.style}
            className={cn(anchoredClassName, "pointer-events-none fixed size-px border-0 p-0 opacity-0")}
          />,
          document.body,
        )}
      {/* A form is sent the date in the one form all share, not the text that shows it. */}
      {name !== undefined && (
        <input type="hidden" name={name} form={form} value={field.value ?? ""} disabled={props.disabled} />
      )}
    </>
  );
}

interface CalendarProps {
  readonly locale: string;
  readonly week: Week;
  readonly active: string;
  readonly onActiveChange: (date: string) => void;
  readonly chosen: string | null;
  readonly today: string;
  readonly minimum: string | undefined;
  readonly maximum: string | undefined;
  readonly messages: DateFieldMessages;
  readonly onChoose: (date: string) => void;
}

const dayClassName = cn(
  "size-8 rounded-md p-0 text-center text-fg-strong tabular-nums select-none [-webkit-tap-highlight-color:transparent]",
  "transition-colors duration-75 motion-reduce:transition-none",
  "pointer-coarse:size-11 pointer-coarse:text-base",
  "data-weekend:text-fg data-outside:text-fg-muted",
  "hover:bg-surface-muted active:bg-surface-strong",
  // Today is heavier and underlined: marked by more than a colour.
  "aria-[current=date]:font-bold aria-[current=date]:underline aria-[current=date]:decoration-2 aria-[current=date]:underline-offset-4",
  "aria-selected:bg-selected aria-selected:text-on-selected aria-selected:hover:bg-selected aria-selected:active:bg-selected",
  "forced-colors:aria-selected:forced-color-adjust-none forced-colors:aria-selected:bg-[Highlight] forced-colors:aria-selected:text-[HighlightText]",
  // Struck through, so that what cannot be chosen is not told by its colour alone.
  "aria-disabled:text-fg-disabled aria-disabled:line-through aria-disabled:hover:bg-transparent aria-disabled:active:bg-transparent",
  "forced-colors:aria-disabled:text-[GrayText]",
  focusRingInsetClassName,
);

const monthButtonClassName = cn(
  iconButtonClassName,
  "size-8 rounded-md text-fg hover:bg-surface-muted active:bg-surface-strong pointer-coarse:size-11",
  "aria-disabled:text-fg-disabled aria-disabled:hover:bg-transparent aria-disabled:active:bg-transparent",
  "forced-colors:aria-disabled:text-[GrayText]",
);

/** The month the active day is in, as a grid of days: one stop in the Tab order, moved through with the keys. */
function Calendar({
  locale,
  week,
  active,
  onActiveChange,
  chosen,
  today,
  minimum,
  maximum,
  messages,
  onChoose,
}: CalendarProps): ReactElement {
  const titleId = useId();
  const grid = useRef<HTMLTableElement>(null);
  const month = monthOf(active);
  const weeks = useMemo(() => monthWeeks(month, week.firstDay), [month, week.firstDay]);
  const extent = { firstDay: week.firstDay, minimum, maximum };
  const move = (by: CalendarMove) => onActiveChange(moveDate(active, by, extent));
  // A month none of whose days is in the range is not gone to.
  const first = minimum !== undefined && monthOf(minimum) >= month;
  const last = maximum !== undefined && monthOf(maximum) <= month;

  // Focus follows the active day while it is among the days; from a month button it stays on the button.
  useLayoutEffect(() => {
    if (grid.current?.contains(document.activeElement)) {
      grid.current.querySelector<HTMLElement>('[tabindex="0"]')?.focus();
    }
    // `active` is not read: it is the signal that the tab stop has moved.
    // oxlint-disable-next-line react/exhaustive-effect-dependencies
  }, [active]);

  // A day that takes focus says its month in its name: only a change made from a
  // month button, which keeps focus, is left for the region to say.
  const { announce, regionProps: announcementProps } = useAnnouncer();
  const shownMonth = useRef(month);
  useEffect(() => {
    if (shownMonth.current === month) return;
    shownMonth.current = month;
    if (!grid.current?.contains(document.activeElement)) announce(monthTitle(locale, month));
  }, [month, locale, announce]);

  const chevron = (
    <svg
      aria-hidden
      viewBox="0 0 16 16"
      className="size-4 rtl:-scale-x-100"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="m6 3.5 4.5 4.5L6 12.5" />
    </svg>
  );

  return (
    <>
      <div className="flex items-center justify-between gap-2 pb-2">
        {/* Unavailable rather than disabled: a disabled button would drop the focus it holds at the range's end. */}
        <button
          type="button"
          aria-label={messages.previousMonth}
          aria-disabled={first || undefined}
          onClick={() => {
            if (!first) move({ by: "month", count: -1 });
          }}
          className={cn(monthButtonClassName, "-scale-x-100")}
        >
          {chevron}
        </button>
        <div id={titleId} className="text-sm font-medium text-fg-strong">
          {monthTitle(locale, month)}
        </div>
        <button
          type="button"
          aria-label={messages.nextMonth}
          aria-disabled={last || undefined}
          onClick={() => {
            if (!last) move({ by: "month", count: 1 });
          }}
          className={monthButtonClassName}
        >
          {chevron}
        </button>
      </div>
      <table
        ref={grid}
        // A table of days is the grid: its rows, and its cells as grid cells, come with it.
        // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
        role="grid"
        aria-labelledby={titleId}
        className="border-separate border-spacing-0 text-sm pointer-coarse:max-sm:w-full"
        onKeyDown={(event) => {
          if (event.altKey || event.metaKey || event.ctrlKey) return;
          if (event.key === "Enter" || event.key === " ") {
            event.preventDefault();
            if (isWithin(active, minimum, maximum)) onChoose(active);
            return;
          }
          const direction = getComputedStyle(event.currentTarget).direction === "rtl" ? "rtl" : "ltr";
          const asked = calendarMoveForKey(event, direction);
          if (asked === null) return;
          event.preventDefault();
          move(asked);
        }}
      >
        {/* For the eye: each day says its own day of the week, once, where a column's name would say it again. */}
        <thead aria-hidden>
          <tr>
            {weekdayNames(locale, week.firstDay, "short").map((weekdayName, column) => (
              <th
                // A column is its place in the week, whatever the day is called.
                // oxlint-disable-next-line react/no-array-index-key
                key={column}
                scope="col"
                className="h-8 p-0 text-center text-xs font-medium text-fg-muted"
              >
                {weekdayName}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {weeks.map((days, row) => (
            // Rows and cells are keyed by place, and so stay from month to month: the cell holding focus is never removed.
            // oxlint-disable-next-line react/no-array-index-key
            <tr key={row}>
              {days.map((date, column) => {
                // By place, as above.
                // oxlint-disable-next-line react/no-array-index-key
                if (date === null) return <td key={column} aria-hidden />;
                const available = isWithin(date, minimum, maximum);
                return (
                  <td
                    // By place, as above.
                    // oxlint-disable-next-line react/no-array-index-key
                    key={column}
                    tabIndex={date === active ? 0 : -1}
                    aria-label={dayName(locale, date)}
                    aria-selected={available ? date === chosen : undefined}
                    aria-current={date === today ? "date" : undefined}
                    aria-disabled={available ? undefined : true}
                    data-date={date}
                    data-outside={date.startsWith(month) ? undefined : ""}
                    data-weekend={isWeekend(date, week) ? "" : undefined}
                    onClick={() => {
                      if (available) onChoose(date);
                    }}
                    className={dayClassName}
                  >
                    {dayText(locale, date)}
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>
      <div {...announcementProps} />
    </>
  );
}