Calendar
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/calendarPlain-CSS theme — no build step
npx astrocraft-ui add forms/calendar --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/calendar --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/calendar --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/calendar --theme tailwind --bridge lumosLive demo
Calendar
A real ARIA grid. Tab lands on ONE day, then ←/→ move by day,↑/↓ by week, Home/End to the ends of the week,PageUp/PageDown by month and with Shift by year. Arrow off the end of the month and it pages by itself; the caption is a live region, so paging is announced. The second one is bounded, and its nav buttons disable at the edges.
| Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday |
|---|---|---|---|---|---|---|
| Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday |
|---|---|---|---|---|---|---|
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Calendar.astro` | `calendar` `calendar-value` | `data-week-start`: `0` · `1` · `2` · `3` · `4` · `5` · `6` | `data-date` `data-month` `data-value` | — |
| `CalendarCell.astro` | `calendar-cell` `calendar-day` | `data-outside`: `true` when set `data-today`: `true` when set | — | `[aria-selected]` `[disabled]` |
| `CalendarGrid.astro` | `calendar-grid` | `data-week-start`: `0` · `1` · `2` · `3` · `4` · `5` · `6` | — | `[disabled]` |
| `CalendarHeader.astro` | `calendar-header` `calendar-label` | — | — | — |
| `CalendarNav.astro` | `calendar-nav` `calendar-nav-next` `calendar-nav-previous` | — | — | — |
| `CalendarWeekday.astro` | `calendar-weekday` `calendar-weekday-name` | — | — | — |
Source
What the command copies — 8 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/calendar/Calendar.astro — headless primitive (see ../../README.md).
// A month view as a real ARIA grid: ↑/↓/←/→ move by day and week, Home/End jump to the ends of the
// week, PageUp/PageDown page by month (+Shift, by year), Enter and Space select — natively, because
// each day is a <button>. Only one of the 42 cells is in the tab order at a time (the roving
// tabindex the grid pattern requires), so Tab steps over the calendar rather than through it.
//
// WHAT MAKES THIS AFFORDABLE: the six-week grid is rendered ONCE, at build time, and paging FILLS
// those 42 cells rather than rebuilding them. No markup is generated in JavaScript anywhere in this
// library, and CalendarCell stays the only file that says what a day looks like. The date
// arithmetic lives in `calendar-grid.ts` so the same code runs in this frontmatter and in the script
// below — a navigated month cannot disagree with a server-rendered one — and is checked by
// `calendar-grid.test.ts`.
//
// Without JavaScript you still get a correctly labelled, readable month table; what you lose is
// paging and the keyboard. Give it `name` and it submits like any other field, from a real hidden
// input holding the ISO date.
//
// ponytail: single date only. Range selection lives in DateRangePicker, which decorates these same
// cells with `data-in-range` from outside rather than making this file understand two values —
// see DateRangePicker.astro. Week numbers and multi-month views are out of scope; render two
// Calendars if you need two months.
import type { HTMLAttributes } from "astro/types";
import { formatMonth, startOfMonth, todayISO } from "./calendar-grid";
import CalendarGrid from "./CalendarGrid.astro";
import CalendarHeader from "./CalendarHeader.astro";
import CalendarNav from "./CalendarNav.astro";
type Props = HTMLAttributes<"div"> & {
/** The selected date, `YYYY-MM-DD`. */
value?: string;
/** Any date in the month to open on. Defaults to `value`'s month, else the current one. */
month?: string;
min?: string;
max?: string;
/** Submits the ISO date from a hidden input, so the calendar is a real form control. */
name?: string;
/** BCP-47 tag for the month, weekday and cell names. Defaults to the browser's. */
locale?: string;
/** 0 = Sunday … 6 = Saturday. */
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
previousLabel?: string;
nextLabel?: string;
};
const {
value,
month,
min,
max,
name,
locale,
weekStart = 1,
previousLabel,
nextLabel,
class: className,
...rest
} = Astro.props;
const shown = startOfMonth(month ?? value ?? todayISO());
---
<div
class={className}
data-slot="calendar"
data-month={shown}
data-value={value}
data-locale={locale}
data-week-start={weekStart}
data-min={min}
data-max={max}
data-auto-month={!month && !value ? "true" : undefined}
{...rest}
>
<CalendarHeader label={formatMonth(shown, locale)}>
<CalendarNav previousLabel={previousLabel} nextLabel={nextLabel} />
</CalendarHeader>
<CalendarGrid
month={shown}
value={value}
min={min}
max={max}
locale={locale}
weekStart={weekStart}
/>
{name && <input type="hidden" name={name} value={value} data-slot="calendar-value" />}
<slot />
</div>
<script>
import { onReadyOnce } from "../../_once";
import {
addDays,
addMonths,
clampToRange,
endOfWeek,
formatFull,
formatMonth,
inRange,
monthGrid,
parseISO,
startOfMonth,
startOfWeek,
todayISO,
} from "./calendar-grid";
function wire(root: HTMLElement) {
const grid = root.querySelector<HTMLElement>('[data-slot="calendar-grid"]');
if (!grid) return;
const cells = [...grid.querySelectorAll<HTMLButtonElement>('[data-slot="calendar-day"]')];
if (cells.length === 0) return;
const caption = root.querySelector<HTMLElement>('[data-slot="calendar-label"]');
const hidden = root.querySelector<HTMLInputElement>('[data-slot="calendar-value"]');
const previous = root.querySelector<HTMLButtonElement>('[data-slot="calendar-nav-previous"]');
const next = root.querySelector<HTMLButtonElement>('[data-slot="calendar-nav-next"]');
const locale = root.dataset.locale || undefined;
const weekStart = Number(root.dataset.weekStart ?? "1");
const min = root.dataset.min || null;
const max = root.dataset.max || null;
let month = startOfMonth(root.dataset.month || todayISO());
let focused = cells.find((cell) => cell.tabIndex === 0)?.dataset.date ?? month;
/** The `data-value` the cells currently reflect — see the MutationObserver at the bottom. */
let rendered = root.dataset.value ?? "";
const flag = (el: HTMLElement, name: "outside" | "today", on: boolean) => {
if (on) el.dataset[name] = "true";
else delete el.dataset[name];
};
/** Fill the 42 cells for `month`. The only DOM write path — nothing here creates an element. */
const render = () => {
const days = monthGrid(month, weekStart);
const key = month.slice(0, 7);
const today = todayISO();
const value = root.dataset.value ?? "";
cells.forEach((cell, i) => {
const iso = days[i];
cell.dataset.date = iso;
cell.textContent = String(Number(iso.slice(8, 10)));
cell.setAttribute("aria-label", formatFull(iso, locale));
cell.disabled = !inRange(iso, min, max);
cell.tabIndex = iso === focused ? 0 : -1;
flag(cell, "outside", key !== iso.slice(0, 7));
flag(cell, "today", iso === today);
cell.parentElement?.setAttribute("aria-selected", String(iso === value));
});
// The roving date can sit outside the rendered grid (paging by year, say). Something must stay
// tabbable or the calendar drops out of the tab order entirely.
if (!cells.some((cell) => cell.tabIndex === 0)) cells[0].tabIndex = 0;
const label = formatMonth(month, locale);
if (caption) caption.textContent = label;
grid.setAttribute("aria-label", label);
root.dataset.month = month;
// The last day of the previous month / the first of the next: if that day is out of bounds,
// so is every day the button would page to.
if (previous) previous.disabled = !inRange(addDays(month, -1), min, max);
if (next) next.disabled = !inRange(addMonths(month, 1), min, max);
rendered = value;
};
/** Move the roving date (paging the view if it left the month) and take focus with it. */
const show = (iso: string) => {
focused = clampToRange(iso, min, max);
month = startOfMonth(focused);
render();
cells.find((cell) => cell.dataset.date === focused)?.focus();
};
/** Page the view without moving focus — the nav buttons must keep it, the caption announces. */
const page = (delta: number) => {
month = startOfMonth(addMonths(month, delta));
const moved = clampToRange(addMonths(focused, delta), min, max);
focused = moved.slice(0, 7) === month.slice(0, 7) ? moved : month;
render();
};
const select = (iso: string) => {
root.dataset.value = iso;
if (hidden) hidden.value = iso;
focused = iso;
render();
// A plain bubbling `change`, like every other value-carrying primitive here: the value is on
// the element (`data-value`, and the hidden input), so a listener needs nothing from the event.
root.dispatchEvent(new Event("change", { bubbles: true }));
};
const dayAt = (target: EventTarget | null) =>
target instanceof Element
? target.closest<HTMLButtonElement>('[data-slot="calendar-day"]')
: null;
grid.addEventListener("click", (event) => {
const cell = dayAt(event.target);
if (cell && !cell.disabled && cell.dataset.date) select(cell.dataset.date);
});
grid.addEventListener("keydown", (event) => {
const from = dayAt(event.target)?.dataset.date;
if (!from) return;
const moves: Record<string, () => string> = {
ArrowLeft: () => addDays(from, -1),
ArrowRight: () => addDays(from, 1),
ArrowUp: () => addDays(from, -7),
ArrowDown: () => addDays(from, 7),
Home: () => startOfWeek(from, weekStart),
End: () => endOfWeek(from, weekStart),
PageUp: () => addMonths(from, event.shiftKey ? -12 : -1),
PageDown: () => addMonths(from, event.shiftKey ? 12 : 1),
};
const to = moves[event.key]?.();
if (!to) return;
event.preventDefault(); // ArrowDown and PageDown would otherwise scroll the page
show(to);
});
previous?.addEventListener("click", () => page(-1));
next?.addEventListener("click", () => page(1));
// DRIVEN FROM OUTSIDE BY THE DOM, not by an exported API — the same contract as showing a Toast
// with `toast.hidden = false`. Write `data-value` or `data-month` on the root (DatePicker does)
// and the grid follows; a changed value pulls the view to its month. The comparison against what
// was last rendered is what keeps `render()`'s own writes from re-entering.
new MutationObserver(() => {
const value = root.dataset.value ?? "";
const asked = startOfMonth(root.dataset.month || month);
if (value === rendered && asked === month) {
// An outside writer may hand `data-month` any day in the month already shown (DatePicker
// hands it the whole date). Nothing needs re-rendering, but the attribute is public, so it
// is put back in its canonical first-of-the-month form. The write re-enters here once and
// then matches, so it settles.
if (root.dataset.month !== month) root.dataset.month = month;
return;
}
if (value !== rendered && !Number.isNaN(parseISO(value))) focused = value;
month = value !== rendered && focused === value ? startOfMonth(value) : asked;
render();
}).observe(root, { attributeFilter: ["data-value", "data-month"] });
// A statically built page bakes the BUILD date into the default month. When the author named
// neither `month` nor `value`, correct it on load — otherwise a site built in September still
// opens on September in February, and `data-today` marks a day that has long gone.
if (root.dataset.autoMonth === "true") {
focused = todayISO();
month = startOfMonth(focused);
}
render();
}
onReadyOnce('[data-slot="calendar"]', wire);
</script>
---
// src/components/ui/forms/calendar/CalendarCell.astro — Calendar compound part (see ../../README.md).
// One day of the grid: a `role="gridcell"` <td> carrying the selected state, wrapping the <button>
// that is actually focusable and clickable.
//
// The split is deliberate. `aria-selected` belongs on the gridcell (that is the cell's state), while
// the thing a user operates has to be a real button so Enter and Space activate it without a line of
// JavaScript. Only ONE button in the whole grid has `tabindex="0"` — that is the roving tabindex the
// grid pattern requires: Tab enters the calendar once and the arrow keys move within it, rather than
// making a user press Tab forty-two times to get past a month.
//
// Every attribute below is also written by Calendar's script when you page to another month: the 42
// cells are filled, never rebuilt, so this file stays the single source of a day's markup.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"td"> & {
/** The day this cell shows, `YYYY-MM-DD`. */
date: string;
/** The cell's accessible name — the full localised date, since the text is only a number. */
label: string;
selected?: boolean;
/** Belongs to the previous or next month: shown, operable, and marked for a theme to dim. */
outside?: boolean;
today?: boolean;
disabled?: boolean;
/** The one cell in the grid that Tab reaches. */
tabbable?: boolean;
};
const {
date,
label,
selected,
outside,
today,
disabled,
tabbable,
class: className,
...rest
} = Astro.props;
---
<td
role="gridcell"
aria-selected={selected ? "true" : "false"}
data-slot="calendar-cell"
class={className}
{...rest}
>
<button
type="button"
data-slot="calendar-day"
data-date={date}
data-outside={outside ? "true" : undefined}
data-today={today ? "true" : undefined}
tabindex={tabbable ? 0 : -1}
aria-label={label}
disabled={disabled}
>
{Number(date.slice(8, 10))}
</button>
</td>
---
// src/components/ui/forms/calendar/CalendarGrid.astro — Calendar compound part (see ../../README.md).
// The month itself: a real <table role="grid"> of six rows of CalendarCell, with a row of
// CalendarWeekday heads. Zero-JS on its own — it renders a readable, correctly-labelled month table
// with no script at all; Calendar's script adds the keyboard and the paging.
//
// SIX ROWS, ALWAYS, even for a February that fits in four. Two reasons, one visual and one
// structural: the calendar does not change height as you page, and the script can FILL 42 existing
// cells instead of building new ones — so no markup is ever generated in JavaScript and CalendarCell
// stays the only place a day's markup is written.
//
// The grid is `aria-label`led with the month rather than a generic word, because "September 2026"
// is what tells a screen reader user which month they just paged to; the script rewrites it in step
// with the caption.
import type { HTMLAttributes } from "astro/types";
import {
formatFull,
formatMonth,
inRange,
monthGrid,
startOfMonth,
todayISO,
weekdayNames,
} from "./calendar-grid";
import CalendarCell from "./CalendarCell.astro";
import CalendarWeekday from "./CalendarWeekday.astro";
type Props = HTMLAttributes<"table"> & {
/** Any date in the month to render. */
month: string;
value?: string;
min?: string;
max?: string;
locale?: string;
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
};
const { month, value, min, max, locale, weekStart = 1, class: className, ...rest } = Astro.props;
const first = startOfMonth(month);
const key = first.slice(0, 7);
const days = monthGrid(first, weekStart);
const today = todayISO();
// The one cell Tab reaches: the selected day if it is in this month, else today, else the 1st.
// A grid where Tab lands on a day in the previous month is disorienting, which is why `outside`
// cells never win here.
const inMonth = (iso?: string) => Boolean(iso) && iso!.slice(0, 7) === key;
const tabbable = inMonth(value) ? value! : inMonth(today) ? today : first;
const rows = Array.from({ length: 6 }, (_, r) => days.slice(r * 7, r * 7 + 7));
---
<table
role="grid"
aria-label={formatMonth(first, locale)}
data-slot="calendar-grid"
class={className}
{...rest}
>
<thead>
<tr>
{
weekdayNames(locale, weekStart).map((d) => (
<CalendarWeekday short={d.short} long={d.long} />
))
}
</tr>
</thead>
<tbody>
{
rows.map((week) => (
<tr>
{week.map((iso) => (
<CalendarCell
date={iso}
label={formatFull(iso, locale)}
selected={iso === value}
outside={iso.slice(0, 7) !== key}
today={iso === today}
disabled={!inRange(iso, min, max)}
tabbable={iso === tabbable}
/>
))}
</tr>
))
}
</tbody>
</table>
---
// src/components/ui/forms/calendar/CalendarHeader.astro — Calendar compound part (see ../../README.md).
// The month caption plus whatever you put beside it (Calendar puts CalendarNav there).
//
// The caption is `aria-live="polite"`, and that is the part that earns this file. Paging with the
// nav buttons or PageUp/PageDown changes which month the 42 cells describe, but focus stays where it
// was — so without a live region a screen reader user presses "next month" and is told nothing at
// all. Polite, not assertive: it should follow the keystroke, not talk over it.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
/** The month caption, e.g. "September 2026". Calendar's script rewrites it on navigation. */
label: string;
};
const { label, class: className, ...rest } = Astro.props;
---
<div data-slot="calendar-header" class={className} {...rest}>
<div data-slot="calendar-label" aria-live="polite">{label}</div>
<slot />
</div>
---
// src/components/ui/forms/calendar/CalendarNav.astro — Calendar compound part (see ../../README.md).
// The previous / next month buttons. Calendar's script pages the grid and disables whichever button
// would step past `min` / `max` — a disabled button is the honest signal that there is nothing
// further, and it also keeps a keyboard user from landing on a month with no reachable days.
//
// The labels are props rather than slotted text because they are the buttons' ACCESSIBLE NAMES and
// the glyphs are `aria-hidden` — "‹" announced as "single left-pointing angle quotation mark" is
// what happens when an icon button is left to name itself. Translate them by passing them.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
previousLabel?: string;
nextLabel?: string;
};
const {
previousLabel = "Previous month",
nextLabel = "Next month",
class: className,
...rest
} = Astro.props;
---
<div data-slot="calendar-nav" class={className} {...rest}>
<button type="button" data-slot="calendar-nav-previous" aria-label={previousLabel}>
<slot name="previous"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6"></path>
</svg></slot
>
</button>
<button type="button" data-slot="calendar-nav-next" aria-label={nextLabel}>
<slot name="next"
><svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6"></path>
</svg></slot
>
</button>
</div>
---
// src/components/ui/forms/calendar/CalendarWeekday.astro — Calendar compound part (see ../../README.md).
// One column head, and it renders the weekday TWICE on purpose: the short name for the eye, the full
// name for assistive tech. A screen reader announcing the column of a date cell would otherwise read
// the abbreviation as written — "Mon", "Tue" — which is either spelled out letter by letter or
// pronounced as a word, depending on the reader.
//
// `<th abbr="Monday">` is the native answer to exactly this and is the reason the markup is not
// three elements deep, but support for it across screen readers is uneven enough that the full name
// is also rendered and visually hidden by structure.css. `scope="col"` is what associates all six
// rows of cells below with this head.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"th"> & {
/** The abbreviation shown, e.g. "Mon". */
short: string;
/** The full name announced, e.g. "Monday". */
long: string;
};
const { short, long, class: className, ...rest } = Astro.props;
---
<th scope="col" abbr={long} data-slot="calendar-weekday" class={className} {...rest}>
<span aria-hidden="true">{short}</span><span data-slot="calendar-weekday-name">{long}</span>
</th>
// src/components/ui/forms/calendar/calendar-grid.ts — the date arithmetic behind Calendar (and, through
// it, DatePicker), in a plain module so it is unit-checkable without a DOM (see calendar-grid.test.ts)
// and so the SAME code runs twice: once in Calendar's frontmatter to render the first month at build
// time, and again in its bundled <script> when you page to another month. One implementation, so a
// navigated month can never disagree with a server-rendered one.
//
// Everything here is UTC. A calendar grid is about calendar days, not instants, and local-time
// arithmetic gets this wrong exactly twice a year: in a zone that springs forward at midnight,
// `new Date(y, m, d + 1)` lands on the same day at 23:00 and the grid repeats a date. Parsing
// `YYYY-MM-DD` with an explicit `Z` and stepping in whole days removes the class of bug.
//
// Formatting is `Intl`, never a table of month names: it is in the platform, it is localised, and it
// is available in Node too — which is what lets the same functions run in frontmatter.
const DAY_MS = 86_400_000;
/** The 6×7 grid every month renders into. Always 42 days — see `monthGrid`. */
export const GRID_DAYS = 42;
/** `YYYY-MM-DD` for a UTC timestamp. */
export function isoDate(time: number): string {
return new Date(time).toISOString().slice(0, 10);
}
/**
* Parse `YYYY-MM-DD` as UTC midnight; `NaN` for anything else, including a well-formed impossible
* date like `2026-02-30`. Callers treat `NaN` as "no date", which is how an empty input arrives.
*
* The round-trip is the check that matters: V8 happily parses `2026-02-30` and hands back 2 March,
* so a component that trusted the format alone would show a date the user never typed.
*/
export function parseISO(iso: string | null | undefined): number {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return NaN;
const t = Date.parse(`${iso}T00:00:00Z`);
return !Number.isNaN(t) && isoDate(t) === iso ? t : NaN;
}
/** `iso` moved by whole days. Invalid input passes straight back out, so callers can chain. */
export function addDays(iso: string, delta: number): string {
const t = parseISO(iso);
return Number.isNaN(t) ? iso : isoDate(t + delta * DAY_MS);
}
/**
* `iso` moved by whole months, with the day CLAMPED to the target month's length — 31 January plus
* one month is 28 February, not 3 March. Rolling over is the behavior people report as a bug when
* paging a calendar, because the day under the cursor silently changes month twice.
*/
export function addMonths(iso: string, delta: number): string {
const t = parseISO(iso);
if (Number.isNaN(t)) return iso;
const d = new Date(t);
const year = d.getUTCFullYear();
const month = d.getUTCMonth() + delta;
const last = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
return isoDate(Date.UTC(year, month, Math.min(d.getUTCDate(), last)));
}
/** The first day of `iso`'s week. `weekStartsOn` is 0 = Sunday … 6 = Saturday. */
export function startOfWeek(iso: string, weekStartsOn = 1): string {
const t = parseISO(iso);
if (Number.isNaN(t)) return iso;
const back = (new Date(t).getUTCDay() - weekStartsOn + 7) % 7;
return isoDate(t - back * DAY_MS);
}
/** The last day of `iso`'s week. */
export function endOfWeek(iso: string, weekStartsOn = 1): string {
return addDays(startOfWeek(iso, weekStartsOn), 6);
}
/** The first of `iso`'s month, i.e. the value Calendar keeps in `data-month`. */
export function startOfMonth(iso: string): string {
return Number.isNaN(parseISO(iso)) ? iso : `${iso.slice(0, 7)}-01`;
}
/**
* The 42 dates of the grid that shows `iso`'s month: the month itself, padded at the front to the
* week start and at the back to a full six rows.
*
* ALWAYS six rows, even for a 28-day February that fits in four. A grid whose row count changes as
* you page makes the whole component jump under the pointer, and — the reason it is here rather than
* in a theme — lets the script fill a fixed set of cells on navigation instead of building DOM,
* which is what keeps markup generation out of JavaScript entirely (contract rule 4's neighbour).
*
* @param iso - any date in the month to show
* @param weekStartsOn - 0 = Sunday … 6 = Saturday
* @returns 42 `YYYY-MM-DD` strings, in reading order
* @example monthGrid("2026-09-11", 1)[0] // => "2026-08-31" (the Monday before 1 September)
*/
export function monthGrid(iso: string, weekStartsOn = 1): string[] {
const first = startOfWeek(startOfMonth(iso), weekStartsOn);
const t = parseISO(first);
if (Number.isNaN(t)) return [];
return Array.from({ length: GRID_DAYS }, (_, i) => isoDate(t + i * DAY_MS));
}
/** Whether `iso` falls inside an optional `min`/`max` pair. Empty bounds mean unbounded. */
export function inRange(iso: string, min?: string | null, max?: string | null): boolean {
const t = parseISO(iso);
if (Number.isNaN(t)) return false;
const lo = parseISO(min);
const hi = parseISO(max);
return !(t < lo) && !(t > hi);
}
/** `iso` pulled inside `min`/`max`. Used when paging lands the focused day out of bounds. */
export function clampToRange(iso: string, min?: string | null, max?: string | null): string {
const t = parseISO(iso);
if (Number.isNaN(t)) return iso;
const lo = parseISO(min);
const hi = parseISO(max);
if (!Number.isNaN(lo) && t < lo) return isoDate(lo);
if (!Number.isNaN(hi) && t > hi) return isoDate(hi);
return iso;
}
/** `"September 2026"` — the heading, localised, with no month-name table anywhere. */
export function formatMonth(iso: string, locale?: string): string {
return new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(parseISO(iso));
}
/** `"Friday, 11 September 2026"` — a cell's accessible name; the visible text is just the number. */
export function formatFull(iso: string, locale?: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }).format(
parseISO(iso),
);
}
/** Weekday names in grid order, `short` for the column head and `long` for its `abbr`. */
export function weekdayNames(
locale?: string,
weekStartsOn = 1,
): { readonly short: string; readonly long: string }[] {
// 2024-01-07 was a Sunday, so offsetting from it gives any week start without a lookup table.
const sunday = Date.UTC(2024, 0, 7);
return Array.from({ length: 7 }, (_, i) => {
const day = new Date(sunday + ((weekStartsOn + i) % 7) * DAY_MS);
return {
short: new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }).format(day),
long: new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }).format(day),
};
});
}
/**
* Today, as the user's calendar reckons it — LOCAL fields, not `toISOString()`. Every other function
* here is UTC because a grid is about calendar days, but "today" is the one value that has to agree
* with the wall clock in the room: at 21:00 in New York the UTC date is already tomorrow, and a
* calendar that highlights tomorrow is simply wrong.
*/
export function todayISO(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
import Calendar from "./Calendar.astro";
import CalendarCell from "./CalendarCell.astro";
import CalendarGrid from "./CalendarGrid.astro";
import CalendarHeader from "./CalendarHeader.astro";
import CalendarNav from "./CalendarNav.astro";
import CalendarWeekday from "./CalendarWeekday.astro";
export { Calendar, CalendarCell, CalendarGrid, CalendarHeader, CalendarNav, CalendarWeekday };
export default Calendar;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/calendar — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.