← ui

calendar

ui

a month to pick a day or a range of days from, every key of the date grid

npx shadcn add @ag/calendar
Already installed? Preview an update
npx shadcn add @ag/calendar --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • New: a month to pick a day from, or a range of days (mode="range": the first press one end, the second the other, the band following the hand between them, Escape letting go). The WAI-ARIA date grid: one Tab stop, the arrows by day and week, PageUp and PageDown by month (a year with Shift), Home and End to the week's edges, Enter or Space chooses; the keys are answered on the grid through ownedHere, so a page's shortcut never also hears them.
  • New: the value is a civil date string, "2026-10-11", or { from, to }, never a Date: a Date is an instant, and the same day picked in Madrid is the day before in UTC once it is serialized. toDay(date) and dateOf(day) cross to and from a Date where an API wants one; a form posts the value by name as the day or an ISO interval (2026-10-03/2026-10-09).
  • New: today dotted, the months either side dimmed, six weeks always so it keeps its height, days disabled refuses or outside min and max reachable but not chosen. The month, the weekdays and the digits are the locale's (locale, default the reader's), on the Gregorian calendar the grid is drawn in; the week starts where the locale's does unless weekStart says. labels for the words that are not dates. Inside a field it is named and described by it and holds still while read-only. Plain or glass; it draws no surface of its own.
  • New: lib/calendar-core, the arithmetic, framework-free: day numbers counted in UTC, so a step never slips across a change of the clocks; month grids, months held to their last day, ranges, the keys' steps, the locale's week start and words.
Updated 2026-10-11 · release notes

shelf controls

uses button, field-context, key-router, material

used by components, date-picker

the demo above, verbatim

calendar.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import {
  addMonths,
  type DateRange,
  type Day,
  weekdayOf,
} from "@/registry/base-nova/lib/calendar-core"
import { Calendar, useToday } from "@/registry/base-nova/ui/calendar"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"

// #region pick
function Pick() {
  const [day, setDay] = React.useState<Day | null>(null)
  return (
    <div className="flex flex-col items-start gap-3">
      <Calendar aria-label="shoot day" value={day} onValueChange={setDay} />
      {/* As wide as the month and no wider: the words wrap under it, so the box
          keeps its size whatever they say. */}
      <p className="w-0 min-w-full font-mono text-current/60 text-xs">
        {day ?? "no day chosen"}
      </p>
    </div>
  )
}
// #endregion

// #region stay
/** A studio booked by the day: from today, three months ahead, closed on Sundays. */
function Stay() {
  const today = useToday()
  const [stay, setStay] = React.useState<DateRange | null>(null)
  return (
    <div className="flex flex-col items-start gap-3">
      <Calendar
        mode="range"
        aria-label="studio days"
        value={stay}
        onValueChange={setStay}
        min={today ?? undefined}
        max={today === null ? undefined : addMonths(today, 3)}
        disabled={(day) => weekdayOf(day) === 0}
      />
      <p className="w-0 min-w-full font-mono text-current/60 text-xs">
        {stay ? `${stay.from} to ${stay.to}` : "first day, then the last"}
      </p>
    </div>
  )
}
// #endregion

export default function CalendarDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="materials"
        title="plain and glass"
        description="tab into the grid, then the arrows, page up and down, home and end; enter chooses."
        with="pick"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Pick />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6 text-white">
              <Panel className="mx-auto w-fit">
                <Pick />
              </Panel>
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample name="day" title="one day, and its value" with="pick">
        <Pick />
      </Sample>
      <Sample
        name="range"
        glance="large"
        title="a range, some days refused"
        description="the band follows the hand from the first end; escape lets go of it."
        with="stay"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Stay />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-4 text-white sm:p-6">
              <Panel className="mx-auto w-fit">
                <Stay />
              </Panel>
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="locales"
        title="the locale's words and week"
        description="spanish weeks start on monday, american on sunday, egyptian on saturday, in arabic digits and right to left."
      >
        <div className="flex flex-wrap gap-8">
          <Calendar
            aria-label="día"
            locale="es-ES"
            labels={{
              previous: "mes anterior",
              next: "mes siguiente",
              today: "hoy",
              selected: "elegido",
            }}
          />
          <Calendar aria-label="day" locale="en-US" />
          <div dir="rtl">
            <Calendar aria-label="يوم" locale="ar-EG" />
          </div>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

calendar.tsx
"use client"

import { ChevronLeft, ChevronRight } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  addMonths,
  clampDay,
  type DateRange,
  type Day,
  dateNumeral,
  firstOfMonth,
  formatDay,
  GRID_WEEKS,
  inRange,
  monthGrid,
  monthTitle,
  rangeOf,
  sameMonth,
  stepDay,
  toDay,
  type Weekday,
  weekdayNames,
  weekStartOf,
} from "@/registry/base-nova/lib/calendar-core"
import { Button } from "@/registry/base-nova/ui/button"
import { heldStill, useField } from "@/registry/base-nova/ui/field-context"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
  type Material,
  MaterialProvider,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { schemeOf } from "@/registry/base-nova/ui/material-glass"

// A MONTH TO PICK A DAY FROM, or a range of days: the WAI-ARIA date grid. One day of
// the grid is the keyboard's (a single Tab stop); the arrows move it by a day and a
// week, PageUp and PageDown by a month (a year with Shift), Home and End to the week's
// edges, and Enter or Space chooses it. Moving past the month turns the page. Its keys
// are answered on the grid itself, asking `ownedHere` first (key-router), so a page's
// shortcut never also hears an arrow pressed in it.
//
// A day is a civil date as ISO writes it, "2026-10-11" (lib/calendar-core says why a
// `Date` is the wrong value for a day picked); a range is `{ from, to }`, both
// included. In a range the first press sets one end and the second the other,
// whichever is earlier; between them the band follows the hand (or the keyboard's
// day). `onValueChange` is called once a choice is whole: a day, or both ends.
//
// Today wears a dot, the days of the months either side are dimmed (and still
// chosen with a press, which turns to their month), a day `disabled` says no to, or
// outside `min` and `max`, can be reached but not chosen. Six weeks always, so the
// calendar keeps its height from month to month. The words are the locale's
// (`Intl.DateTimeFormat`: the month, the weekdays, the digits), and so is the day a
// week starts on unless `weekStart` says.
//
// It draws no surface of its own, as a field draws no card: it sits on the page, a
// panel or a popover (date-picker's). Plain, the chosen day is the page's primary;
// glass, a lit pane of glass, and the days the button's ghost. Inside a field it is
// named and described by it and holds still while it is read-only.

/** The words the calendar says that are not dates, for a page in another language. */
export interface CalendarLabels {
  previous: string
  next: string
  today: string
  selected: string
}

const LABELS: CalendarLabels = {
  previous: "previous month",
  next: "next month",
  today: "today",
  selected: "selected",
}

/** The grid's own focusable parts: its days are buttons, and their keys are its. */
const OWN_ROLES = ["button"]

/** The band under the days of a range, its ends rounded as the chosen day is. */
const BAND: Record<Material, string> = {
  plain: "bg-foreground/8",
  glass: "bg-current/10",
}
const BAND_START: Record<Material, string> = {
  plain: "rounded-s-md",
  glass: "rounded-s-full",
}
const BAND_END: Record<Material, string> = {
  plain: "rounded-e-md",
  glass: "rounded-e-full",
}
/** The chosen day in glass: a pane stained with its words' colour, its rim lit. */
const GLASS_CHOSEN =
  "[--glass-stain-amount:16%] [--glass-stain:currentColor] [--glass-rim:1.8]"
const DAY_FACE = "font-normal text-[13px] tabular-nums"
const OUTSIDE: Record<Material, string> = {
  plain: "text-foreground/35",
  glass: "text-current/40",
}

// ── what only the reader's browser knows ────────────────────────────────────────

function subscribeToday(change: () => void): () => void {
  let timer = 0
  const arm = () => {
    const now = new Date()
    const midnight = new Date(now)
    midnight.setHours(24, 0, 1, 0)
    timer = window.setTimeout(() => {
      change()
      arm()
    }, midnight.getTime() - now.getTime())
  }
  arm()
  return () => window.clearTimeout(timer)
}

/** Today where the reader is, turning at their midnight; null while the server
 *  renders, which cannot know their zone. */
export function useToday(): Day | null {
  return React.useSyncExternalStore(
    subscribeToday,
    () => toDay(new Date()),
    () => null,
  )
}

const unchanging = () => () => {}

/** The locale dates are said in: the one asked for, else the reader's own. The server
 *  cannot know theirs: it renders `en-US`, and the page says it in theirs at once. */
export function useCalendarLocale(locale?: string): string {
  const reader = React.useSyncExternalStore(
    unchanging,
    () => navigator.language,
    () => "en-US",
  )
  return locale ?? reader
}

// ── the calendar ────────────────────────────────────────────────────────────────

type One = {
  mode?: "single"
  value?: Day | null
  defaultValue?: Day | null
  /** A day was chosen. */
  onValueChange?: (day: Day) => void
}
type Range = {
  mode: "range"
  value?: DateRange | null
  defaultValue?: DateRange | null
  /** Both ends were chosen. */
  onValueChange?: (range: DateRange) => void
}

export type CalendarProps = (One | Range) & {
  /** The earliest and the latest day that can be chosen. */
  min?: Day
  max?: Day
  /** True: nothing can be chosen. A function: the days it says no to. */
  disabled?: boolean | ((day: Day) => boolean)
  /** The language of its words, a BCP 47 tag. Default: the reader's. */
  locale?: string
  /** The day a week starts on (0 Sunday … 6 Saturday). Default: the locale's. */
  weekStart?: Weekday
  /** A day in the month shown first, while nothing is chosen. Default: today's. */
  defaultMonth?: Day
  /** Which day is today. Default: the reader's, in their zone. */
  today?: Day
  /** Submitted with a form: the day, or the range as an ISO interval
   *  ("2026-10-03/2026-10-09"). */
  name?: string
  labels?: Partial<CalendarLabels>
  id?: string
  "aria-label"?: string
  "aria-labelledby"?: string
  "aria-describedby"?: string
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  className?: string
}

const keyOf = (v: DateRange | null) => (v ? `${v.from}/${v.to}` : "")

export function Calendar(props: CalendarProps) {
  const {
    min,
    max,
    disabled,
    locale: asked,
    weekStart: start,
    defaultMonth,
    today: given,
    name,
    labels: words,
    id,
    material,
    scheme,
    className,
  } = props
  if (min !== undefined && max !== undefined && min > max)
    throw new Error(`calendar: min ${min} is after max ${max}`)
  const m = useMaterial(material)
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
  const field = useField()
  const locale = useCalendarLocale(asked)
  const reader = useToday()
  const today = given ?? reader
  const weekStart = start ?? weekStartOf(locale)
  const labels = { ...LABELS, ...words }
  const range = props.mode === "range"
  const titleId = `${React.useId()}-month`
  const grid = React.useRef<HTMLTableElement>(null)
  const off = disabled === true || field?.wiring.disabled === true
  const refused = (day: Day) =>
    off ||
    (min !== undefined && day < min) ||
    (max !== undefined && day > max) ||
    (typeof disabled === "function" && disabled(day))

  // The value, the consumer's or held here, as a range either way (a day is a range
  // of one).
  const [held, setHeld] = React.useState<Day | DateRange | null>(
    props.defaultValue ?? null,
  )
  const raw = props.value !== undefined ? props.value : held
  const chosen: DateRange | null =
    raw === null ? null : typeof raw === "string" ? { from: raw, to: raw } : raw

  // The first end of a range being chosen, and the day under the hand.
  const [anchor, setAnchor] = React.useState<Day | null>(null)
  const [hover, setHover] = React.useState<Day | null>(null)

  // The keyboard's day, which is also the month shown. Moved by the keys, the arrows
  // and a press; unmoved, the chosen day's (the first end's), else today's. A value
  // arriving from outside brings its month back into view; one chosen here leaves
  // the month where the hand is.
  const [moved, setMoved] = React.useState<Day | null>(null)
  const [emitted, setEmitted] = React.useState<string | null>(null)
  const [seen, setSeen] = React.useState(keyOf(chosen))
  if (seen !== keyOf(chosen)) {
    setSeen(keyOf(chosen))
    if (keyOf(chosen) !== emitted) setMoved(null)
  }
  const unclamped = moved ?? chosen?.from ?? defaultMonth ?? today
  const active = unclamped === null ? null : clampDay(unclamped, min, max)

  // A key moved the day: focus follows it once it is drawn (in another month, too).
  const follow = React.useRef(false)
  React.useEffect(() => {
    if (!follow.current || active === null) return
    follow.current = false
    grid.current?.querySelector<HTMLElement>(`[data-day="${active}"]`)?.focus()
  }, [active])

  const emit = (next: Day | DateRange) => {
    const asRange = typeof next === "string" ? { from: next, to: next } : next
    setEmitted(keyOf(asRange))
    setHeld(next)
    ;(props.onValueChange as ((v: Day | DateRange) => void) | undefined)?.(next)
  }
  const pick = (day: Day) => {
    setMoved(day)
    if (!range) return emit(day)
    if (anchor === null) return setAnchor(day)
    setAnchor(null)
    setHover(null)
    emit(rangeOf(anchor, day))
  }
  // Read-only (a form sending): the days take no press, the keys still move.
  const choose = heldStill(field, pick) ?? pick

  const onKeyDown = (e: React.KeyboardEvent<HTMLTableElement>) => {
    if (active === null || e.metaKey || e.ctrlKey || e.altKey) return
    if (ownedHere(e.nativeEvent, e.currentTarget, OWN_ROLES)) return
    // Escape lets go of a range's first end before it closes anything round it.
    if (e.key === "Escape" && anchor !== null) {
      e.preventDefault()
      e.stopPropagation()
      setAnchor(null)
      setHover(null)
      return
    }
    const step = stepDay(active, e.key, {
      weekStart,
      shift: e.shiftKey,
      rtl: getComputedStyle(e.currentTarget).direction === "rtl",
    })
    if (step === null) return
    e.preventDefault()
    const next = clampDay(step, min, max)
    if (next === active) return
    follow.current = true
    setMoved(next)
  }

  // What is drawn as chosen: the range being chosen, from its first end to the day
  // under the hand (or the keyboard's), else the value.
  const shown: DateRange | null =
    anchor !== null ? rangeOf(anchor, hover ?? active ?? anchor) : chosen
  const isChosen = (day: Day) =>
    anchor !== null
      ? day === anchor
      : chosen !== null && (day === chosen.from || day === chosen.to)
  const inBand = (day: Day) => range && shown !== null && inRange(day, shown)

  const first = active === null ? null : firstOfMonth(active)
  const previousOff =
    off || first === null || (min !== undefined && first <= min)
  const nextOff =
    off || first === null || (max !== undefined && addMonths(first, 1) > max)
  const turn = (by: number) => {
    if (active === null) return
    setMoved(clampDay(addMonths(active, by), min, max))
  }

  const names = weekdayNames(locale, weekStart)
  const weeks = active === null ? null : monthGrid(active, weekStart)
  const value =
    chosen === null ? "" : range ? `${chosen.from}/${chosen.to}` : chosen.from

  return (
    // biome-ignore lint/a11y/useSemanticElements: a calendar's month and grid, one control, not a form's fieldset
    <div
      ref={here}
      id={id ?? field?.wiring.id}
      role="group"
      aria-label={props["aria-label"]}
      aria-labelledby={
        props["aria-labelledby"] ??
        (props["aria-label"] ? undefined : field?.labelId)
      }
      aria-describedby={
        props["aria-describedby"] ?? field?.wiring["aria-describedby"]
      }
      data-slot="calendar"
      data-mode={range ? "range" : "single"}
      data-material={m}
      data-disabled={off || undefined}
      className={cn(
        "inline-flex w-fit flex-col gap-2 select-none data-disabled:opacity-50",
        schemeOf(s),
        className,
      )}
    >
      <MaterialProvider material={m} scheme={s}>
        <div
          data-slot="calendar-head"
          className="flex items-center justify-between gap-2"
        >
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={labels.previous}
            disabled={previousOff}
            onClick={() => turn(-1)}
          >
            <ChevronLeft className="rtl:rotate-180" />
          </Button>
          <div
            id={titleId}
            aria-live="polite"
            data-slot="calendar-month"
            className="font-medium text-[13px]"
          >
            {active === null ? "" : monthTitle(active, locale)}
          </div>
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={labels.next}
            disabled={nextOff}
            onClick={() => turn(1)}
          >
            <ChevronRight className="rtl:rotate-180" />
          </Button>
        </div>
        <table
          ref={grid}
          // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: WAI-ARIA's date grid is a table given the grid role; its days are walked with the keys
          role="grid"
          aria-labelledby={titleId}
          aria-multiselectable={range || undefined}
          data-slot="calendar-grid"
          onKeyDown={onKeyDown}
          onPointerLeave={() => setHover(null)}
          className="border-separate border-spacing-x-0 border-spacing-y-0.5"
        >
          <thead>
            <tr>
              {names.map((n) => (
                <th
                  key={n.long}
                  scope="col"
                  abbr={n.long}
                  className="h-7 w-8 p-0 font-normal text-[11px] text-current/50"
                >
                  {n.short}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {weeks === null
              ? Array.from({ length: GRID_WEEKS }, (_, w) => (
                  // biome-ignore lint/suspicious/noArrayIndexKey: an empty week is its place
                  <tr key={w}>
                    {names.map((n) => (
                      <td key={n.long} className="size-8 p-0" />
                    ))}
                  </tr>
                ))
              : weeks.map((week) => (
                  <tr key={week[0]}>
                    {week.map((day, col) => {
                      const outside = !sameMonth(day, active as Day)
                      const lit = isChosen(day)
                      const band = inBand(day)
                      const selected =
                        chosen !== null && anchor === null
                          ? inRange(day, chosen)
                          : day === anchor
                      const label = [
                        formatDay(day, locale, { dateStyle: "full" }),
                        day === today && labels.today,
                        selected && labels.selected,
                      ]
                        .filter(Boolean)
                        .join(", ")
                      return (
                        // biome-ignore lint/a11y/useFocusableInteractive: the day's button inside the cell is what takes focus (WAI-ARIA's date grid)
                        <td
                          key={day}
                          // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: a cell of the date grid is a gridcell, which says whether it is chosen
                          role="gridcell"
                          aria-selected={selected}
                          className={cn(
                            "p-0 text-center",
                            band && BAND[m],
                            band &&
                              (day === shown?.from || col === 0) &&
                              BAND_START[m],
                            band &&
                              (day === shown?.to || col === 6) &&
                              BAND_END[m],
                          )}
                        >
                          <Button
                            data-slot="calendar-day"
                            data-day={day}
                            data-outside={outside || undefined}
                            data-today={day === today || undefined}
                            data-chosen={lit || undefined}
                            variant={lit ? "default" : "ghost"}
                            size="icon"
                            tabIndex={day === active ? 0 : -1}
                            disabled={refused(day)}
                            focusableWhenDisabled
                            aria-label={label}
                            onClick={() => choose(day)}
                            onPointerEnter={() =>
                              anchor !== null && setHover(day)
                            }
                            className={cn(
                              DAY_FACE,
                              outside && !lit && OUTSIDE[m],
                              lit && "font-medium",
                              lit && m === "glass" && GLASS_CHOSEN,
                            )}
                          >
                            {dateNumeral(day, locale)}
                            {day === today && (
                              <span
                                aria-hidden
                                data-slot="calendar-today"
                                className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-current"
                              />
                            )}
                          </Button>
                        </td>
                      )
                    })}
                  </tr>
                ))}
          </tbody>
        </table>
      </MaterialProvider>
      {name && <input type="hidden" name={name} value={value} />}
    </div>
  )
}
calendar-core.ts
// Days on a calendar, framework-free: the arithmetic and the words of `calendar` and
// `date-picker`, runnable anywhere (a server formatting a booking, a script).
//
// A DAY IS A CIVIL DATE, written as ISO 8601 writes one: "2026-10-11". No time of day
// and no time zone, which is what a person picking a day on a calendar means. A `Date`
// is an instant: the same day picked in Madrid is the previous day in UTC, so a `Date`
// held for a day turns into another day the moment it is serialized (`toISOString`), a
// form posts it, or a server renders it in its own zone. A day string survives all of
// that unchanged, compares with `===`, sorts with `<` (four-digit years, zero-padded),
// is a form's value and a URL's as it is, and needs no library.
//
// The arithmetic runs on day numbers (days since 1970-01-01, counted in UTC, which has
// no daylight saving), so stepping a day never lands on 23:00 of the one before. Local
// time enters at two doors only: `toDay` (the day an instant falls on here) and
// `dateOf` (local midnight of a day).
//
// The words come from `Intl.DateTimeFormat` in the locale asked for, always on the
// Gregorian calendar the grid is drawn in (ar-SA and th-TH default to others) and in
// UTC, so the day formatted is the day given whatever the zone.

/** A civil date, "YYYY-MM-DD". */
export type Day = string

/** Days from one to another, both included, `from` never after `to`. */
export interface DateRange {
  from: Day
  to: Day
}

/** A day of the week as `Date.getDay` numbers it: 0 Sunday … 6 Saturday. */
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6

const DAY = /^(\d{4})-(\d{2})-(\d{2})$/
const MS_PER_DAY = 86_400_000

/** The weeks a month grid always shows: six, so a calendar keeps one height from
 *  month to month and what is under it never moves. */
export const GRID_WEEKS = 6

/** Milliseconds at UTC midnight of a civil date; `setUTCFullYear`, because `Date.UTC`
 *  reads years 0 to 99 as 1900 to 1999. */
function utc(year: number, month: number, date: number): number {
  const t = new Date(0)
  t.setUTCFullYear(year, month - 1, date)
  return t.getTime()
}

/** Whether `text` is a day: the shape and a date that exists (no February 30th). */
export function isDay(text: string): boolean {
  const m = DAY.exec(text)
  if (!m) return false
  const [year, month, date] = [Number(m[1]), Number(m[2]), Number(m[3])]
  return month >= 1 && month <= 12 && date >= 1 && date <= daysIn(year, month)
}

/** A day's year, month (1 to 12) and date. Anything else screams. */
export function partsOf(day: Day): {
  year: number
  month: number
  date: number
} {
  if (!isDay(day))
    throw new Error(`calendar: "${day}" is not a day (YYYY-MM-DD)`)
  const m = DAY.exec(day) as RegExpExecArray
  return { year: Number(m[1]), month: Number(m[2]), date: Number(m[3]) }
}

/** The day of a year, a month (1 to 12) and a date. */
export function dayOf(year: number, month: number, date: number): Day {
  const day = `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(date).padStart(2, "0")}`
  if (!isDay(day))
    throw new Error(`calendar: ${year}, ${month}, ${date} is not a day`)
  return day
}

/** How many days a month has. */
export function daysIn(year: number, month: number): number {
  return new Date(utc(year, month + 1, 0)).getUTCDate()
}

/** Days since 1970-01-01. */
export function dayNumber(day: Day): number {
  const { year, month, date } = partsOf(day)
  return Math.round(utc(year, month, date) / MS_PER_DAY)
}

/** The day `n` days after 1970-01-01. */
export function dayAt(n: number): Day {
  if (!Number.isInteger(n))
    throw new Error(`calendar: day number ${n} is not whole`)
  const t = new Date(n * MS_PER_DAY)
  return dayOf(t.getUTCFullYear(), t.getUTCMonth() + 1, t.getUTCDate())
}

export const addDays = (day: Day, n: number): Day => dayAt(dayNumber(day) + n)

/** Days from `a` to `b`: positive when `b` is later. */
export const daysBetween = (a: Day, b: Day): number =>
  dayNumber(b) - dayNumber(a)

/** The same date `n` months on, held to the month's last day where it has fewer
 *  (January 31st and a month is February 28th, or the 29th). */
export function addMonths(day: Day, n: number): Day {
  const { year, month, date } = partsOf(day)
  const index = year * 12 + (month - 1) + n
  const y = Math.floor(index / 12)
  const m = index - y * 12 + 1
  return dayOf(y, m, Math.min(date, daysIn(y, m)))
}

export const weekdayOf = (day: Day): Weekday =>
  // 1970-01-01 was a Thursday.
  ((((dayNumber(day) + 4) % 7) + 7) % 7) as Weekday

/** The first day of the week `day` is in, weeks starting on `weekStart`. */
export const startOfWeek = (day: Day, weekStart: Weekday): Day =>
  addDays(day, -((weekdayOf(day) - weekStart + 7) % 7))

export const endOfWeek = (day: Day, weekStart: Weekday): Day =>
  addDays(startOfWeek(day, weekStart), 6)

export function firstOfMonth(day: Day): Day {
  const { year, month } = partsOf(day)
  return dayOf(year, month, 1)
}

export const sameMonth = (a: Day, b: Day): boolean =>
  a.slice(0, 7) === b.slice(0, 7)

/** The month `day` is in as a grid: `GRID_WEEKS` weeks of seven days, starting on
 *  `weekStart`, the first holding the month's first day; the days before and after
 *  the month fill the first and last weeks. */
export function monthGrid(day: Day, weekStart: Weekday): Day[][] {
  const first = dayNumber(startOfWeek(firstOfMonth(day), weekStart))
  return Array.from({ length: GRID_WEEKS }, (_, w) =>
    Array.from({ length: 7 }, (_, d) => dayAt(first + w * 7 + d)),
  )
}

/** `day` held between `min` and `max`, either open. */
export function clampDay(day: Day, min?: Day, max?: Day): Day {
  if (min !== undefined && max !== undefined && min > max)
    throw new Error(`calendar: min ${min} is after max ${max}`)
  if (min !== undefined && day < min) return min
  if (max !== undefined && day > max) return max
  return day
}

/** The range between two days, in either order. */
export const rangeOf = (a: Day, b: Day): DateRange =>
  a <= b ? { from: a, to: b } : { from: b, to: a }

export const inRange = (day: Day, range: DateRange): boolean =>
  day >= range.from && day <= range.to

/** The day an instant falls on, in this runtime's zone. */
export const toDay = (date: Date): Day =>
  dayOf(date.getFullYear(), date.getMonth() + 1, date.getDate())

/** Local midnight of a day, for an API that wants a `Date`. */
export function dateOf(day: Day): Date {
  const { year, month, date } = partsOf(day)
  const t = new Date(0)
  t.setFullYear(year, month - 1, date)
  t.setHours(0, 0, 0, 0)
  return t
}

// ── the keys ────────────────────────────────────────────────────────────────────

/** The day a key moves to from `day` (the WAI-ARIA date grid): the arrows a day or a
 *  week, PageUp and PageDown a month (a year with Shift), Home and End the week's
 *  edges. Right to left, the horizontal arrows run the other way. Null for any other
 *  key. */
export function stepDay(
  day: Day,
  key: string,
  {
    weekStart,
    shift = false,
    rtl = false,
  }: { weekStart: Weekday; shift?: boolean; rtl?: boolean },
): Day | null {
  const ahead = rtl ? -1 : 1
  switch (key) {
    case "ArrowLeft":
      return addDays(day, -ahead)
    case "ArrowRight":
      return addDays(day, ahead)
    case "ArrowUp":
      return addDays(day, -7)
    case "ArrowDown":
      return addDays(day, 7)
    case "PageUp":
      return addMonths(day, shift ? -12 : -1)
    case "PageDown":
      return addMonths(day, shift ? 12 : 1)
    case "Home":
      return startOfWeek(day, weekStart)
    case "End":
      return endOfWeek(day, weekStart)
    default:
      return null
  }
}

// ── the words ───────────────────────────────────────────────────────────────────

/** Regions whose week starts on Sunday, and on Saturday (CLDR's `firstDay`), for a
 *  runtime whose `Intl.Locale` has no week info (Firefox): everywhere else, Monday. */
const SUNDAY_FIRST = new Set(
  "AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW".split(
    " ",
  ),
)
const SATURDAY_FIRST = new Set(
  "AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY".split(" "),
)

type WithWeekInfo = Intl.Locale & {
  getWeekInfo?: () => { firstDay: number }
  weekInfo?: { firstDay: number }
}

/** The day a week starts on where `locale` is spoken (Monday in Spain, Sunday in the
 *  United States, Saturday in Egypt). */
export function weekStartOf(locale: string): Weekday {
  const l = new Intl.Locale(locale) as WithWeekInfo
  const info = l.getWeekInfo?.() ?? l.weekInfo
  // Intl counts 1 Monday … 7 Sunday.
  if (info) return (info.firstDay % 7) as Weekday
  const region = l.maximize().region ?? ""
  return SUNDAY_FIRST.has(region) ? 0 : SATURDAY_FIRST.has(region) ? 6 : 1
}

const formats = new Map<string, Intl.DateTimeFormat>()
/** A formatter on the grid's calendar and in UTC, made once per locale and options. */
function format(
  locale: string,
  options: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormat {
  const key = `${locale} ${JSON.stringify(options)}`
  let f = formats.get(key)
  if (!f) {
    f = new Intl.DateTimeFormat(locale, {
      ...options,
      calendar: "gregory",
      timeZone: "UTC",
    })
    formats.set(key, f)
  }
  return f
}

const instant = (day: Day) => new Date(dayNumber(day) * MS_PER_DAY)

/** A day in words (`{ dateStyle: "medium" }` unless told: "Oct 11, 2026", "11 oct
 *  2026"). */
export const formatDay = (
  day: Day,
  locale: string,
  options: Intl.DateTimeFormatOptions = { dateStyle: "medium" },
): string => format(locale, options).format(instant(day))

/** A range in words, what the two days share said once ("Oct 3 to 9, 2026" in the
 *  locale's own way); one day when they are the same. */
export function formatRange(
  range: DateRange,
  locale: string,
  options: Intl.DateTimeFormatOptions = { dateStyle: "medium" },
): string {
  const f = format(locale, options)
  return range.from === range.to
    ? f.format(instant(range.from))
    : f.formatRange(instant(range.from), instant(range.to))
}

/** A date's number as the locale writes it (Arabic-Indic digits in ar-EG). */
export const dateNumeral = (day: Day, locale: string): string =>
  format(locale, { day: "numeric" }).format(instant(day))

/** The month and year a grid shows, as a title ("October 2026", "octubre de 2026"). */
export const monthTitle = (day: Day, locale: string): string =>
  format(locale, { month: "long", year: "numeric" }).format(instant(day))

/** The seven weekdays from `weekStart`, each short (a column's head) and long (what
 *  it is read as). */
export function weekdayNames(
  locale: string,
  weekStart: Weekday,
): { short: string; long: string }[] {
  // 1970-01-04 was a Sunday.
  return Array.from({ length: 7 }, (_, i) => {
    const day = dayAt(3 + ((weekStart + i) % 7))
    return {
      short: format(locale, { weekday: "short" }).format(instant(day)),
      long: format(locale, { weekday: "long" }).format(instant(day)),
    }
  })
}