Component
Calendar — Controlled State
A fully parent-controlled calendar: the visible month, the selected date, and even the locale live in parent state, with external month/year/locale selectors and an event log of every callback.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/calendar/calendar-controlled/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Calendar/calendar-controlled React/Components/Calendar/calendar-controlled import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
HTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
ReactElement,
ReactNode,
} from "react";
/**
* DevSnips React Calendar — controlled state.
*
* The shared compound Calendar with parent-owned month, selection, and
* locale: every change flows through `onMonthChange` / `onSelect`.
* Implementation identical to the reference `calendar/code.tsx`.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ------------------------------------------------------------------------ */
/* Date utilities — local calendar-date semantics */
/* ------------------------------------------------------------------------ */
export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
const WEEK_MS = 604800000;
/** Days in a (year, month); `month` is 0-based. Handles leap years. */
export function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
/** Whether a Gregorian year is a leap year. */
export function isLeapYear(year: number): boolean {
return new Date(year, 1, 29).getMonth() === 1;
}
/**
* Numeric identity of a local calendar date:
* `year * 10000 + (month + 1) * 100 + day`. Strictly monotonic with calendar
* order, so keys compare directly — no string comparison and no timestamp
* comparison (both break across DST / UTC boundaries).
*/
function dayKey(date: Date): number {
return date.getFullYear() * 10000 + (date.getMonth() + 1) * 100 + date.getDate();
}
/** Rebuild a local calendar date from its `dayKey`. */
function dateFromKey(key: number): Date {
const year = Math.floor(key / 10000);
const month = Math.floor((key % 10000) / 100) - 1;
return new Date(year, month, key % 100);
}
/** -1 / 0 / 1 comparison by calendar day; time-of-day is ignored. */
export function compareDays(a: Date, b: Date): number {
const diff = dayKey(a) - dayKey(b);
return diff === 0 ? 0 : diff < 0 ? -1 : 1;
}
/** Whether two Dates fall on the same local calendar day. */
export function isSameDay(a: Date, b: Date): boolean {
return dayKey(a) === dayKey(b);
}
/**
* Add calendar days using local constructor arithmetic. This is DST-safe:
* `new Date(y, m, d + n)` normalizes month/year overflow itself, unlike
* timestamp math (`date.getTime() + n * 86400000`), which skips or repeats
* an hour across DST transitions and can shift the calendar day.
*/
export function addDays(date: Date, amount: number): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);
}
/**
* Add calendar months, clamping the day-of-month to the target month
* (`Jan 31 + 1 month` lands on `Feb 28/29`, not `Mar 2/3`). Handles
* December ↔ January year rollover via constructor normalization.
*/
export function addMonths(date: Date, amount: number): Date {
const first = new Date(date.getFullYear(), date.getMonth() + amount, 1);
const day = Math.min(date.getDate(), daysInMonth(first.getFullYear(), first.getMonth()));
return new Date(first.getFullYear(), first.getMonth(), day);
}
/** The first day of the month containing `date`. */
export function startOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
/** The last day of the month containing `date`. */
export function endOfMonth(date: Date): Date {
const year = date.getFullYear();
const month = date.getMonth();
return new Date(year, month, daysInMonth(year, month));
}
/**
* ISO-8601 week number (weeks start Monday; week 1 contains the year's
* first Thursday). Computed on local-noon copies so the Thursday shift is
* never affected by a midnight DST transition.
*/
export function isoWeekNumber(date: Date): number {
const noon = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 12);
const thursday = addDays(noon, 3 - ((noon.getDay() + 6) % 7));
const jan4 = new Date(thursday.getFullYear(), 0, 4, 12);
const weekOneThursday = addDays(jan4, 3 - ((jan4.getDay() + 6) % 7));
return 1 + Math.round((thursday.getTime() - weekOneThursday.getTime()) / WEEK_MS);
}
/**
* The 6 × 7 day matrix covering `month`, aligned to `weekStartsOn`.
* Always exactly six rows so the grid never changes height between months.
* Leading / trailing cells hold adjacent-month dates.
*/
export function buildMonthWeeks(month: Date, weekStartsOn: WeekDay): Date[][] {
const first = startOfMonth(month);
const leading = (first.getDay() - weekStartsOn + 7) % 7;
const gridStart = addDays(first, -leading);
const weeks: Date[][] = [];
for (let w = 0; w < 6; w += 1) {
const row: Date[] = [];
for (let d = 0; d < 7; d += 1) row.push(addDays(gridStart, w * 7 + d));
weeks.push(row);
}
return weeks;
}
/* ------------------------------------------------------------------------ */
/* Locale-aware formatting (Intl — no hardcoded month / weekday names) */
/* ------------------------------------------------------------------------ */
function monthYearLabel(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
}
function monthName(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
}
function yearLabel(year: number, locale: string): string {
return new Intl.DateTimeFormat(locale, { year: "numeric" }).format(new Date(year, 0, 1));
}
function fullDateLabel(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
}
function monthNames(locale: string): string[] {
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
return Array.from({ length: 12 }, (_, m) => fmt.format(new Date(2024, m, 1)));
}
/** Short + long weekday names in grid order for a given week start. */
function weekdayNames(locale: string, weekStartsOn: WeekDay): Array<{ short: string; long: string }> {
// 2024-01-07 was a Sunday — a fixed reference week, independent of today.
const base = new Date(2024, 0, 7);
const shortFmt = new Intl.DateTimeFormat(locale, { weekday: "short" });
const longFmt = new Intl.DateTimeFormat(locale, { weekday: "long" });
return Array.from({ length: 7 }, (_, i) => {
const day = addDays(base, (weekStartsOn + i) % 7);
return { short: shortFmt.format(day), long: longFmt.format(day) };
});
}
/* ------------------------------------------------------------------------ */
/* Public types */
/* ------------------------------------------------------------------------ */
export interface DateRange {
from: Date;
to: Date | null;
}
export type CalendarMode = "single" | "multiple" | "range";
export type CalendarView = "days" | "months" | "years";
export interface CalendarBaseProps {
/** Visible month (controlled). Any Date inside the month; normalized to the 1st. */
month?: Date;
/** Initial visible month (uncontrolled). Defaults to the month of the initial selection, else today. */
defaultMonth?: Date;
/** Called whenever the visible month changes (navigation, keyboard, outside-day click, picker selection). */
onMonthChange?: (month: Date) => void;
/** Initial picker view. The heading button cycles days → months → years. */
defaultView?: CalendarView;
/** Earliest selectable date (inclusive). Earlier dates and earlier navigation are disabled. */
minDate?: Date;
/** Latest selectable date (inclusive). Later dates and later navigation are disabled. */
maxDate?: Date;
/** Matcher for individual disabled dates; combined with `minDate` / `maxDate`. */
disabled?: (date: Date) => boolean;
/** BCP-47 locale tag for month / weekday / day labels. Default `"en-US"`. */
locale?: string;
/** First weekday column: 0 = Sunday … 6 = Saturday. Default 0. */
weekStartsOn?: WeekDay;
/** Render adjacent-month days at the grid edges (they select + navigate on click). Default false. */
showOutsideDays?: boolean;
/** Render a leading ISO-8601 week-number column (non-interactive). Default false. */
showWeekNumbers?: boolean;
/** Number of consecutive month grids. Render one `<CalendarGrid monthOffset={i}>` per month. Default 1. */
numberOfMonths?: number;
className?: string;
children?: ReactNode;
}
export interface CalendarSingleSelectionProps {
mode?: "single";
selected?: Date | null;
defaultSelected?: Date | null;
onSelect?: (date: Date | null) => void;
}
export interface CalendarMultipleSelectionProps {
mode: "multiple";
selected?: Date[];
defaultSelected?: Date[];
onSelect?: (dates: Date[]) => void;
}
export interface CalendarRangeSelectionProps {
mode: "range";
selected?: DateRange | null;
defaultSelected?: DateRange | null;
onSelect?: (range: DateRange | null) => void;
}
export type CalendarProps = CalendarBaseProps &
(CalendarSingleSelectionProps | CalendarMultipleSelectionProps | CalendarRangeSelectionProps);
/* ------------------------------------------------------------------------ */
/* Controlled / uncontrolled state */
/* ------------------------------------------------------------------------ */
function useControllableState<T>(
controlled: T | undefined,
defaultValue: T,
onChange: ((value: T) => void) | undefined,
): [T, (value: T) => void] {
const [internal, setInternal] = useState<T>(defaultValue);
const isControlled = controlled !== undefined;
const value = isControlled ? (controlled as T) : internal;
const set = useCallback(
(next: T) => {
if (!isControlled) setInternal(next);
if (onChange) onChange(next);
},
[isControlled, onChange],
);
return [value, set];
}
/* ------------------------------------------------------------------------ */
/* Context */
/* ------------------------------------------------------------------------ */
export interface CalendarContextValue {
mode: CalendarMode;
locale: string;
weekStartsOn: WeekDay;
showOutsideDays: boolean;
showWeekNumbers: boolean;
numberOfMonths: number;
view: CalendarView;
setView: (view: CalendarView) => void;
/** First visible month (normalized to its 1st day). */
month: Date;
today: Date;
headingLabel: string;
goToMonth: (month: Date) => void;
goToPrevious: () => void;
goToNext: () => void;
canGoPrevious: boolean;
canGoNext: boolean;
previousLabel: string;
nextLabel: string;
isDisabled: (date: Date) => boolean;
isSelected: (date: Date) => boolean;
isRangeStart: (date: Date) => boolean;
isRangeEnd: (date: Date) => boolean;
isRangeMiddle: (date: Date) => boolean;
selectDate: (date: Date) => void;
clearSelection: () => void;
handleDayClick: (date: Date, outside: boolean) => void;
handleDayKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>, date: Date) => void;
tabbableDayKey: number | null;
monthEnabled: (year: number, monthIndex: number) => boolean;
yearEnabled: (year: number) => boolean;
monthActiveKey: string | null;
yearActiveKey: string | null;
yearPage: number[];
selectMonth: (monthIndex: number) => void;
selectYear: (year: number) => void;
handleMonthKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>, monthIndex: number) => void;
handleYearKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>, year: number) => void;
focusKey: string | null;
setFocusKey: (key: string) => void;
}
const CalendarContext = createContext<CalendarContextValue | null>(null);
/** Access the nearest `<Calendar>` context (for composed children such as footer actions). */
export function useCalendar(): CalendarContextValue {
const ctx = useContext(CalendarContext);
if (!ctx) throw new Error("Calendar components must be composed inside <Calendar>.");
return ctx;
}
/* ------------------------------------------------------------------------ */
/* Shared class constants (token-driven) */
/* ------------------------------------------------------------------------ */
const ROOT_CLASSES =
"w-max max-w-full rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] p-3 shadow-[var(--ds-shadow-xs)]";
const HEADER_CLASSES = "mb-1 flex items-center justify-between gap-1";
const NAV_BUTTON_CLASSES =
"inline-flex size-9 shrink-0 items-center justify-center rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-muted-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const HEADING_CLASSES = "m-0 text-sm font-semibold leading-9 text-[var(--ds-color-foreground)]";
const HEADING_BUTTON_CLASSES =
"-mx-1.5 rounded-[var(--ds-radius-sm)] px-1.5 transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const WEEKDAY_CLASSES =
"flex size-9 items-center justify-center text-[11px] font-medium uppercase tracking-[0.04em] text-[var(--ds-color-muted-foreground)]";
const WEEKNUM_CLASSES =
"flex size-9 items-center justify-center font-mono text-[11px] tabular-nums text-[var(--ds-color-muted-foreground)]";
const DAY_CLASSES =
"inline-flex size-9 items-center justify-center rounded-[var(--ds-radius-sm)] border border-transparent text-sm leading-5 tabular-nums transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed motion-reduce:transition-none";
const PICKER_BUTTON_CLASSES =
"flex h-9 flex-1 items-center justify-center rounded-[var(--ds-radius-sm)] border border-transparent px-2 text-sm leading-5 transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-40 motion-reduce:transition-none";
const FOOTER_CLASSES =
"mt-2 flex items-center justify-between gap-2 border-t border-[var(--ds-color-border-subtle)] pt-2 text-xs leading-4 text-[var(--ds-color-muted-foreground)]";
const SELECTED_DAY_CLASSES =
"bg-[var(--ds-color-primary)] font-medium text-[var(--ds-color-primary-foreground)] hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)]";
const RANGE_MIDDLE_CLASSES = "rounded-none bg-[var(--ds-color-surface-active)] text-[var(--ds-color-foreground)]";
const PICKER_SELECTED_CLASSES = "bg-[var(--ds-color-primary)] font-medium text-[var(--ds-color-primary-foreground)]";
/* ------------------------------------------------------------------------ */
/* Icons (inline SVG — consistent stroke with the DevSnips icon set) */
/* ------------------------------------------------------------------------ */
function ChevronLeftIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
<path d="m15 18-6-6 6-6" />
</svg>
);
}
function ChevronRightIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
<path d="m9 18 6-6-6-6" />
</svg>
);
}
/* ------------------------------------------------------------------------ */
/* Root */
/* ------------------------------------------------------------------------ */
export function Calendar(props: CalendarProps): ReactElement {
const {
month: controlledMonth,
defaultMonth,
onMonthChange,
defaultView = "days",
minDate,
maxDate,
disabled,
locale = "en-US",
weekStartsOn = 0,
showOutsideDays = false,
showWeekNumbers = false,
numberOfMonths = 1,
className,
children,
// Selection props are consumed through the typed slices below — they are
// destructured here so they never leak onto the DOM via `...rest`
// (`onSelect` also collides with the native React event handler).
mode: _mode,
selected: _selected,
defaultSelected: _defaultSelected,
onSelect: _onSelect,
...rest
} = props;
const mode: CalendarMode = _mode ?? "single";
// The discriminated union guarantees each selection slice matches `mode`;
// the casts re-associate the union member with its own props. A slice is
// only ever read or written when `mode` selects it — the other two stay
// dormant for the life of the component.
const singleSlice = props as CalendarBaseProps & CalendarSingleSelectionProps;
const multipleSlice = props as CalendarBaseProps & CalendarMultipleSelectionProps;
const rangeSlice = props as CalendarBaseProps & CalendarRangeSelectionProps;
const [singleSelection, setSingleSelection] = useControllableState<Date | null>(
mode === "single" ? singleSlice.selected : undefined,
(mode === "single" ? singleSlice.defaultSelected : null) ?? null,
mode === "single" ? singleSlice.onSelect : undefined,
);
const [multipleSelection, setMultipleSelection] = useControllableState<Date[]>(
mode === "multiple" ? multipleSlice.selected : undefined,
(mode === "multiple" ? multipleSlice.defaultSelected : undefined) ?? [],
mode === "multiple" ? multipleSlice.onSelect : undefined,
);
const [rangeSelection, setRangeSelection] = useControllableState<DateRange | null>(
mode === "range" ? rangeSlice.selected : undefined,
(mode === "range" ? rangeSlice.defaultSelected : undefined) ?? null,
mode === "range" ? rangeSlice.onSelect : undefined,
);
const [today] = useState(() => new Date());
const firstSelected =
mode === "single"
? singleSelection
: mode === "multiple"
? (multipleSelection[0] ?? null)
: (rangeSelection?.from ?? null);
// Visible month — controlled (`month`) or uncontrolled (`defaultMonth`).
// Normalized to the 1st; the numeric key stabilizes the memo below across
// fresh Date identities from a controlled parent.
const [monthState, setMonthState] = useControllableState<Date>(
controlledMonth !== undefined ? startOfMonth(controlledMonth) : undefined,
startOfMonth(defaultMonth ?? firstSelected ?? today),
onMonthChange,
);
const monthKey = monthState.getFullYear() * 12 + monthState.getMonth();
const month = useMemo(() => startOfMonth(monthState), [monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
const [view, setView] = useState<CalendarView>(defaultView);
const [focusKey, setFocusKey] = useState<string | null>(null);
const focusIntentRef = useRef(false);
const rootRef = useRef<HTMLDivElement>(null);
/* ----- constraints ----------------------------------------------------- */
const isDisabled = useCallback(
(date: Date): boolean => {
if (minDate && compareDays(date, minDate) < 0) return true;
if (maxDate && compareDays(date, maxDate) > 0) return true;
return disabled ? disabled(date) : false;
},
[minDate, maxDate, disabled],
);
const monthEnabled = useCallback(
(year: number, monthIndex: number): boolean => {
if (minDate && compareDays(endOfMonth(new Date(year, monthIndex, 1)), minDate) < 0) return false;
if (maxDate && compareDays(new Date(year, monthIndex, 1), maxDate) > 0) return false;
return true;
},
[minDate, maxDate],
);
const yearEnabled = useCallback(
(year: number): boolean => {
if (minDate && compareDays(new Date(year, 11, 31), minDate) < 0) return false;
if (maxDate && compareDays(new Date(year, 0, 1), maxDate) > 0) return false;
return true;
},
[minDate, maxDate],
);
/* ----- visible range ---------------------------------------------------- */
const lastVisibleMonth = useMemo(
() => startOfMonth(addMonths(month, numberOfMonths - 1)),
[month, numberOfMonths],
);
// Range of day cells actually rendered (in-month cells of every visible
// month, plus the edge outside cells when `showOutsideDays` is on). The
// range is continuous — interior outside cells of multi-month layouts are
// not rendered (they would duplicate the neighbouring grid's in-month
// cells), but every date in between exists as an in-month cell.
const [renderedStart, renderedEnd] = useMemo((): [Date, Date] => {
if (!showOutsideDays) return [month, endOfMonth(lastVisibleMonth)];
const firstWeeks = buildMonthWeeks(month, weekStartsOn);
const lastWeeks = buildMonthWeeks(lastVisibleMonth, weekStartsOn);
const start = firstWeeks[0]?.[0] ?? month;
const end = lastWeeks[lastWeeks.length - 1]?.[6] ?? endOfMonth(lastVisibleMonth);
return [start, end];
}, [month, lastVisibleMonth, weekStartsOn, showOutsideDays]);
const isRenderedDate = useCallback(
(date: Date): boolean =>
compareDays(date, renderedStart) >= 0 && compareDays(date, renderedEnd) <= 0,
[renderedStart, renderedEnd],
);
const goToMonth = useCallback(
(target: Date) => {
let t = startOfMonth(target);
if (maxDate) {
const latest = startOfMonth(addMonths(maxDate, -(numberOfMonths - 1)));
if (compareDays(t, latest) > 0) t = latest;
}
if (minDate && compareDays(t, startOfMonth(minDate)) < 0) t = startOfMonth(minDate);
setMonthState(t);
},
[minDate, maxDate, numberOfMonths, setMonthState],
);
/** The first visible month that reveals `date` (unchanged when already visible). */
const monthToReveal = useCallback(
(date: Date): Date => {
if (compareDays(date, month) < 0) return startOfMonth(date);
if (compareDays(date, endOfMonth(lastVisibleMonth)) > 0) {
return startOfMonth(addMonths(date, -(numberOfMonths - 1)));
}
return month;
},
[month, lastVisibleMonth, numberOfMonths],
);
/* ----- navigation ------------------------------------------------------- */
const navStep = view === "days" ? 1 : view === "months" ? 12 : 144;
let canGoPrevious = true;
let canGoNext = true;
if (view === "days") {
canGoPrevious = !minDate || compareDays(endOfMonth(addMonths(month, -1)), minDate) >= 0;
canGoNext = !maxDate || compareDays(startOfMonth(addMonths(month, numberOfMonths)), maxDate) <= 0;
} else if (view === "months") {
const y = month.getFullYear();
canGoPrevious = !minDate || compareDays(new Date(y - 1, 11, 31), minDate) >= 0;
canGoNext = !maxDate || compareDays(new Date(y + 1, 0, 1), maxDate) <= 0;
} else {
const pageStart = Math.floor(month.getFullYear() / 12) * 12;
canGoPrevious = !minDate || compareDays(new Date(pageStart - 1, 11, 31), minDate) >= 0;
canGoNext = !maxDate || compareDays(new Date(pageStart + 12, 0, 1), maxDate) <= 0;
}
const goToPrevious = useCallback(() => {
if (canGoPrevious) goToMonth(addMonths(month, -navStep));
}, [canGoPrevious, goToMonth, month, navStep]);
const goToNext = useCallback(() => {
if (canGoNext) goToMonth(addMonths(month, navStep));
}, [canGoNext, goToMonth, month, navStep]);
const previousLabel =
view === "days" ? "Go to previous month" : view === "months" ? "Go to previous year" : "Go to previous 12 years";
const nextLabel =
view === "days" ? "Go to next month" : view === "months" ? "Go to next year" : "Go to next 12 years";
const headingLabel = useMemo(() => {
if (view === "months") return yearLabel(month.getFullYear(), locale);
if (view === "years") {
const pageStart = Math.floor(month.getFullYear() / 12) * 12;
return `${yearLabel(pageStart, locale)} – ${yearLabel(pageStart + 11, locale)}`;
}
if (numberOfMonths === 1) return monthYearLabel(month, locale);
const last = addMonths(month, numberOfMonths - 1);
if (month.getFullYear() === last.getFullYear()) {
return `${monthName(month, locale)} – ${monthYearLabel(last, locale)}`;
}
return `${monthYearLabel(month, locale)} – ${monthYearLabel(last, locale)}`;
}, [view, month, numberOfMonths, locale]);
const yearPage = useMemo(() => {
const pageStart = Math.floor(month.getFullYear() / 12) * 12;
return Array.from({ length: 12 }, (_, i) => pageStart + i);
}, [month]);
/* ----- selection -------------------------------------------------------- */
// A range may never silently cross a disabled date (disabled includes the
// min/max boundaries). Checked on completion; endpoints themselves can
// never be disabled because disabled day buttons cannot be activated.
const rangeCrossesDisabled = useCallback(
(from: Date, to: Date): boolean => {
let cursor = addDays(from, 1);
for (let i = 0; i < 3700 && compareDays(cursor, to) < 0; i += 1) {
if (isDisabled(cursor)) return true;
cursor = addDays(cursor, 1);
}
return false;
},
[isDisabled],
);
const selectDate = useCallback(
(date: Date) => {
if (isDisabled(date)) return;
if (mode === "single") {
// Clicking the selected day is a no-op — never an accidental deselect.
if (singleSelection && isSameDay(singleSelection, date)) return;
setSingleSelection(date);
return;
}
if (mode === "multiple") {
const exists = multipleSelection.some((d) => isSameDay(d, date));
// Immutable updates — caller-owned arrays are never mutated.
setMultipleSelection(
exists ? multipleSelection.filter((d) => !isSameDay(d, date)) : [...multipleSelection, date],
);
return;
}
// range
if (!rangeSelection || rangeSelection.to) {
setRangeSelection({ from: date, to: null });
return;
}
const from = rangeSelection.from;
if (isSameDay(date, from)) {
setRangeSelection({ from, to: from });
} else if (compareDays(date, from) < 0 || rangeCrossesDisabled(from, date)) {
// Earlier-than-start or crossing a disabled day: restart, predictably.
setRangeSelection({ from: date, to: null });
} else {
setRangeSelection({ from, to: date });
}
},
[
isDisabled,
mode,
singleSelection,
multipleSelection,
rangeSelection,
setSingleSelection,
setMultipleSelection,
setRangeSelection,
rangeCrossesDisabled,
],
);
const clearSelection = useCallback(() => {
if (mode === "single") setSingleSelection(null);
else if (mode === "multiple") setMultipleSelection([]);
else setRangeSelection(null);
}, [mode, setSingleSelection, setMultipleSelection, setRangeSelection]);
const isRangeStart = useCallback(
(date: Date) =>
mode === "range" &&
rangeSelection !== null &&
rangeSelection.to !== null &&
!isSameDay(rangeSelection.from, rangeSelection.to) &&
isSameDay(rangeSelection.from, date),
[mode, rangeSelection],
);
const isRangeEnd = useCallback(
(date: Date) =>
mode === "range" &&
rangeSelection !== null &&
rangeSelection.to !== null &&
!isSameDay(rangeSelection.from, rangeSelection.to) &&
isSameDay(rangeSelection.to, date),
[mode, rangeSelection],
);
const isRangeMiddle = useCallback(
(date: Date) =>
mode === "range" &&
rangeSelection !== null &&
rangeSelection.to !== null &&
compareDays(date, rangeSelection.from) > 0 &&
compareDays(date, rangeSelection.to) < 0,
[mode, rangeSelection],
);
const isSelected = useCallback(
(date: Date): boolean => {
if (mode === "single") return singleSelection !== null && isSameDay(singleSelection, date);
if (mode === "multiple") return multipleSelection.some((d) => isSameDay(d, date));
return (
rangeSelection !== null &&
(isSameDay(rangeSelection.from, date) ||
(rangeSelection.to !== null &&
compareDays(date, rangeSelection.from) > 0 &&
compareDays(date, rangeSelection.to) <= 0))
);
},
[mode, singleSelection, multipleSelection, rangeSelection],
);
/* ----- focus model (roving tabindex) ------------------------------------ */
const focusedDay = useMemo((): Date | null => {
if (focusKey && /^\d{8}$/.test(focusKey)) return dateFromKey(Number(focusKey));
return null;
}, [focusKey]);
const tabbableDayKey = useMemo((): number | null => {
const candidates: Array<Date | null> = [focusedDay, firstSelected, today];
for (const candidate of candidates) {
if (candidate && isRenderedDate(candidate) && !isDisabled(candidate)) return dayKey(candidate);
}
for (let i = 0; i < numberOfMonths; i += 1) {
const gridMonth = startOfMonth(addMonths(month, i));
const count = daysInMonth(gridMonth.getFullYear(), gridMonth.getMonth());
for (let d = 0; d < count; d += 1) {
const date = addDays(gridMonth, d);
if (!isDisabled(date)) return dayKey(date);
}
}
return null;
}, [focusedDay, firstSelected, today, isRenderedDate, isDisabled, numberOfMonths, month]);
const monthActiveKey = useMemo((): string | null => {
const y = month.getFullYear();
const fromFocus = focusKey ? /^m(\d{1,2})$/.exec(focusKey) : null;
if (fromFocus) {
const m = Number(fromFocus[1]);
if (m < 12 && monthEnabled(y, m)) return `m${m}`;
}
if (monthEnabled(y, month.getMonth())) return `m${month.getMonth()}`;
for (let m = 0; m < 12; m += 1) if (monthEnabled(y, m)) return `m${m}`;
return null;
}, [focusKey, month, monthEnabled]);
const yearActiveKey = useMemo((): string | null => {
const fromFocus = focusKey ? /^y(\d+)$/.exec(focusKey) : null;
if (fromFocus) {
const y = Number(fromFocus[1]);
if (yearPage.includes(y) && yearEnabled(y)) return `y${y}`;
}
const current = month.getFullYear();
if (yearPage.includes(current) && yearEnabled(current)) return `y${current}`;
for (const y of yearPage) if (yearEnabled(y)) return `y${y}`;
return null;
}, [focusKey, month, yearPage, yearEnabled]);
/** Move focus to `key`; used by keyboard handlers and month-changing activations. */
const requestFocus = useCallback((key: string) => {
focusIntentRef.current = true;
setFocusKey(key);
}, []);
// Focus follows the roving key: after keyboard navigation (or a
// month-changing activation such as an outside-day click) the newly
// rendered target cell receives focus. Without an explicit intent the
// effect only refocuses while focus already lives inside the calendar, so
// pointer users are never focus-napped.
useEffect(() => {
if (focusKey == null) return;
const root = rootRef.current;
if (!root) return;
const intentional = focusIntentRef.current;
focusIntentRef.current = false;
if (!intentional && !root.contains(document.activeElement)) return;
const el = root.querySelector<HTMLElement>(`[data-cal-focus="${focusKey}"]`);
if (el && el !== document.activeElement) el.focus();
}, [focusKey, monthKey, view]);
/* ----- keyboard: day grid ------------------------------------------------ */
const stepToEnabled = useCallback(
(start: Date, step: (d: Date) => Date): Date | null => {
let cursor = start;
for (let i = 0; i < 732; i += 1) {
if (!isDisabled(cursor)) return cursor;
cursor = step(cursor);
}
return null;
},
[isDisabled],
);
const handleDayKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>, date: Date) => {
let target: Date;
let step: (d: Date) => Date;
switch (event.key) {
case "ArrowLeft":
target = addDays(date, -1);
step = (d) => addDays(d, -1);
break;
case "ArrowRight":
target = addDays(date, 1);
step = (d) => addDays(d, 1);
break;
case "ArrowUp":
target = addDays(date, -7);
step = (d) => addDays(d, -7);
break;
case "ArrowDown":
target = addDays(date, 7);
step = (d) => addDays(d, 7);
break;
case "Home":
// Start of the current week row (respects weekStartsOn).
target = addDays(date, -((date.getDay() - weekStartsOn + 7) % 7));
step = (d) => addDays(d, 1);
break;
case "End":
target = addDays(date, 6 - ((date.getDay() - weekStartsOn + 7) % 7));
step = (d) => addDays(d, -1);
break;
case "PageUp": {
const amount = event.shiftKey ? -12 : -1;
target = addMonths(date, amount);
step = (d) => addMonths(d, amount);
break;
}
case "PageDown": {
const amount = event.shiftKey ? 12 : 1;
target = addMonths(date, amount);
step = (d) => addMonths(d, amount);
break;
}
default:
return;
}
event.preventDefault();
const next = stepToEnabled(target, step);
if (!next) return;
if (!isRenderedDate(next)) goToMonth(monthToReveal(next));
requestFocus(String(dayKey(next)));
},
[weekStartsOn, stepToEnabled, isRenderedDate, goToMonth, monthToReveal, requestFocus],
);
const handleDayClick = useCallback(
(date: Date, outside: boolean) => {
if (outside) {
goToMonth(monthToReveal(date));
requestFocus(String(dayKey(date)));
}
selectDate(date);
},
[goToMonth, monthToReveal, requestFocus, selectDate],
);
/* ----- keyboard: month / year pickers ------------------------------------- */
const handleMonthKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>, monthIndex: number) => {
const year = month.getFullYear();
const norm = (y: number, m: number): { y: number; m: number } => ({
y: y + Math.floor(m / 12),
m: ((m % 12) + 12) % 12,
});
let target: { y: number; m: number };
let step: (c: { y: number; m: number }) => { y: number; m: number };
switch (event.key) {
case "ArrowLeft":
target = norm(year, monthIndex - 1);
step = (c) => norm(c.y, c.m - 1);
break;
case "ArrowRight":
target = norm(year, monthIndex + 1);
step = (c) => norm(c.y, c.m + 1);
break;
case "ArrowUp":
target = norm(year, monthIndex - 3);
step = (c) => norm(c.y, c.m - 3);
break;
case "ArrowDown":
target = norm(year, monthIndex + 3);
step = (c) => norm(c.y, c.m + 3);
break;
case "Home":
target = { y: year, m: 0 };
step = (c) => norm(c.y, c.m + 1);
break;
case "End":
target = { y: year, m: 11 };
step = (c) => norm(c.y, c.m - 1);
break;
case "PageUp":
target = { y: year - 1, m: monthIndex };
step = (c) => ({ y: c.y - 1, m: c.m });
break;
case "PageDown":
target = { y: year + 1, m: monthIndex };
step = (c) => ({ y: c.y + 1, m: c.m });
break;
default:
return;
}
event.preventDefault();
let cursor = target;
for (let i = 0; i < 240 && !monthEnabled(cursor.y, cursor.m); i += 1) cursor = step(cursor);
if (!monthEnabled(cursor.y, cursor.m)) return;
if (cursor.y !== year) goToMonth(new Date(cursor.y, month.getMonth(), 1));
requestFocus(`m${cursor.m}`);
},
[month, monthEnabled, goToMonth, requestFocus],
);
const handleYearKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>, year: number) => {
let target: number;
let step: (y: number) => number;
switch (event.key) {
case "ArrowLeft":
target = year - 1;
step = (y) => y - 1;
break;
case "ArrowRight":
target = year + 1;
step = (y) => y + 1;
break;
case "ArrowUp":
target = year - 3;
step = (y) => y - 3;
break;
case "ArrowDown":
target = year + 3;
step = (y) => y + 3;
break;
case "Home":
target = yearPage[0] ?? year;
step = (y) => y + 1;
break;
case "End":
target = yearPage[yearPage.length - 1] ?? year;
step = (y) => y - 1;
break;
case "PageUp":
target = year - 12;
step = (y) => y - 12;
break;
case "PageDown":
target = year + 12;
step = (y) => y + 12;
break;
default:
return;
}
event.preventDefault();
let cursor = target;
for (let i = 0; i < 240 && !yearEnabled(cursor); i += 1) cursor = step(cursor);
if (!yearEnabled(cursor)) return;
if (!yearPage.includes(cursor)) goToMonth(new Date(cursor, month.getMonth(), 1));
requestFocus(`y${cursor}`);
},
[month, yearPage, yearEnabled, goToMonth, requestFocus],
);
const selectMonth = useCallback(
(monthIndex: number) => {
const year = month.getFullYear();
const anchor = focusedDay ?? firstSelected ?? today;
const day = Math.min(anchor.getDate(), daysInMonth(year, monthIndex));
let target = new Date(year, monthIndex, day);
if (isDisabled(target)) {
target =
stepToEnabled(target, (d) => addDays(d, 1)) ??
stepToEnabled(target, (d) => addDays(d, -1)) ??
new Date(year, monthIndex, 1);
}
goToMonth(new Date(year, monthIndex, 1));
setView("days");
requestFocus(String(dayKey(target)));
},
[month, focusedDay, firstSelected, today, isDisabled, stepToEnabled, goToMonth, requestFocus],
);
const selectYear = useCallback(
(year: number) => {
goToMonth(new Date(year, month.getMonth(), 1));
setView("months");
requestFocus(`m${month.getMonth()}`);
},
[month, goToMonth, requestFocus],
);
const contextValue: CalendarContextValue = {
mode,
locale,
weekStartsOn,
showOutsideDays,
showWeekNumbers,
numberOfMonths,
view,
setView,
month,
today,
headingLabel,
goToMonth,
goToPrevious,
goToNext,
canGoPrevious,
canGoNext,
previousLabel,
nextLabel,
isDisabled,
isSelected,
isRangeStart,
isRangeEnd,
isRangeMiddle,
selectDate,
clearSelection,
handleDayClick,
handleDayKeyDown,
tabbableDayKey,
monthEnabled,
yearEnabled,
monthActiveKey,
yearActiveKey,
yearPage,
selectMonth,
selectYear,
handleMonthKeyDown,
handleYearKeyDown,
focusKey,
setFocusKey,
};
return (
<CalendarContext.Provider value={contextValue}>
<div ref={rootRef} className={cx(ROOT_CLASSES, className)} {...rest}>
{children}
</div>
</CalendarContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* Header parts */
/* ------------------------------------------------------------------------ */
export type CalendarPartProps = HTMLAttributes<HTMLDivElement>;
export function CalendarHeader({ className, children, ...rest }: CalendarPartProps): ReactElement {
return (
<div className={cx(HEADER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export function CalendarPrevious({ className, ...rest }: HTMLAttributes<HTMLButtonElement>): ReactElement {
const ctx = useCalendar();
return (
<button
type="button"
aria-label={ctx.previousLabel}
disabled={!ctx.canGoPrevious}
onClick={ctx.goToPrevious}
className={cx(NAV_BUTTON_CLASSES, className)}
{...rest}
>
<ChevronLeftIcon />
</button>
);
}
export function CalendarNext({ className, ...rest }: HTMLAttributes<HTMLButtonElement>): ReactElement {
const ctx = useCalendar();
return (
<button
type="button"
aria-label={ctx.nextLabel}
disabled={!ctx.canGoNext}
onClick={ctx.goToNext}
className={cx(NAV_BUTTON_CLASSES, className)}
{...rest}
>
<ChevronRightIcon />
</button>
);
}
export function CalendarHeading({ className, ...rest }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
const ctx = useCalendar();
if (ctx.view === "years") {
return (
<h2 aria-live="polite" aria-atomic="true" className={cx(HEADING_CLASSES, className)} {...rest}>
{ctx.headingLabel}
</h2>
);
}
return (
<h2 aria-live="polite" aria-atomic="true" className={cx(HEADING_CLASSES, className)} {...rest}>
<button
type="button"
onClick={() => ctx.setView(ctx.view === "days" ? "months" : "years")}
aria-label={
ctx.view === "days"
? `${ctx.headingLabel} — activate to choose a month`
: `${ctx.headingLabel} — activate to choose a year`
}
className={HEADING_BUTTON_CLASSES}
>
{ctx.headingLabel}
</button>
</h2>
);
}
/* ------------------------------------------------------------------------ */
/* Day grid */
/* ------------------------------------------------------------------------ */
export interface CalendarGridProps extends HTMLAttributes<HTMLDivElement> {
/** Which visible month this grid renders (0-based). Must be < `numberOfMonths`. */
monthOffset?: number;
}
export function CalendarGrid({ monthOffset = 0, className, ...rest }: CalendarGridProps): ReactElement | null {
const ctx = useCalendar();
if (ctx.view === "months") return monthOffset === 0 ? <MonthsPanel className={className} /> : null;
if (ctx.view === "years") return monthOffset === 0 ? <YearsPanel className={className} /> : null;
const gridMonth = startOfMonth(addMonths(ctx.month, monthOffset));
const weeks = buildMonthWeeks(gridMonth, ctx.weekStartsOn);
const weekdays = weekdayNames(ctx.locale, ctx.weekStartsOn);
// ISO weeks belong to their Thursday — the row's week number is computed
// from the Thursday of the row regardless of the configured week start.
const weekNumberOf = (row: Date[]): number =>
isoWeekNumber(addDays(row[0] ?? gridMonth, (4 - ctx.weekStartsOn + 7) % 7));
return (
<div className={className} {...rest}>
<div role="grid" aria-label={monthYearLabel(gridMonth, ctx.locale)}>
<div role="row" className="flex">
{ctx.showWeekNumbers && (
<div role="columnheader" aria-label="Week number" className={WEEKNUM_CLASSES}>
Wk
</div>
)}
{weekdays.map((day, i) => (
<div role="columnheader" key={i} aria-label={day.long} className={WEEKDAY_CLASSES}>
{day.short}
</div>
))}
</div>
{weeks.map((week, rowIndex) => (
<div role="row" className="flex" key={rowIndex}>
{ctx.showWeekNumbers && (
<div role="rowheader" className={WEEKNUM_CLASSES}>
{weekNumberOf(week)}
</div>
)}
{week.map((date) => (
<DayCell key={dayKey(date)} date={date} gridMonth={gridMonth} monthOffset={monthOffset} />
))}
</div>
))}
</div>
</div>
);
}
function DayCell({
date,
gridMonth,
monthOffset,
}: {
date: Date;
gridMonth: Date;
monthOffset: number;
}): ReactElement {
const ctx = useCalendar();
const outside = date.getMonth() !== gridMonth.getMonth();
if (outside) {
// Outside cells render only at the layout's outer edges — with several
// months, interior outside cells would duplicate the neighbouring grid's
// in-month buttons (double focus targets, duplicate accessible names).
const isLeading = compareDays(date, gridMonth) < 0;
const renderOutside =
ctx.showOutsideDays && (isLeading ? monthOffset === 0 : monthOffset === ctx.numberOfMonths - 1);
if (!renderOutside) return <div role="gridcell" className="size-9" />;
}
const key = String(dayKey(date));
const isDisabledDay = ctx.isDisabled(date);
const selected = ctx.isSelected(date);
const rangeStart = ctx.isRangeStart(date);
const rangeEnd = ctx.isRangeEnd(date);
const rangeMiddle = ctx.isRangeMiddle(date);
const filled =
(ctx.mode !== "range" && selected) ||
rangeStart ||
rangeEnd ||
(ctx.mode === "range" && selected && !rangeMiddle);
const isToday = isSameDay(date, ctx.today);
return (
<div role="gridcell" aria-selected={selected} className="size-9">
<button
type="button"
disabled={isDisabledDay}
tabIndex={ctx.tabbableDayKey === dayKey(date) ? 0 : -1}
data-cal-focus={key}
data-outside={outside || undefined}
aria-label={fullDateLabel(date, ctx.locale)}
aria-current={isToday ? "date" : undefined}
onClick={() => ctx.handleDayClick(date, outside)}
onFocus={() => ctx.setFocusKey(key)}
onKeyDown={(event) => ctx.handleDayKeyDown(event, date)}
className={cx(
DAY_CLASSES,
rangeMiddle && RANGE_MIDDLE_CLASSES,
!rangeMiddle && filled && SELECTED_DAY_CLASSES,
!rangeMiddle && !filled &&
(outside ? "text-[var(--ds-color-muted-foreground)]" : "text-[var(--ds-color-foreground)]"),
!rangeMiddle && !filled && "hover:bg-[var(--ds-color-surface-hover)]",
rangeStart && "rounded-r-none",
rangeEnd && "rounded-l-none",
isToday && "border-[var(--ds-color-border-strong)] font-semibold",
isDisabledDay && "opacity-40",
)}
>
{date.getDate()}
</button>
</div>
);
}
/* ------------------------------------------------------------------------ */
/* Month / year picker panels */
/* ------------------------------------------------------------------------ */
function MonthsPanel({ className }: { className?: string }): ReactElement {
const ctx = useCalendar();
const year = ctx.month.getFullYear();
const names = monthNames(ctx.locale);
const rows: number[][] = [];
for (let r = 0; r < 4; r += 1) rows.push([r * 3, r * 3 + 1, r * 3 + 2]);
return (
<div className={className}>
<div role="grid" aria-label={`Choose a month in ${yearLabel(year, ctx.locale)}`}>
{rows.map((row, r) => (
<div role="row" className="flex gap-1" key={r}>
{row.map((m) => {
const key = `m${m}`;
const enabled = ctx.monthEnabled(year, m);
const isCurrent = m === ctx.month.getMonth();
return (
<div role="gridcell" aria-selected={isCurrent} className="flex-1" key={m}>
<button
type="button"
disabled={!enabled}
tabIndex={ctx.monthActiveKey === key ? 0 : -1}
data-cal-focus={key}
aria-label={`${names[m]} ${yearLabel(year, ctx.locale)}`}
onClick={() => ctx.selectMonth(m)}
onFocus={() => ctx.setFocusKey(key)}
onKeyDown={(event) => ctx.handleMonthKeyDown(event, m)}
className={cx(
PICKER_BUTTON_CLASSES,
"w-full",
isCurrent
? PICKER_SELECTED_CLASSES
: "text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)]",
)}
>
{names[m]}
</button>
</div>
);
})}
</div>
))}
</div>
</div>
);
}
function YearsPanel({ className }: { className?: string }): ReactElement {
const ctx = useCalendar();
const rows: number[][] = [];
for (let r = 0; r < 4; r += 1) rows.push(ctx.yearPage.slice(r * 3, r * 3 + 3));
return (
<div className={className}>
<div role="grid" aria-label="Choose a year">
{rows.map((row, r) => (
<div role="row" className="flex gap-1" key={r}>
{row.map((year) => {
const key = `y${year}`;
const enabled = ctx.yearEnabled(year);
const isCurrent = year === ctx.month.getFullYear();
return (
<div role="gridcell" aria-selected={isCurrent} className="flex-1" key={year}>
<button
type="button"
disabled={!enabled}
tabIndex={ctx.yearActiveKey === key ? 0 : -1}
data-cal-focus={key}
onClick={() => ctx.selectYear(year)}
onFocus={() => ctx.setFocusKey(key)}
onKeyDown={(event) => ctx.handleYearKeyDown(event, year)}
className={cx(
PICKER_BUTTON_CLASSES,
"w-full tabular-nums",
isCurrent
? PICKER_SELECTED_CLASSES
: "text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)]",
)}
>
{yearLabel(year, ctx.locale)}
</button>
</div>
);
})}
</div>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------------ */
/* Footer */
/* ------------------------------------------------------------------------ */
export function CalendarFooter({ className, children, ...rest }: CalendarPartProps): ReactElement {
return (
<div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export default Calendar; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const WEEK_MS = 6048e5;
function daysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
}
function isLeapYear(year) {
return new Date(year, 1, 29).getMonth() === 1;
}
function dayKey(date) {
return date.getFullYear() * 1e4 + (date.getMonth() + 1) * 100 + date.getDate();
}
function dateFromKey(key) {
const year = Math.floor(key / 1e4);
const month = Math.floor(key % 1e4 / 100) - 1;
return new Date(year, month, key % 100);
}
function compareDays(a, b) {
const diff = dayKey(a) - dayKey(b);
return diff === 0 ? 0 : diff < 0 ? -1 : 1;
}
function isSameDay(a, b) {
return dayKey(a) === dayKey(b);
}
function addDays(date, amount) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);
}
function addMonths(date, amount) {
const first = new Date(date.getFullYear(), date.getMonth() + amount, 1);
const day = Math.min(date.getDate(), daysInMonth(first.getFullYear(), first.getMonth()));
return new Date(first.getFullYear(), first.getMonth(), day);
}
function startOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function endOfMonth(date) {
const year = date.getFullYear();
const month = date.getMonth();
return new Date(year, month, daysInMonth(year, month));
}
function isoWeekNumber(date) {
const noon = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 12);
const thursday = addDays(noon, 3 - (noon.getDay() + 6) % 7);
const jan4 = new Date(thursday.getFullYear(), 0, 4, 12);
const weekOneThursday = addDays(jan4, 3 - (jan4.getDay() + 6) % 7);
return 1 + Math.round((thursday.getTime() - weekOneThursday.getTime()) / WEEK_MS);
}
function buildMonthWeeks(month, weekStartsOn) {
const first = startOfMonth(month);
const leading = (first.getDay() - weekStartsOn + 7) % 7;
const gridStart = addDays(first, -leading);
const weeks = [];
for (let w = 0; w < 6; w += 1) {
const row = [];
for (let d = 0; d < 7; d += 1) row.push(addDays(gridStart, w * 7 + d));
weeks.push(row);
}
return weeks;
}
function monthYearLabel(date, locale) {
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
}
function monthName(date, locale) {
return new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
}
function yearLabel(year, locale) {
return new Intl.DateTimeFormat(locale, { year: "numeric" }).format(new Date(year, 0, 1));
}
function fullDateLabel(date, locale) {
return new Intl.DateTimeFormat(locale, {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric"
}).format(date);
}
function monthNames(locale) {
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
return Array.from({ length: 12 }, (_, m) => fmt.format(new Date(2024, m, 1)));
}
function weekdayNames(locale, weekStartsOn) {
const base = new Date(2024, 0, 7);
const shortFmt = new Intl.DateTimeFormat(locale, { weekday: "short" });
const longFmt = new Intl.DateTimeFormat(locale, { weekday: "long" });
return Array.from({ length: 7 }, (_, i) => {
const day = addDays(base, (weekStartsOn + i) % 7);
return { short: shortFmt.format(day), long: longFmt.format(day) };
});
}
function useControllableState(controlled, defaultValue, onChange) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = controlled !== undefined;
const value = isControlled ? controlled : internal;
const set = useCallback(
(next) => {
if (!isControlled) setInternal(next);
if (onChange) onChange(next);
},
[isControlled, onChange]
);
return [value, set];
}
const CalendarContext = createContext(null);
function useCalendar() {
const ctx = useContext(CalendarContext);
if (!ctx) throw new Error("Calendar components must be composed inside <Calendar>.");
return ctx;
}
const ROOT_CLASSES = "w-max max-w-full rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] p-3 shadow-[var(--ds-shadow-xs)]";
const HEADER_CLASSES = "mb-1 flex items-center justify-between gap-1";
const NAV_BUTTON_CLASSES = "inline-flex size-9 shrink-0 items-center justify-center rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-muted-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const HEADING_CLASSES = "m-0 text-sm font-semibold leading-9 text-[var(--ds-color-foreground)]";
const HEADING_BUTTON_CLASSES = "-mx-1.5 rounded-[var(--ds-radius-sm)] px-1.5 transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none";
const WEEKDAY_CLASSES = "flex size-9 items-center justify-center text-[11px] font-medium uppercase tracking-[0.04em] text-[var(--ds-color-muted-foreground)]";
const WEEKNUM_CLASSES = "flex size-9 items-center justify-center font-mono text-[11px] tabular-nums text-[var(--ds-color-muted-foreground)]";
const DAY_CLASSES = "inline-flex size-9 items-center justify-center rounded-[var(--ds-radius-sm)] border border-transparent text-sm leading-5 tabular-nums transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed motion-reduce:transition-none";
const PICKER_BUTTON_CLASSES = "flex h-9 flex-1 items-center justify-center rounded-[var(--ds-radius-sm)] border border-transparent px-2 text-sm leading-5 transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-40 motion-reduce:transition-none";
const FOOTER_CLASSES = "mt-2 flex items-center justify-between gap-2 border-t border-[var(--ds-color-border-subtle)] pt-2 text-xs leading-4 text-[var(--ds-color-muted-foreground)]";
const SELECTED_DAY_CLASSES = "bg-[var(--ds-color-primary)] font-medium text-[var(--ds-color-primary-foreground)] hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)]";
const RANGE_MIDDLE_CLASSES = "rounded-none bg-[var(--ds-color-surface-active)] text-[var(--ds-color-foreground)]";
const PICKER_SELECTED_CLASSES = "bg-[var(--ds-color-primary)] font-medium text-[var(--ds-color-primary-foreground)]";
function ChevronLeftIcon() {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false"><path d="m15 18-6-6 6-6" /></svg>;
}
function ChevronRightIcon() {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6" /></svg>;
}
function Calendar(props) {
const {
month: controlledMonth,
defaultMonth,
onMonthChange,
defaultView = "days",
minDate,
maxDate,
disabled,
locale = "en-US",
weekStartsOn = 0,
showOutsideDays = false,
showWeekNumbers = false,
numberOfMonths = 1,
className,
children,
// Selection props are consumed through the typed slices below — they are
// destructured here so they never leak onto the DOM via `...rest`
// (`onSelect` also collides with the native React event handler).
mode: _mode,
selected: _selected,
defaultSelected: _defaultSelected,
onSelect: _onSelect,
...rest
} = props;
const mode = _mode ?? "single";
const singleSlice = props;
const multipleSlice = props;
const rangeSlice = props;
const [singleSelection, setSingleSelection] = useControllableState(
mode === "single" ? singleSlice.selected : undefined,
(mode === "single" ? singleSlice.defaultSelected : null) ?? null,
mode === "single" ? singleSlice.onSelect : undefined
);
const [multipleSelection, setMultipleSelection] = useControllableState(
mode === "multiple" ? multipleSlice.selected : undefined,
(mode === "multiple" ? multipleSlice.defaultSelected : undefined) ?? [],
mode === "multiple" ? multipleSlice.onSelect : undefined
);
const [rangeSelection, setRangeSelection] = useControllableState(
mode === "range" ? rangeSlice.selected : undefined,
(mode === "range" ? rangeSlice.defaultSelected : undefined) ?? null,
mode === "range" ? rangeSlice.onSelect : undefined
);
const [today] = useState(() => /* @__PURE__ */ new Date());
const firstSelected = mode === "single" ? singleSelection : mode === "multiple" ? multipleSelection[0] ?? null : rangeSelection?.from ?? null;
const [monthState, setMonthState] = useControllableState(
controlledMonth !== undefined ? startOfMonth(controlledMonth) : undefined,
startOfMonth(defaultMonth ?? firstSelected ?? today),
onMonthChange
);
const monthKey = monthState.getFullYear() * 12 + monthState.getMonth();
const month = useMemo(() => startOfMonth(monthState), [monthKey]);
const [view, setView] = useState(defaultView);
const [focusKey, setFocusKey] = useState(null);
const focusIntentRef = useRef(false);
const rootRef = useRef(null);
const isDisabled = useCallback(
(date) => {
if (minDate && compareDays(date, minDate) < 0) return true;
if (maxDate && compareDays(date, maxDate) > 0) return true;
return disabled ? disabled(date) : false;
},
[minDate, maxDate, disabled]
);
const monthEnabled = useCallback(
(year, monthIndex) => {
if (minDate && compareDays(endOfMonth(new Date(year, monthIndex, 1)), minDate) < 0) return false;
if (maxDate && compareDays(new Date(year, monthIndex, 1), maxDate) > 0) return false;
return true;
},
[minDate, maxDate]
);
const yearEnabled = useCallback(
(year) => {
if (minDate && compareDays(new Date(year, 11, 31), minDate) < 0) return false;
if (maxDate && compareDays(new Date(year, 0, 1), maxDate) > 0) return false;
return true;
},
[minDate, maxDate]
);
const lastVisibleMonth = useMemo(
() => startOfMonth(addMonths(month, numberOfMonths - 1)),
[month, numberOfMonths]
);
const [renderedStart, renderedEnd] = useMemo(() => {
if (!showOutsideDays) return [month, endOfMonth(lastVisibleMonth)];
const firstWeeks = buildMonthWeeks(month, weekStartsOn);
const lastWeeks = buildMonthWeeks(lastVisibleMonth, weekStartsOn);
const start = firstWeeks[0]?.[0] ?? month;
const end = lastWeeks[lastWeeks.length - 1]?.[6] ?? endOfMonth(lastVisibleMonth);
return [start, end];
}, [month, lastVisibleMonth, weekStartsOn, showOutsideDays]);
const isRenderedDate = useCallback(
(date) => compareDays(date, renderedStart) >= 0 && compareDays(date, renderedEnd) <= 0,
[renderedStart, renderedEnd]
);
const goToMonth = useCallback(
(target) => {
let t = startOfMonth(target);
if (maxDate) {
const latest = startOfMonth(addMonths(maxDate, -(numberOfMonths - 1)));
if (compareDays(t, latest) > 0) t = latest;
}
if (minDate && compareDays(t, startOfMonth(minDate)) < 0) t = startOfMonth(minDate);
setMonthState(t);
},
[minDate, maxDate, numberOfMonths, setMonthState]
);
const monthToReveal = useCallback(
(date) => {
if (compareDays(date, month) < 0) return startOfMonth(date);
if (compareDays(date, endOfMonth(lastVisibleMonth)) > 0) {
return startOfMonth(addMonths(date, -(numberOfMonths - 1)));
}
return month;
},
[month, lastVisibleMonth, numberOfMonths]
);
const navStep = view === "days" ? 1 : view === "months" ? 12 : 144;
let canGoPrevious = true;
let canGoNext = true;
if (view === "days") {
canGoPrevious = !minDate || compareDays(endOfMonth(addMonths(month, -1)), minDate) >= 0;
canGoNext = !maxDate || compareDays(startOfMonth(addMonths(month, numberOfMonths)), maxDate) <= 0;
} else if (view === "months") {
const y = month.getFullYear();
canGoPrevious = !minDate || compareDays(new Date(y - 1, 11, 31), minDate) >= 0;
canGoNext = !maxDate || compareDays(new Date(y + 1, 0, 1), maxDate) <= 0;
} else {
const pageStart = Math.floor(month.getFullYear() / 12) * 12;
canGoPrevious = !minDate || compareDays(new Date(pageStart - 1, 11, 31), minDate) >= 0;
canGoNext = !maxDate || compareDays(new Date(pageStart + 12, 0, 1), maxDate) <= 0;
}
const goToPrevious = useCallback(() => {
if (canGoPrevious) goToMonth(addMonths(month, -navStep));
}, [canGoPrevious, goToMonth, month, navStep]);
const goToNext = useCallback(() => {
if (canGoNext) goToMonth(addMonths(month, navStep));
}, [canGoNext, goToMonth, month, navStep]);
const previousLabel = view === "days" ? "Go to previous month" : view === "months" ? "Go to previous year" : "Go to previous 12 years";
const nextLabel = view === "days" ? "Go to next month" : view === "months" ? "Go to next year" : "Go to next 12 years";
const headingLabel = useMemo(() => {
if (view === "months") return yearLabel(month.getFullYear(), locale);
if (view === "years") {
const pageStart = Math.floor(month.getFullYear() / 12) * 12;
return `${yearLabel(pageStart, locale)} \u2013 ${yearLabel(pageStart + 11, locale)}`;
}
if (numberOfMonths === 1) return monthYearLabel(month, locale);
const last = addMonths(month, numberOfMonths - 1);
if (month.getFullYear() === last.getFullYear()) {
return `${monthName(month, locale)} \u2013 ${monthYearLabel(last, locale)}`;
}
return `${monthYearLabel(month, locale)} \u2013 ${monthYearLabel(last, locale)}`;
}, [view, month, numberOfMonths, locale]);
const yearPage = useMemo(() => {
const pageStart = Math.floor(month.getFullYear() / 12) * 12;
return Array.from({ length: 12 }, (_, i) => pageStart + i);
}, [month]);
const rangeCrossesDisabled = useCallback(
(from, to) => {
let cursor = addDays(from, 1);
for (let i = 0; i < 3700 && compareDays(cursor, to) < 0; i += 1) {
if (isDisabled(cursor)) return true;
cursor = addDays(cursor, 1);
}
return false;
},
[isDisabled]
);
const selectDate = useCallback(
(date) => {
if (isDisabled(date)) return;
if (mode === "single") {
if (singleSelection && isSameDay(singleSelection, date)) return;
setSingleSelection(date);
return;
}
if (mode === "multiple") {
const exists = multipleSelection.some((d) => isSameDay(d, date));
setMultipleSelection(
exists ? multipleSelection.filter((d) => !isSameDay(d, date)) : [...multipleSelection, date]
);
return;
}
if (!rangeSelection || rangeSelection.to) {
setRangeSelection({ from: date, to: null });
return;
}
const from = rangeSelection.from;
if (isSameDay(date, from)) {
setRangeSelection({ from, to: from });
} else if (compareDays(date, from) < 0 || rangeCrossesDisabled(from, date)) {
setRangeSelection({ from: date, to: null });
} else {
setRangeSelection({ from, to: date });
}
},
[
isDisabled,
mode,
singleSelection,
multipleSelection,
rangeSelection,
setSingleSelection,
setMultipleSelection,
setRangeSelection,
rangeCrossesDisabled
]
);
const clearSelection = useCallback(() => {
if (mode === "single") setSingleSelection(null);
else if (mode === "multiple") setMultipleSelection([]);
else setRangeSelection(null);
}, [mode, setSingleSelection, setMultipleSelection, setRangeSelection]);
const isRangeStart = useCallback(
(date) => mode === "range" && rangeSelection !== null && rangeSelection.to !== null && !isSameDay(rangeSelection.from, rangeSelection.to) && isSameDay(rangeSelection.from, date),
[mode, rangeSelection]
);
const isRangeEnd = useCallback(
(date) => mode === "range" && rangeSelection !== null && rangeSelection.to !== null && !isSameDay(rangeSelection.from, rangeSelection.to) && isSameDay(rangeSelection.to, date),
[mode, rangeSelection]
);
const isRangeMiddle = useCallback(
(date) => mode === "range" && rangeSelection !== null && rangeSelection.to !== null && compareDays(date, rangeSelection.from) > 0 && compareDays(date, rangeSelection.to) < 0,
[mode, rangeSelection]
);
const isSelected = useCallback(
(date) => {
if (mode === "single") return singleSelection !== null && isSameDay(singleSelection, date);
if (mode === "multiple") return multipleSelection.some((d) => isSameDay(d, date));
return rangeSelection !== null && (isSameDay(rangeSelection.from, date) || rangeSelection.to !== null && compareDays(date, rangeSelection.from) > 0 && compareDays(date, rangeSelection.to) <= 0);
},
[mode, singleSelection, multipleSelection, rangeSelection]
);
const focusedDay = useMemo(() => {
if (focusKey && /^\d{8}$/.test(focusKey)) return dateFromKey(Number(focusKey));
return null;
}, [focusKey]);
const tabbableDayKey = useMemo(() => {
const candidates = [focusedDay, firstSelected, today];
for (const candidate of candidates) {
if (candidate && isRenderedDate(candidate) && !isDisabled(candidate)) return dayKey(candidate);
}
for (let i = 0; i < numberOfMonths; i += 1) {
const gridMonth = startOfMonth(addMonths(month, i));
const count = daysInMonth(gridMonth.getFullYear(), gridMonth.getMonth());
for (let d = 0; d < count; d += 1) {
const date = addDays(gridMonth, d);
if (!isDisabled(date)) return dayKey(date);
}
}
return null;
}, [focusedDay, firstSelected, today, isRenderedDate, isDisabled, numberOfMonths, month]);
const monthActiveKey = useMemo(() => {
const y = month.getFullYear();
const fromFocus = focusKey ? /^m(\d{1,2})$/.exec(focusKey) : null;
if (fromFocus) {
const m = Number(fromFocus[1]);
if (m < 12 && monthEnabled(y, m)) return `m${m}`;
}
if (monthEnabled(y, month.getMonth())) return `m${month.getMonth()}`;
for (let m = 0; m < 12; m += 1) if (monthEnabled(y, m)) return `m${m}`;
return null;
}, [focusKey, month, monthEnabled]);
const yearActiveKey = useMemo(() => {
const fromFocus = focusKey ? /^y(\d+)$/.exec(focusKey) : null;
if (fromFocus) {
const y = Number(fromFocus[1]);
if (yearPage.includes(y) && yearEnabled(y)) return `y${y}`;
}
const current = month.getFullYear();
if (yearPage.includes(current) && yearEnabled(current)) return `y${current}`;
for (const y of yearPage) if (yearEnabled(y)) return `y${y}`;
return null;
}, [focusKey, month, yearPage, yearEnabled]);
const requestFocus = useCallback((key) => {
focusIntentRef.current = true;
setFocusKey(key);
}, []);
useEffect(() => {
if (focusKey == null) return;
const root = rootRef.current;
if (!root) return;
const intentional = focusIntentRef.current;
focusIntentRef.current = false;
if (!intentional && !root.contains(document.activeElement)) return;
const el = root.querySelector(`[data-cal-focus="${focusKey}"]`);
if (el && el !== document.activeElement) el.focus();
}, [focusKey, monthKey, view]);
const stepToEnabled = useCallback(
(start, step) => {
let cursor = start;
for (let i = 0; i < 732; i += 1) {
if (!isDisabled(cursor)) return cursor;
cursor = step(cursor);
}
return null;
},
[isDisabled]
);
const handleDayKeyDown = useCallback(
(event, date) => {
let target;
let step;
switch (event.key) {
case "ArrowLeft":
target = addDays(date, -1);
step = (d) => addDays(d, -1);
break;
case "ArrowRight":
target = addDays(date, 1);
step = (d) => addDays(d, 1);
break;
case "ArrowUp":
target = addDays(date, -7);
step = (d) => addDays(d, -7);
break;
case "ArrowDown":
target = addDays(date, 7);
step = (d) => addDays(d, 7);
break;
case "Home":
target = addDays(date, -((date.getDay() - weekStartsOn + 7) % 7));
step = (d) => addDays(d, 1);
break;
case "End":
target = addDays(date, 6 - (date.getDay() - weekStartsOn + 7) % 7);
step = (d) => addDays(d, -1);
break;
case "PageUp": {
const amount = event.shiftKey ? -12 : -1;
target = addMonths(date, amount);
step = (d) => addMonths(d, amount);
break;
}
case "PageDown": {
const amount = event.shiftKey ? 12 : 1;
target = addMonths(date, amount);
step = (d) => addMonths(d, amount);
break;
}
default:
return;
}
event.preventDefault();
const next = stepToEnabled(target, step);
if (!next) return;
if (!isRenderedDate(next)) goToMonth(monthToReveal(next));
requestFocus(String(dayKey(next)));
},
[weekStartsOn, stepToEnabled, isRenderedDate, goToMonth, monthToReveal, requestFocus]
);
const handleDayClick = useCallback(
(date, outside) => {
if (outside) {
goToMonth(monthToReveal(date));
requestFocus(String(dayKey(date)));
}
selectDate(date);
},
[goToMonth, monthToReveal, requestFocus, selectDate]
);
const handleMonthKeyDown = useCallback(
(event, monthIndex) => {
const year = month.getFullYear();
const norm = (y, m) => ({
y: y + Math.floor(m / 12),
m: (m % 12 + 12) % 12
});
let target;
let step;
switch (event.key) {
case "ArrowLeft":
target = norm(year, monthIndex - 1);
step = (c) => norm(c.y, c.m - 1);
break;
case "ArrowRight":
target = norm(year, monthIndex + 1);
step = (c) => norm(c.y, c.m + 1);
break;
case "ArrowUp":
target = norm(year, monthIndex - 3);
step = (c) => norm(c.y, c.m - 3);
break;
case "ArrowDown":
target = norm(year, monthIndex + 3);
step = (c) => norm(c.y, c.m + 3);
break;
case "Home":
target = { y: year, m: 0 };
step = (c) => norm(c.y, c.m + 1);
break;
case "End":
target = { y: year, m: 11 };
step = (c) => norm(c.y, c.m - 1);
break;
case "PageUp":
target = { y: year - 1, m: monthIndex };
step = (c) => ({ y: c.y - 1, m: c.m });
break;
case "PageDown":
target = { y: year + 1, m: monthIndex };
step = (c) => ({ y: c.y + 1, m: c.m });
break;
default:
return;
}
event.preventDefault();
let cursor = target;
for (let i = 0; i < 240 && !monthEnabled(cursor.y, cursor.m); i += 1) cursor = step(cursor);
if (!monthEnabled(cursor.y, cursor.m)) return;
if (cursor.y !== year) goToMonth(new Date(cursor.y, month.getMonth(), 1));
requestFocus(`m${cursor.m}`);
},
[month, monthEnabled, goToMonth, requestFocus]
);
const handleYearKeyDown = useCallback(
(event, year) => {
let target;
let step;
switch (event.key) {
case "ArrowLeft":
target = year - 1;
step = (y) => y - 1;
break;
case "ArrowRight":
target = year + 1;
step = (y) => y + 1;
break;
case "ArrowUp":
target = year - 3;
step = (y) => y - 3;
break;
case "ArrowDown":
target = year + 3;
step = (y) => y + 3;
break;
case "Home":
target = yearPage[0] ?? year;
step = (y) => y + 1;
break;
case "End":
target = yearPage[yearPage.length - 1] ?? year;
step = (y) => y - 1;
break;
case "PageUp":
target = year - 12;
step = (y) => y - 12;
break;
case "PageDown":
target = year + 12;
step = (y) => y + 12;
break;
default:
return;
}
event.preventDefault();
let cursor = target;
for (let i = 0; i < 240 && !yearEnabled(cursor); i += 1) cursor = step(cursor);
if (!yearEnabled(cursor)) return;
if (!yearPage.includes(cursor)) goToMonth(new Date(cursor, month.getMonth(), 1));
requestFocus(`y${cursor}`);
},
[month, yearPage, yearEnabled, goToMonth, requestFocus]
);
const selectMonth = useCallback(
(monthIndex) => {
const year = month.getFullYear();
const anchor = focusedDay ?? firstSelected ?? today;
const day = Math.min(anchor.getDate(), daysInMonth(year, monthIndex));
let target = new Date(year, monthIndex, day);
if (isDisabled(target)) {
target = stepToEnabled(target, (d) => addDays(d, 1)) ?? stepToEnabled(target, (d) => addDays(d, -1)) ?? new Date(year, monthIndex, 1);
}
goToMonth(new Date(year, monthIndex, 1));
setView("days");
requestFocus(String(dayKey(target)));
},
[month, focusedDay, firstSelected, today, isDisabled, stepToEnabled, goToMonth, requestFocus]
);
const selectYear = useCallback(
(year) => {
goToMonth(new Date(year, month.getMonth(), 1));
setView("months");
requestFocus(`m${month.getMonth()}`);
},
[month, goToMonth, requestFocus]
);
const contextValue = {
mode,
locale,
weekStartsOn,
showOutsideDays,
showWeekNumbers,
numberOfMonths,
view,
setView,
month,
today,
headingLabel,
goToMonth,
goToPrevious,
goToNext,
canGoPrevious,
canGoNext,
previousLabel,
nextLabel,
isDisabled,
isSelected,
isRangeStart,
isRangeEnd,
isRangeMiddle,
selectDate,
clearSelection,
handleDayClick,
handleDayKeyDown,
tabbableDayKey,
monthEnabled,
yearEnabled,
monthActiveKey,
yearActiveKey,
yearPage,
selectMonth,
selectYear,
handleMonthKeyDown,
handleYearKeyDown,
focusKey,
setFocusKey
};
return <CalendarContext.Provider value={contextValue}><div ref={rootRef} className={cx(ROOT_CLASSES, className)} {...rest}>{children}</div></CalendarContext.Provider>;
}
function CalendarHeader({ className, children, ...rest }) {
return <div className={cx(HEADER_CLASSES, className)} {...rest}>{children}</div>;
}
function CalendarPrevious({ className, ...rest }) {
const ctx = useCalendar();
return <button
type="button"
aria-label={ctx.previousLabel}
disabled={!ctx.canGoPrevious}
onClick={ctx.goToPrevious}
className={cx(NAV_BUTTON_CLASSES, className)}
{...rest}
><ChevronLeftIcon /></button>;
}
function CalendarNext({ className, ...rest }) {
const ctx = useCalendar();
return <button
type="button"
aria-label={ctx.nextLabel}
disabled={!ctx.canGoNext}
onClick={ctx.goToNext}
className={cx(NAV_BUTTON_CLASSES, className)}
{...rest}
><ChevronRightIcon /></button>;
}
function CalendarHeading({ className, ...rest }) {
const ctx = useCalendar();
if (ctx.view === "years") {
return <h2 aria-live="polite" aria-atomic="true" className={cx(HEADING_CLASSES, className)} {...rest}>{ctx.headingLabel}</h2>;
}
return <h2 aria-live="polite" aria-atomic="true" className={cx(HEADING_CLASSES, className)} {...rest}><button
type="button"
onClick={() => ctx.setView(ctx.view === "days" ? "months" : "years")}
aria-label={ctx.view === "days" ? `${ctx.headingLabel} \u2014 activate to choose a month` : `${ctx.headingLabel} \u2014 activate to choose a year`}
className={HEADING_BUTTON_CLASSES}
>{ctx.headingLabel}</button></h2>;
}
function CalendarGrid({ monthOffset = 0, className, ...rest }) {
const ctx = useCalendar();
if (ctx.view === "months") return monthOffset === 0 ? <MonthsPanel className={className} /> : null;
if (ctx.view === "years") return monthOffset === 0 ? <YearsPanel className={className} /> : null;
const gridMonth = startOfMonth(addMonths(ctx.month, monthOffset));
const weeks = buildMonthWeeks(gridMonth, ctx.weekStartsOn);
const weekdays = weekdayNames(ctx.locale, ctx.weekStartsOn);
const weekNumberOf = (row) => isoWeekNumber(addDays(row[0] ?? gridMonth, (4 - ctx.weekStartsOn + 7) % 7));
return <div className={className} {...rest}><div role="grid" aria-label={monthYearLabel(gridMonth, ctx.locale)}><div role="row" className="flex">{ctx.showWeekNumbers && <div role="columnheader" aria-label="Week number" className={WEEKNUM_CLASSES}>
Wk
</div>}{weekdays.map((day, i) => <div role="columnheader" key={i} aria-label={day.long} className={WEEKDAY_CLASSES}>{day.short}</div>)}</div>{weeks.map((week, rowIndex) => <div role="row" className="flex" key={rowIndex}>{ctx.showWeekNumbers && <div role="rowheader" className={WEEKNUM_CLASSES}>{weekNumberOf(week)}</div>}{week.map((date) => <DayCell key={dayKey(date)} date={date} gridMonth={gridMonth} monthOffset={monthOffset} />)}</div>)}</div></div>;
}
function DayCell({
date,
gridMonth,
monthOffset
}) {
const ctx = useCalendar();
const outside = date.getMonth() !== gridMonth.getMonth();
if (outside) {
const isLeading = compareDays(date, gridMonth) < 0;
const renderOutside = ctx.showOutsideDays && (isLeading ? monthOffset === 0 : monthOffset === ctx.numberOfMonths - 1);
if (!renderOutside) return <div role="gridcell" className="size-9" />;
}
const key = String(dayKey(date));
const isDisabledDay = ctx.isDisabled(date);
const selected = ctx.isSelected(date);
const rangeStart = ctx.isRangeStart(date);
const rangeEnd = ctx.isRangeEnd(date);
const rangeMiddle = ctx.isRangeMiddle(date);
const filled = ctx.mode !== "range" && selected || rangeStart || rangeEnd || ctx.mode === "range" && selected && !rangeMiddle;
const isToday = isSameDay(date, ctx.today);
return <div role="gridcell" aria-selected={selected} className="size-9"><button
type="button"
disabled={isDisabledDay}
tabIndex={ctx.tabbableDayKey === dayKey(date) ? 0 : -1}
data-cal-focus={key}
data-outside={outside || undefined}
aria-label={fullDateLabel(date, ctx.locale)}
aria-current={isToday ? "date" : undefined}
onClick={() => ctx.handleDayClick(date, outside)}
onFocus={() => ctx.setFocusKey(key)}
onKeyDown={(event) => ctx.handleDayKeyDown(event, date)}
className={cx(
DAY_CLASSES,
rangeMiddle && RANGE_MIDDLE_CLASSES,
!rangeMiddle && filled && SELECTED_DAY_CLASSES,
!rangeMiddle && !filled && (outside ? "text-[var(--ds-color-muted-foreground)]" : "text-[var(--ds-color-foreground)]"),
!rangeMiddle && !filled && "hover:bg-[var(--ds-color-surface-hover)]",
rangeStart && "rounded-r-none",
rangeEnd && "rounded-l-none",
isToday && "border-[var(--ds-color-border-strong)] font-semibold",
isDisabledDay && "opacity-40"
)}
>{date.getDate()}</button></div>;
}
function MonthsPanel({ className }) {
const ctx = useCalendar();
const year = ctx.month.getFullYear();
const names = monthNames(ctx.locale);
const rows = [];
for (let r = 0; r < 4; r += 1) rows.push([r * 3, r * 3 + 1, r * 3 + 2]);
return <div className={className}><div role="grid" aria-label={`Choose a month in ${yearLabel(year, ctx.locale)}`}>{rows.map((row, r) => <div role="row" className="flex gap-1" key={r}>{row.map((m) => {
const key = `m${m}`;
const enabled = ctx.monthEnabled(year, m);
const isCurrent = m === ctx.month.getMonth();
return <div role="gridcell" aria-selected={isCurrent} className="flex-1" key={m}><button
type="button"
disabled={!enabled}
tabIndex={ctx.monthActiveKey === key ? 0 : -1}
data-cal-focus={key}
aria-label={`${names[m]} ${yearLabel(year, ctx.locale)}`}
onClick={() => ctx.selectMonth(m)}
onFocus={() => ctx.setFocusKey(key)}
onKeyDown={(event) => ctx.handleMonthKeyDown(event, m)}
className={cx(
PICKER_BUTTON_CLASSES,
"w-full",
isCurrent ? PICKER_SELECTED_CLASSES : "text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)]"
)}
>{names[m]}</button></div>;
})}</div>)}</div></div>;
}
function YearsPanel({ className }) {
const ctx = useCalendar();
const rows = [];
for (let r = 0; r < 4; r += 1) rows.push(ctx.yearPage.slice(r * 3, r * 3 + 3));
return <div className={className}><div role="grid" aria-label="Choose a year">{rows.map((row, r) => <div role="row" className="flex gap-1" key={r}>{row.map((year) => {
const key = `y${year}`;
const enabled = ctx.yearEnabled(year);
const isCurrent = year === ctx.month.getFullYear();
return <div role="gridcell" aria-selected={isCurrent} className="flex-1" key={year}><button
type="button"
disabled={!enabled}
tabIndex={ctx.yearActiveKey === key ? 0 : -1}
data-cal-focus={key}
onClick={() => ctx.selectYear(year)}
onFocus={() => ctx.setFocusKey(key)}
onKeyDown={(event) => ctx.handleYearKeyDown(event, year)}
className={cx(
PICKER_BUTTON_CLASSES,
"w-full tabular-nums",
isCurrent ? PICKER_SELECTED_CLASSES : "text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)]"
)}
>{yearLabel(year, ctx.locale)}</button></div>;
})}</div>)}</div></div>;
}
function CalendarFooter({ className, children, ...rest }) {
return <div className={cx(FOOTER_CLASSES, className)} {...rest}>{children}</div>;
}
export { daysInMonth, isLeapYear, compareDays, isSameDay, addDays, addMonths, startOfMonth, endOfMonth, isoWeekNumber, buildMonthWeeks, useCalendar, Calendar, CalendarHeader, CalendarPrevious, CalendarNext, CalendarHeading, CalendarGrid, CalendarFooter };
export default Calendar; # Calendar — Controlled State
A fully parent-controlled calendar: the visible month, the selected date, and even the locale live in parent state, with external month/year/locale selectors and an event log of every callback.
## Installation
Copy `code.tsx` into your project (or `code.jsx` for plain-JavaScript builds — same API, types stripped). The only runtime dependency is React 18+; there is **no date library** — the component ships its own small, typed date utilities (`addDays`, `addMonths`, `compareDays`, `isSameDay`, `daysInMonth`, `isLeapYear`, `isoWeekNumber`, `buildMonthWeeks`, `startOfMonth`, `endOfMonth`), which are also exported for reuse.
The component consumes DevSnips `--ds-*` design tokens through Tailwind arbitrary values (for example `bg-[var(--ds-color-surface)]`). Define the tokens once in your theme per [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) — no component-specific CSS file is required.
## Usage
```tsx
const [month, setMonth] = useState(new Date(2026, 7, 1));
const [selected, setSelected] = useState<Date | null>(null);
const [locale, setLocale] = useState("en-US");
<Calendar
month={month}
onMonthChange={setMonth}
selected={selected}
onSelect={setSelected}
locale={locale}
>
<CalendarHeader>
<CalendarPrevious />
<CalendarHeading className="flex-1 text-center" />
<CalendarNext />
</CalendarHeader>
<CalendarGrid />
</Calendar>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
const [month, setMonth] = useState(new Date(2026, 7, 1));
const [selected, setSelected] = useState<Date | null>(null);
const [locale, setLocale] = useState("en-US");
<Calendar
month={month}
onMonthChange={setMonth}
selected={selected}
onSelect={setSelected}
locale={locale}
>
<CalendarHeader>
<CalendarPrevious />
<CalendarHeading className="flex-1 text-center" />
<CalendarNext />
</CalendarHeader>
<CalendarGrid />
</Calendar>
```
## Props
### `<Calendar>`
| Name | Type | Default | Description |
|---|---|---|---|
| `mode` | `"single" \| "multiple" \| "range"` | `"single"` | Selection mode. Determines the shape of `selected` / `defaultSelected` / `onSelect` (discriminated union). |
| `selected` | `Date \| null` · `Date[]` · `DateRange \| null` | — | Selection (controlled). Shape follows `mode`. |
| `defaultSelected` | same as `selected` | `null` / `[]` | Initial selection (uncontrolled). |
| `onSelect` | `(date) => void` · `(dates) => void` · `(range) => void` | — | Called on every selection change, including in-progress range steps (`{ from, to: null }`). |
| `month` | `Date` | — | Visible month (controlled); any day inside it, normalized to the 1st. |
| `defaultMonth` | `Date` | selection month, else today | Initial visible month (uncontrolled). |
| `onMonthChange` | `(month: Date) => void` | — | Called whenever the visible month changes (navigation, keyboard paging, outside-day click, picker selection). |
| `defaultView` | `"days" \| "months" \| "years"` | `"days"` | Initial picker view. The heading button cycles days → months → years regardless. |
| `minDate` | `Date` | — | Earliest selectable calendar day (inclusive). |
| `maxDate` | `Date` | — | Latest selectable calendar day (inclusive). |
| `disabled` | `(date: Date) => boolean` | — | Matcher for individual disabled dates; composes with `minDate` / `maxDate`. |
| `locale` | `string` | `"en-US"` | BCP-47 tag for month, weekday, and day accessible names (via `Intl.DateTimeFormat`). |
| `weekStartsOn` | `0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6` | `0` | First weekday column (0 = Sunday, 1 = Monday, …). Headers and rows stay consistent. |
| `showOutsideDays` | `boolean` | `false` | Render adjacent-month days at the layout's outer edges. |
| `showWeekNumbers` | `boolean` | `false` | Render the non-interactive ISO-8601 week-number column. |
| `numberOfMonths` | `number` | `1` | Consecutive months shown; render one `<CalendarGrid monthOffset={i}>` per month. |
| `className` | `string` | — | Extra classes on the panel. |
| `children` | `ReactNode` | — | The composed parts (header, grid(s), footer). |
### Parts
| Part | Props | Renders |
|---|---|---|
| `CalendarHeader` | `className`, native div attrs | The navigation row. |
| `CalendarPrevious` / `CalendarNext` | `className`, native button attrs | View-aware nav buttons (month / year / 12-year page). Auto-disabled at `minDate` / `maxDate`. |
| `CalendarHeading` | `className`, native heading attrs | `aria-live` month/year label; a button that opens the month / year picker. |
| `CalendarGrid` | `monthOffset` (0-based), `className`, native div attrs | One `role="grid"` month matrix — or the picker panel in the months/years views (`monthOffset={0}` only). |
| `CalendarFooter` | `className`, native div attrs | A hairline-separated summary / action region. |
`useCalendar()` returns the root context for composed children (footer actions, custom cells) — selection helpers (`selectDate`, `clearSelection`), navigation (`goToMonth`, `goToPrevious`, `goToNext`), and state (`month`, `view`, `isDisabled`, …).
### Date utilities
`daysInMonth(year, month)`, `isLeapYear(year)`, `addDays(date, n)`, `addMonths(date, n)` (day clamped to the target month), `startOfMonth(date)`, `endOfMonth(date)`, `compareDays(a, b)`, `isSameDay(a, b)`, `isoWeekNumber(date)`, `buildMonthWeeks(month, weekStartsOn)` — small, typed, local-calendar-date helpers used internally and exported for reuse (they are the foundation of the DatePicker family).
## Composition
- `Calendar` — the root provider. Owns the selection state (per `mode`), the visible month (controlled `month` + `onMonthChange`, or uncontrolled `defaultMonth`), the picker view (`days` / `months` / `years`, seeded by `defaultView`), the roving focus key, and the constraints (`minDate`, `maxDate`, `disabled`). Renders the bordered panel.
- `CalendarHeader` — the navigation row. Compose `CalendarPrevious` + `CalendarHeading` + `CalendarNext` inside it.
- `CalendarPrevious` / `CalendarNext` — real buttons with view-aware accessible names ("Go to previous month" / "year" / "12 years"). They disable themselves when `minDate` / `maxDate` make that direction impossible.
- `CalendarHeading` — an `aria-live="polite"` `<h2>` announcing the visible month/year. In the days and months views the label is a button that switches to the month / year picker.
- `CalendarGrid` — one month matrix (`role="grid"`). Render `<CalendarGrid monthOffset={i} />` once per visible month when using `numberOfMonths`. In the `months` / `years` views the first grid renders the picker panel and the others render nothing.
- `CalendarFooter` — an optional summary / action region separated by a hairline.
`useCalendar()` exposes the context to composed children (for example a footer "Today" action that calls `goToMonth` + `selectDate`).
## Selection Modes
The selection API is a discriminated union on `mode` — TypeScript rejects a `selected` shape that does not match the mode.
| Mode | `selected` | `onSelect` | Behavior |
|---|---|---|---|
| `single` (default) | `Date \| null` | `(date: Date \| null) => void` | One date. Clicking the selected date is a no-op — there is no accidental deselection. |
| `multiple` | `Date[]` | `(dates: Date[]) => void` | Click toggles a date in/out of the set. Updates are immutable — caller arrays are never mutated. |
| `range` | `DateRange \| null` (`{ from: Date; to: Date \| null }`) | `(range: DateRange \| null) => void` | First click starts (`to: null`); second click completes. See the range rules below. |
Range rules (predictable by design):
1. Clicking any date with a complete range (or none) starts a new range at that date.
2. Clicking the pending `from` date completes a **same-day range** (`from === to`).
3. Clicking a date **earlier** than `from` restarts the range at that date (no silent swap).
4. A completion that would **cross a disabled date** is rejected — the click restarts the range at the clicked date instead of producing a range that spans an unselectable day.
Every mode works controlled (`selected` + `onSelect`) or uncontrolled (`defaultSelected`). A component never mixes the two: pass `selected` to control it, `defaultSelected` to seed it.
With `month` + `selected` both controlled, the calendar is a pure function of props: every internal navigation or selection request flows out through `onMonthChange` / `onSelect`, and the parent decides the new state. Uncontrolled pieces (the picker view, the roving focus) stay internal.
## Date Model and Timezones
Calendar dates are **local calendar dates**, never UTC timestamps.
- All arithmetic uses `new Date(year, month, day)` constructor normalization (`addDays`, `addMonths`) — never timestamp math (`getTime() + 86400000`), which shifts a day across DST transitions, and never string comparison.
- Day identity and ordering use a numeric key (`year * 10000 + (month+1) * 100 + day`), which is strictly monotonic with calendar order.
- `toISOString()` is never used (it converts to UTC and would shift the calendar day for most timezones).
- A selected date's *calendar* meaning is preserved: March 10 stays March 10 in the user's local calendar. The returned `Date` objects are local dates; compare them with `isSameDay` / `compareDays`, not `getTime()`.
- `minDate` / `maxDate` / `disabled(date)` are compared by calendar day — the time-of-day on the `Date` objects you pass is ignored.
- Week numbers follow ISO 8601 (weeks start Monday; week 1 contains the year's first Thursday), computed on local-noon copies so the Thursday shift is immune to midnight DST transitions.
## Date Constraints
`minDate` and `maxDate` are inclusive calendar-day boundaries. They disable:
- day cells outside the range (native `disabled` — not focusable, not activatable, announced as unavailable),
- the previous/next navigation buttons when the target month / year / 12-year page would hold no selectable day,
- month and year picker options that fall entirely outside the range,
- keyboard navigation — arrow/PageUp/PageDown movement skips disabled dates and never lands on one,
- range completion across the boundary (the range restarts instead of crossing).
Selection can never bypass constraints: every selection path (click, Enter/Space, footer actions) goes through the same `isDisabled` guard. The `disabled` matcher composes with `minDate` / `maxDate` (a date is disabled if any rule rejects it).
## Keyboard Interaction
| Key | Day grid | Month picker | Year picker |
|---|---|---|---|
| `ArrowLeft` / `ArrowRight` | Previous / next day | Previous / next month | Previous / next year |
| `ArrowUp` / `ArrowDown` | Same weekday, previous / next week | Same month ± 1 year-quarter row (± 3) | ± 3 years |
| `Home` / `End` | First / last day of the current week row (respects `weekStartsOn`) | January / December | First / last year of the page |
| `PageUp` / `PageDown` | Previous / next month (day clamped, e.g. Jan 31 → Feb 28) | Previous / next year | Previous / next 12-year page |
| `Shift+PageUp` / `Shift+PageDown` | Previous / next year | — | — |
| `Enter` / `Space` | Select the focused date (native button activation) | Choose the month | Choose the year |
| `Tab` / `Shift+Tab` | Moves into / out of the calendar — exactly one tabbable cell (roving tabindex) | same | same |
Focus model: roving `tabIndex` — only one cell in the calendar is tabbable at a time (the focused date, else the selected date, else today, else the first enabled day). Arrow movement skips disabled dates automatically, and moving past a month edge pages the visible month so focus is never lost. There are no keyboard traps: Tab always leaves the calendar. The previous/next buttons and the heading button are ordinary tab stops with visible focus rings.
## Accessibility
The day matrix follows the WAI-ARIA date-picker grid pattern:
- `role="grid"` per month (labelled with the month + year), `role="row"` per week, `role="columnheader"` for weekday labels (short text visible, full name in `aria-label`), `role="rowheader"` for ISO week numbers, `role="gridcell"` per day carrying `aria-selected`.
- Every day is a real `<button>` with a full locale-aware accessible name ("Friday, August 22, 2026") — never a bare number, never a clickable div.
- Today carries `aria-current="date"` — it is distinguishable from the *selected* date (which uses `aria-selected` + a filled treatment), and today is never auto-selected.
- Disabled dates use native `disabled` (exposed as unavailable, skipped by keyboard) and stay visible with reduced opacity — never hidden, never color-only.
- The month/year heading is `aria-live="polite"`, so navigation is announced.
- The previous/next buttons have view-aware accessible names; the heading button's `aria-label` explains that it opens the month/year picker.
- Selected vs. hover vs. today vs. disabled are distinguished by more than color alone (fill + weight, border + weight, opacity).
The month, year, and locale selectors are real labelled `<select>` elements outside the calendar — the calendar itself keeps its full grid semantics. Switching locale re-renders every month name, weekday header, and day accessible name through `Intl.DateTimeFormat`; the `aria-live` heading announces the new label.
## States
- **Default** — foreground text on the surface; hover shifts to `surface-hover`.
- **Selected** — solid `primary` fill with `primary-foreground` text and medium weight (fill + weight, not color alone). Hover darkens via `color-mix`.
- **Range start / end** — the same primary fill, squared toward the range interior; the **range middle** is a continuous `surface-active` band (squared corners).
- **Today** — a `border-strong` outline + semibold text; combined with the fill when today is also selected.
- **Focused** — the roving cell; keyboard focus shows a 2px `focus-ring` outline (`:focus-visible`).
- **Disabled** — native `disabled` + 40% opacity + `cursor-not-allowed`; skipped by pointer and keyboard.
- **Outside days** — muted-foreground text (with `showOutsideDays`); selecting one pages to its month.
All state transitions are 150ms `ease-out` color transitions and collapse to nothing under `prefers-reduced-motion`.
## Responsive Behavior
A single month grid is 252px wide (7 × 36px cells; 288px with week numbers) and fits a 375px viewport without scaling. Day cells are 36×36px — the family's default control size. With `numberOfMonths > 1`, wrap the grids in a `flex-col sm:flex-row` container (see `calendar-range`) so the months stack on narrow screens instead of overflowing. The month/year pickers use the same width and a 3-column layout. Every variant is verified overflow-free at 375 / 768 / 1280px.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. The calendar follows the Calendar / Date Picker token rules: neutral surfaces, strong typography, thin borders, restrained radius (`radius-sm` cells, `radius-md` panel), compact 36px controls, a clear selected state, and light/dark parity.
## Notes
The control-panel pattern: external month/year selects can jump the calendar anywhere (e.g. February 2024 to inspect a leap month), and the calendar's own navigation stays consistent because it reports through the same callbacks. The locale switcher demonstrates that no month or weekday name is hardcoded. 1326 lines UTF-8 · LF · Spaces: 2
Continue browsing