Component
Date Picker — with Disabled Dates
Constraint enforcement end to end: disabled weekends + a holiday hold + a min/max booking window. Disabled days are genuinely non-selectable — pointer, keyboard, and range completion all respect them.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/datepicker/date-picker-with-disabled-dates/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/DatePicker/date-picker-with-disabled-dates React/Components/DatePicker/date-picker-with-disabled-dates import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
InputHTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
ReactElement,
ReactNode,
RefObject,
} from "react";
/**
* DevSnips React Date Picker — disabled dates and constraints.
*
* The shared compound DatePicker with a `disabledDates` matcher (weekends
* plus a holiday hold) inside a `minDate` / `maxDate` booking window.
* Disabled days are genuinely non-selectable across pointer, keyboard, and
* range completion. Implementation identical to the reference
* `date-picker/code.tsx`.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ------------------------------------------------------------------------ */
/* Date utilities — local calendar-date semantics (Calendar family model) */
/* ------------------------------------------------------------------------ */
export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** 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));
}
/**
* 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;
}
/**
* `yyyy-mm-dd` built from LOCAL date parts — never the built-in ISO
* conversion (which goes through UTC and can shift the calendar day). This
* is the value the hidden form input submits.
*/
export function formatISODate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/** `yyyy-mm-ddThh:mm` from local parts (the date-time mode form value). */
export function formatISODateTime(date: Date): string {
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${formatISODate(date)}T${hh}:${mm}`;
}
/* ------------------------------------------------------------------------ */
/* 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 DatePickerMode = "single" | "range";
export type DatePickerView = "days" | "months" | "years";
export type DatePickerSize = "md" | "lg";
export type DatePickerOpenOrigin = "input" | "trigger" | "programmatic";
/**
* A preset option for `<DatePickerPresets>`. `getValue` receives today so
* relative presets ("Last 7 days") stay correct without a clock dependency
* inside the component. The returned shape must match the picker's `mode`
* (`Date` for `single`, `DateRange` for `range`).
*/
export interface DatePickerPreset {
label: string;
description?: string;
getValue: (today: Date) => Date | DateRange;
}
export interface DatePickerBaseProps {
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called whenever the popover opens or closes. */
onOpenChange?: (open: boolean) => void;
/** 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; composes with `minDate` / `maxDate`. */
disabledDates?: (date: Date) => boolean;
/** BCP-47 locale tag for the calendar labels and the default date format. Default `"en-US"`. */
locale?: string;
/** First weekday column: 0 = Sunday … 6 = Saturday. Default 0. */
weekStartsOn?: WeekDay;
/**
* Display formatter for the committed value shown in the input. Defaults to
* `Intl.DateTimeFormat(locale, { dateStyle: "medium" })` (plus
* `timeStyle: "short"` when `withTime`).
*/
formatDate?: (date: Date) => string;
/** Input placeholder. Defaults to "Select date" / "Select date range". */
placeholder?: string;
/** Disables the control: no popover, not focusable, not submitted. */
disabled?: boolean;
/** Freezes the value: the input stays focusable + submittable but the popover cannot open. */
readOnly?: boolean;
/** Marks the control required: `aria-required` + a required marker on the root-rendered label. */
required?: boolean;
/**
* Form field name. When set, a hidden input submits the value as
* `yyyy-mm-dd` (`yyyy-mm-ddThh:mm` with `withTime`; `from/to` for ranges,
* `to` empty while the range is incomplete).
*/
name?: string;
/** Label text rendered by the root and associated with the input via `htmlFor` + `id`. */
label?: string;
/** Description text rendered above the control and referenced via `aria-describedby`. */
description?: string;
/** Helper text rendered below the control (hidden while `error` is set) and referenced via `aria-describedby`. */
helperText?: string;
/** Error message: renders with `role="alert"`, sets `aria-invalid` on the input, and is referenced via `aria-describedby`. */
error?: string;
/** Accessible name for the trigger button. Default "Open calendar". */
triggerLabel?: string;
/** Accessible name for the input when no `label` is rendered. Default "Date" / "Date range". */
inputAriaLabel?: string;
/**
* When true, calendar interaction stages a draft value: the input keeps
* showing the committed value until `<DatePickerApply>` commits the draft
* (Escape / outside close discards it). `<DatePickerClear>` always clears
* both immediately.
*/
requireApply?: boolean;
/** Adds the `<DatePickerTime>` hour/minute section to the value and keeps the popover open after day selection. */
withTime?: boolean;
/** Minute step for the time controls. Default 5. The current value is always offered even when off-step. */
timeStep?: number;
/** Initial visible month (uncontrolled). Defaults to the committed selection's month, else today. Any Date inside the month; normalized to the 1st. */
defaultMonth?: Date;
/** Consecutive month grids in the popover. Default 1. Grids stack vertically on narrow screens. */
numberOfMonths?: number;
/** Initial picker view. The heading button cycles days → months → years regardless. */
defaultView?: DatePickerView;
/** Control size: `md` = 36px cells (default), `lg` = 44px cells (touch-friendly). */
size?: DatePickerSize;
className?: string;
children?: ReactNode;
}
export interface DatePickerSingleValueProps {
mode?: "single";
value?: Date | null;
defaultValue?: Date | null;
onChange?: (date: Date | null) => void;
}
export interface DatePickerRangeValueProps {
mode: "range";
value?: DateRange | null;
defaultValue?: DateRange | null;
onChange?: (range: DateRange | null) => void;
}
export type DatePickerProps = DatePickerBaseProps &
(DatePickerSingleValueProps | DatePickerRangeValueProps);
/* ------------------------------------------------------------------------ */
/* 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 DatePickerContextValue {
mode: DatePickerMode;
locale: string;
weekStartsOn: WeekDay;
size: DatePickerSize;
numberOfMonths: number;
withTime: boolean;
requireApply: boolean;
open: boolean;
requestOpen: (origin: DatePickerOpenOrigin) => void;
requestClose: (restoreFocus: boolean) => void;
toggleOpen: (origin: DatePickerOpenOrigin) => void;
inputId: string;
panelId: string;
describedBy: string | undefined;
hasError: boolean;
disabled: boolean;
readOnly: boolean;
required: boolean;
placeholder: string;
triggerLabel: string;
/** Resolved accessible name for the input (root `inputAriaLabel` or the mode default). */
inputAriaLabel: string;
/** The committed value shown in the input (`Date | DateRange | null`). */
committedValue: Date | DateRange | null;
/** The value the calendar displays — the staged draft with `requireApply`, else the committed value. */
stagedValue: Date | DateRange | null;
displayValue: string;
/** Locale-aware display string for the value shapes (used by composed readouts). */
formatValue: (value: Date | DateRange | null) => string;
/** View state. */
view: DatePickerView;
setView: (view: DatePickerView) => void;
month: Date;
today: Date;
headingLabel: string;
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;
isPreviewMiddle: (date: Date) => boolean;
handleDayClick: (date: Date) => void;
handleDayKeyDown: (event: ReactKeyboardEvent<HTMLButtonElement>, date: Date) => void;
handleDayPointerEnter: (date: Date) => void;
handleDayPointerLeave: () => 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;
setFocusKey: (key: string) => void;
/** Footer / preset actions. */
clearValue: () => void;
applyDraft: () => void;
canApply: boolean;
selectToday: () => void;
todayDisabled: boolean;
applyPreset: (preset: DatePickerPreset) => void;
isPresetActive: (preset: DatePickerPreset) => boolean;
/** Time section state (`withTime`). Null until a date is staged/committed. */
time: { hours: number; minutes: number } | null;
setTime: (hours: number, minutes: number) => void;
timeStep: number;
}
const DatePickerContext = createContext<DatePickerContextValue | null>(null);
/** Access the nearest `<DatePicker>` context (for composed children such as footer readouts). */
export function useDatePicker(): DatePickerContextValue {
const ctx = useContext(DatePickerContext);
if (!ctx) throw new Error("DatePicker components must be composed inside <DatePicker>.");
return ctx;
}
/* Refs shared with the field primitives (input / trigger / content) without
* widening the public context surface. */
interface InternalRefs {
inputRef: RefObject<HTMLInputElement>;
triggerRef: RefObject<HTMLButtonElement>;
panelRef: RefObject<HTMLDivElement>;
}
const DatePickerRefsContext = createContext<InternalRefs>({
inputRef: { current: null },
triggerRef: { current: null },
panelRef: { current: null },
});
/* ------------------------------------------------------------------------ */
/* Shared class constants (token-driven) */
/* ------------------------------------------------------------------------ */
const INPUT_CLASSES =
"h-9 w-full cursor-default rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-input)] px-3 text-sm leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none";
const INPUT_ERROR_CLASSES = "border-[var(--ds-color-destructive)] hover:border-[var(--ds-color-destructive)]";
const TRIGGER_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 PANEL_CLASSES =
"absolute z-50 w-max max-w-[calc(100vw-1rem)] rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-3 shadow-[var(--ds-shadow-md)]";
const PANEL_SHEET_CLASSES =
"max-sm:fixed max-sm:inset-x-3 max-sm:bottom-3 max-sm:top-auto max-sm:mt-0 max-sm:w-auto max-sm:max-w-none max-sm:max-h-[75dvh] max-sm:overflow-y-auto";
const SHEET_OVERLAY_CLASSES = "fixed inset-0 z-40 bg-[var(--ds-color-overlay)] sm:hidden";
const NAV_BUTTON_CLASSES = TRIGGER_CLASSES;
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 DAY_BASE_CLASSES =
"inline-flex items-center justify-center rounded-[var(--ds-radius-sm)] border border-transparent 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 flex-wrap items-center justify-between gap-2 border-t border-[var(--ds-color-border-subtle)] pt-2";
const PRESET_CLASSES =
"inline-flex h-8 w-full items-center justify-start rounded-[var(--ds-radius-sm)] px-2 text-left text-sm leading-5 text-[var(--ds-color-foreground)] 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 PRESET_ACTIVE_CLASSES = "bg-[var(--ds-color-surface-active)] font-medium";
const ACTION_BUTTON_CLASSES =
"inline-flex h-8 items-center justify-center gap-1.5 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-2.5 text-xs font-medium leading-4 text-[var(--ds-color-foreground)] 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)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const APPLY_BUTTON_CLASSES =
"inline-flex h-8 items-center justify-center gap-1.5 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-primary)] px-2.5 text-xs font-medium leading-4 text-[var(--ds-color-primary-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] 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 TIME_SELECT_CLASSES =
"h-9 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-input)] px-2 text-sm leading-5 text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none";
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 PREVIEW_MIDDLE_CLASSES = "rounded-none bg-[var(--ds-color-surface-hover)] text-[var(--ds-color-foreground)]";
const PICKER_SELECTED_CLASSES = "bg-[var(--ds-color-primary)] font-medium text-[var(--ds-color-primary-foreground)]";
/** Size scale: md = the family's default 36px controls; lg = 44px touch targets. */
const SIZE_CLASSES: Record<DatePickerSize, { cell: string; text: string; weekday: string }> = {
md: { cell: "size-9", text: "text-sm", weekday: "text-[11px]" },
lg: { cell: "size-11", text: "text-sm", weekday: "text-xs" },
};
/* ------------------------------------------------------------------------ */
/* Icons (inline SVG — consistent stroke with the DevSnips icon set) */
/* ------------------------------------------------------------------------ */
function CalendarGlyphIcon() {
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">
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
<line x1="16" x2="16" y1="2" y2="6" />
<line x1="8" x2="8" y1="2" y2="6" />
<line x1="3" x2="21" y1="10" y2="10" />
</svg>
);
}
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 */
/* ------------------------------------------------------------------------ */
function firstDateOf(value: Date | DateRange | null): Date | null {
if (!value) return null;
return value instanceof Date ? value : value.from;
}
export function DatePicker(props: DatePickerProps): ReactElement {
const {
open: controlledOpen,
defaultOpen = false,
onOpenChange,
minDate,
maxDate,
disabledDates,
locale = "en-US",
weekStartsOn = 0,
formatDate: formatDateProp,
placeholder: placeholderProp,
disabled = false,
readOnly = false,
required = false,
name,
label,
description,
helperText,
error,
triggerLabel = "Open calendar",
inputAriaLabel,
requireApply = false,
withTime = false,
timeStep = 5,
defaultMonth,
numberOfMonths = 1,
defaultView = "days",
size = "md",
className,
children,
// Value props are consumed through the typed slices below — destructured
// here so they never leak onto the DOM via `...rest` (`onChange` also
// collides with the native React event handler).
mode: _mode,
value: _value,
defaultValue: _defaultValue,
onChange: _onChange,
...rest
} = props;
const mode: DatePickerMode = _mode ?? "single";
// The discriminated union guarantees each value 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.
const singleSlice = props as DatePickerBaseProps & DatePickerSingleValueProps;
const rangeSlice = props as DatePickerBaseProps & DatePickerRangeValueProps;
const [singleValue, setSingleValue] = useControllableState<Date | null>(
mode === "single" ? singleSlice.value : undefined,
(mode === "single" ? singleSlice.defaultValue : null) ?? null,
mode === "single" ? singleSlice.onChange : undefined,
);
const [rangeValue, setRangeValue] = useControllableState<DateRange | null>(
mode === "range" ? rangeSlice.value : undefined,
(mode === "range" ? rangeSlice.defaultValue : undefined) ?? null,
mode === "range" ? rangeSlice.onChange : undefined,
);
const committedValue: Date | DateRange | null = mode === "single" ? singleValue : rangeValue;
const commitValue = useCallback(
(next: Date | DateRange | null) => {
if (mode === "single") setSingleValue(next === null || next instanceof Date ? next : next.from);
else setRangeValue(next instanceof Date ? { from: next, to: null } : next);
},
[mode, setSingleValue, setRangeValue],
);
const [open, setOpen] = useControllableState<boolean>(controlledOpen, defaultOpen, onOpenChange);
const [today] = useState(() => new Date());
/* ----- formatting ------------------------------------------------------- */
const formatDate = useCallback(
(date: Date): string => {
if (formatDateProp) return formatDateProp(date);
return new Intl.DateTimeFormat(
locale,
withTime
? { dateStyle: "medium", timeStyle: "short", hourCycle: "h23" }
: { dateStyle: "medium" },
).format(date);
},
[formatDateProp, locale, withTime],
);
const formatValue = useCallback(
(value: Date | DateRange | null): string => {
if (!value) return "";
if (value instanceof Date) return formatDate(value);
if (!value.to) return `${formatDate(value.from)} – …`;
return `${formatDate(value.from)} – ${formatDate(value.to)}`;
},
[formatDate],
);
const placeholder =
placeholderProp ?? (mode === "range" ? "Select date range" : "Select date");
const resolvedInputAriaLabel =
inputAriaLabel ?? (mode === "range" ? "Date range" : "Date");
/* ----- visible month ---------------------------------------------------- */
const [monthState, setMonthState] = useState<Date>(() =>
startOfMonth(defaultMonth ?? firstDateOf(committedValue) ?? today),
);
const monthKey = monthState.getFullYear() * 12 + monthState.getMonth();
const month = useMemo(() => startOfMonth(monthState), [monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
const [view, setView] = useState<DatePickerView>(defaultView);
const [focusKey, setFocusKey] = useState<string | null>(null);
const focusIntentRef = useRef<string | null>(null);
const [hoveredDate, setHoveredDate] = useState<Date | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const restoreTargetRef = useRef<HTMLElement | null>(null);
const restoreRequestedRef = useRef(false);
/* ----- constraints ------------------------------------------------------ */
const isDisabled = useCallback(
(date: Date): boolean => {
if (minDate && compareDays(date, minDate) < 0) return true;
if (maxDate && compareDays(date, maxDate) > 0) return true;
return disabledDates ? disabledDates(date) : false;
},
[minDate, maxDate, disabledDates],
);
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],
);
const lastVisibleMonth = useMemo(
() => startOfMonth(addMonths(month, numberOfMonths - 1)),
[month, numberOfMonths],
);
const isRenderedDate = useCallback(
(date: Date): boolean =>
compareDays(date, month) >= 0 && compareDays(date, endOfMonth(lastVisibleMonth)) <= 0,
[month, lastVisibleMonth],
);
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],
);
/** 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]);
/* ----- open / close ------------------------------------------------------ */
// The focus-restore target is captured at request time (not in the open
// effect): child effects run before the root's, so by effect time focus has
// already moved into the panel.
const requestOpen = useCallback(
(_origin: DatePickerOpenOrigin) => {
if (disabled || readOnly || open) return;
restoreTargetRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
setOpen(true);
},
[disabled, readOnly, open, setOpen],
);
const requestClose = useCallback(
(restoreFocus: boolean) => {
if (!open) return;
if (restoreFocus) restoreRequestedRef.current = true;
setOpen(false);
},
[open, setOpen],
);
const toggleOpen = useCallback(
(origin: DatePickerOpenOrigin) => {
if (open) requestClose(true);
else requestOpen(origin);
},
[open, requestOpen, requestClose],
);
// Opening: reset to the default view and reveal the selected date's month.
// Focus moves into the grid only when the open was user-initiated (a
// restore target was captured) — a programmatic `defaultOpen` / controlled
// `open` never steals focus on mount.
const committedRef = useRef(committedValue);
committedRef.current = committedValue;
useEffect(() => {
if (!open) return;
setHoveredDate(null);
setView(defaultView);
const selected = firstDateOf(committedRef.current);
const reveal = selected ? monthToReveal(selected) : month;
if (selected) goToMonth(reveal);
if (!restoreTargetRef.current) return;
// Picker entry views focus the matching picker option, not a day.
if (defaultView === "months") {
requestFocus(`m${reveal.getMonth()}`);
return;
}
if (defaultView === "years") {
requestFocus(`y${reveal.getFullYear()}`);
return;
}
// Focus target: the selected day, else today (when rendered), else the
// first enabled day of the visible months — computed against the revealed
// month, which applies in the same commit as the focus request.
const renderedEnd = endOfMonth(startOfMonth(addMonths(reveal, numberOfMonths - 1)));
const rendered = (d: Date) => compareDays(d, reveal) >= 0 && compareDays(d, renderedEnd) <= 0;
let target: Date | null = null;
if (selected && rendered(selected) && !isDisabled(selected)) target = selected;
else if (rendered(today) && !isDisabled(today)) target = today;
else {
for (let i = 0; i < numberOfMonths && !target; i += 1) {
const gridMonth = startOfMonth(addMonths(reveal, i));
const count = daysInMonth(gridMonth.getFullYear(), gridMonth.getMonth());
for (let d = 0; d < count && !target; d += 1) {
const candidate = addDays(gridMonth, d);
if (!isDisabled(candidate)) target = candidate;
}
}
}
if (target) requestFocus(String(dayKey(target)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Closing: restore focus to the element that opened the popover, but never
// to an unmounted one.
useEffect(() => {
if (open || !restoreRequestedRef.current) return;
restoreRequestedRef.current = false;
const target = restoreTargetRef.current;
restoreTargetRef.current = null;
if (target && target.isConnected) target.focus();
}, [open]);
/* ----- draft (staged selection for requireApply) ------------------------- */
const [draft, setDraft] = useState<Date | DateRange | null>(committedValue);
// The draft tracks the committed value while the popover is closed, so
// reopening starts from the committed value and a discarded draft never
// lingers.
useEffect(() => {
if (!open) setDraft(committedValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, committedValue]);
const stagedValue: Date | DateRange | null = requireApply ? draft : committedValue;
/* ----- 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],
);
/** Preserve the current time on a newly picked day (withTime); 12:00 when empty. */
const withCurrentTime = useCallback(
(day: Date): Date => {
if (!withTime) return day;
const base = firstDateOf(stagedValue);
const hours = base ? base.getHours() : 12;
const minutes = base ? base.getMinutes() : 0;
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), hours, minutes);
},
[withTime, stagedValue],
);
const selectDate = useCallback(
(date: Date) => {
if (isDisabled(date)) return;
const day = withCurrentTime(date);
if (mode === "single") {
if (requireApply) {
setDraft(day);
return;
}
if (singleValue && isSameDay(singleValue, day)) {
// Re-clicking the committed date: never an accidental deselect —
// just dismiss the popover.
if (!withTime) requestClose(true);
return;
}
commitValue(day);
if (!withTime) requestClose(true);
return;
}
// range
const current = stagedValue instanceof Date || stagedValue === null ? null : stagedValue;
let next: DateRange;
if (!current || current.to) {
next = { from: day, to: null };
} else if (isSameDay(day, current.from)) {
next = { from: current.from, to: current.from };
} else if (compareDays(day, current.from) < 0 || rangeCrossesDisabled(current.from, day)) {
// Earlier-than-start or crossing a disabled day: restart, predictably.
next = { from: day, to: null };
} else {
next = { from: current.from, to: day };
}
if (requireApply) setDraft(next);
else commitValue(next);
setHoveredDate(null);
if (next.to && !requireApply && !withTime) requestClose(true);
},
[
isDisabled,
withCurrentTime,
mode,
requireApply,
singleValue,
withTime,
requestClose,
commitValue,
stagedValue,
rangeCrossesDisabled,
],
);
const clearValue = useCallback(() => {
// Clear is an explicit action: it commits immediately even under
// requireApply, and resets the draft so a reopened popover stays empty.
commitValue(null);
setDraft(null);
setHoveredDate(null);
}, [commitValue]);
const canApply =
mode === "single" ||
stagedValue === null ||
stagedValue instanceof Date ||
stagedValue.to !== null;
const applyDraft = useCallback(() => {
if (!canApply) return;
if (requireApply) commitValue(stagedValue);
requestClose(true);
}, [canApply, requireApply, commitValue, stagedValue, requestClose]);
const todayDisabled = isDisabled(today);
const selectToday = useCallback(() => {
if (todayDisabled) return;
goToMonth(monthToReveal(today));
requestFocus(String(dayKey(today)));
selectDate(today);
}, [todayDisabled, goToMonth, monthToReveal, today, selectDate]);
const applyPreset = useCallback(
(preset: DatePickerPreset) => {
const value = preset.getValue(today);
const anchor = value instanceof Date ? value : value.from;
const matches = mode === "single" ? value instanceof Date : !(value instanceof Date);
if (!matches) return;
if (requireApply) setDraft(value);
else commitValue(value);
goToMonth(monthToReveal(anchor));
requestFocus(String(dayKey(anchor)));
if (!requireApply) requestClose(true);
},
[today, mode, requireApply, commitValue, goToMonth, monthToReveal, requestClose],
);
const isPresetActive = useCallback(
(preset: DatePickerPreset): boolean => {
const value = preset.getValue(today);
const current = stagedValue;
if (value instanceof Date) return current instanceof Date && isSameDay(value, current);
if (current === null || current instanceof Date || current.to === null) return false;
return isSameDay(value.from, current.from) && isSameDay(value.to ?? value.from, current.to);
},
[today, stagedValue],
);
/* ----- time (withTime) ---------------------------------------------------- */
const time = useMemo((): { hours: number; minutes: number } | null => {
if (!withTime) return null;
const base = firstDateOf(stagedValue);
return base ? { hours: base.getHours(), minutes: base.getMinutes() } : null;
}, [withTime, stagedValue]);
const setTime = useCallback(
(hours: number, minutes: number) => {
if (!withTime) return;
const base = stagedValue;
if (!base) return;
const merge = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate(), hours, minutes);
const next =
base instanceof Date
? merge(base)
: { from: merge(base.from), to: base.to ? merge(base.to) : null };
if (requireApply) setDraft(next);
else commitValue(next);
},
[withTime, stagedValue, requireApply, commitValue],
);
/* ----- range / preview state ---------------------------------------------- */
const rangeState = stagedValue instanceof Date || stagedValue === null ? null : stagedValue;
const isRangeStart = useCallback(
(date: Date) =>
mode === "range" &&
rangeState !== null &&
rangeState.to !== null &&
!isSameDay(rangeState.from, rangeState.to) &&
isSameDay(rangeState.from, date),
[mode, rangeState],
);
const isRangeEnd = useCallback(
(date: Date) =>
mode === "range" &&
rangeState !== null &&
rangeState.to !== null &&
!isSameDay(rangeState.from, rangeState.to) &&
isSameDay(rangeState.to, date),
[mode, rangeState],
);
const isRangeMiddle = useCallback(
(date: Date) =>
mode === "range" &&
rangeState !== null &&
rangeState.to !== null &&
compareDays(date, rangeState.from) > 0 &&
compareDays(date, rangeState.to) < 0,
[mode, rangeState],
);
const isPreviewMiddle = useCallback(
(date: Date): boolean => {
if (mode !== "range" || !rangeState || rangeState.to !== null || !hoveredDate) return false;
if (compareDays(hoveredDate, rangeState.from) <= 0) return false;
return compareDays(date, rangeState.from) > 0 && compareDays(date, hoveredDate) < 0;
},
[mode, rangeState, hoveredDate],
);
const isSelected = useCallback(
(date: Date): boolean => {
if (mode === "single") return stagedValue instanceof Date && isSameDay(stagedValue, date);
return (
rangeState !== null &&
(isSameDay(rangeState.from, date) ||
(rangeState.to !== null &&
compareDays(date, rangeState.from) > 0 &&
compareDays(date, rangeState.to) <= 0))
);
},
[mode, stagedValue, rangeState],
);
const handleDayClick = useCallback((date: Date) => selectDate(date), [selectDate]);
const handleDayPointerEnter = useCallback(
(date: Date) => {
if (mode === "range" && rangeState && rangeState.to === null) setHoveredDate(date);
},
[mode, rangeState],
);
const handleDayPointerLeave = useCallback(() => setHoveredDate(null), []);
/* ----- focus model (roving tabindex) -------------------------------------- */
const focusedDay = useMemo((): Date | null => {
if (focusKey && /^\d{8}$/.test(focusKey)) return dateFromKey(Number(focusKey));
return null;
}, [focusKey]);
const firstStagedDate = firstDateOf(stagedValue);
const tabbableDayKey = useMemo((): number | null => {
const candidates: Array<Date | null> = [focusedDay, firstStagedDate, 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, firstStagedDate, 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 activations. The key
* is parked on a ref so the focus effect can always read the LATEST
* request — even from a commit whose `focusKey` closure is still stale.
*/
const requestFocus = useCallback((key: string) => {
focusIntentRef.current = key;
setFocusKey(key);
}, []);
// Focus follows the roving key. Without an explicit intent the effect only
// refocuses while focus already lives inside the panel, so pointer users
// are never focus-napped.
useEffect(() => {
if (!open || focusKey == null) return;
const panel = panelRef.current;
if (!panel) return;
const intent = focusIntentRef.current;
focusIntentRef.current = null;
if (!intent && !panel.contains(document.activeElement)) return;
const el = panel.querySelector<HTMLElement>(`[data-dp-focus="${intent ?? focusKey}"]`);
if (el && el !== document.activeElement) el.focus();
}, [open, 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":
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],
);
/* ----- 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 ?? firstStagedDate ?? 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, firstStagedDate, 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],
);
/* ----- ids + field wiring --------------------------------------------------- */
const baseId = useId().replace(/:/g, "");
const inputId = `${baseId}-input`;
const panelId = `${baseId}-panel`;
const descriptionId = description !== undefined ? `${baseId}-description` : null;
const hasError = typeof error === "string" && error.length > 0;
const messageId = hasError ? `${baseId}-error` : helperText !== undefined ? `${baseId}-helper` : null;
const describedBy = [descriptionId, messageId].filter(Boolean).join(" ") || undefined;
/* ----- form value ------------------------------------------------------------ */
const isoValue = useMemo((): string => {
if (!committedValue) return "";
if (committedValue instanceof Date) {
return withTime ? formatISODateTime(committedValue) : formatISODate(committedValue);
}
const from = formatISODate(committedValue.from);
const to = committedValue.to ? formatISODate(committedValue.to) : "";
return `${from}/${to}`;
}, [committedValue, withTime]);
const displayValue = formatValue(committedValue);
const contextValue: DatePickerContextValue = {
mode,
locale,
weekStartsOn,
size,
numberOfMonths,
withTime,
requireApply,
open,
requestOpen,
requestClose,
toggleOpen,
inputId,
panelId,
describedBy,
hasError,
disabled,
readOnly,
required,
placeholder,
triggerLabel,
inputAriaLabel: resolvedInputAriaLabel,
committedValue,
stagedValue,
displayValue,
formatValue,
view,
setView,
month,
today,
headingLabel,
goToPrevious,
goToNext,
canGoPrevious,
canGoNext,
previousLabel,
nextLabel,
isDisabled,
isSelected,
isRangeStart,
isRangeEnd,
isRangeMiddle,
isPreviewMiddle,
handleDayClick,
handleDayKeyDown,
handleDayPointerEnter,
handleDayPointerLeave,
tabbableDayKey,
monthEnabled,
yearEnabled,
monthActiveKey,
yearActiveKey,
yearPage,
selectMonth,
selectYear,
handleMonthKeyDown,
handleYearKeyDown,
setFocusKey,
clearValue,
applyDraft,
canApply,
selectToday,
todayDisabled,
applyPreset,
isPresetActive,
time,
setTime,
timeStep,
};
const refsValue = useMemo<InternalRefs>(
() => ({ inputRef, triggerRef, panelRef }),
[],
);
const hasFieldChrome =
label !== undefined || description !== undefined || helperText !== undefined || hasError;
const hiddenInput = name !== undefined && (
<input type="hidden" name={name} value={isoValue} disabled={disabled} readOnly />
);
return (
<DatePickerContext.Provider value={contextValue}>
<DatePickerRefsContext.Provider value={refsValue}>
{hasFieldChrome ? (
<div className={cx("w-full max-w-xs space-y-1.5", className)} {...rest}>
{label !== undefined && (
<label
htmlFor={inputId}
className="block text-sm font-medium leading-5 text-[var(--ds-color-foreground)]"
>
{label}
{required && (
<span aria-hidden="true" className="text-[var(--ds-color-destructive)]">
{" *"}
</span>
)}
</label>
)}
{description !== undefined && (
<p
id={descriptionId ?? undefined}
className="m-0 text-xs leading-4 text-[var(--ds-color-muted-foreground)]"
>
{description}
</p>
)}
<div className="relative flex items-center gap-1">
{children}
{hiddenInput}
</div>
{hasError ? (
<p
id={messageId ?? undefined}
role="alert"
className="m-0 text-xs font-medium leading-4 text-[var(--ds-color-destructive)]"
>
{error}
</p>
) : (
helperText !== undefined && (
<p
id={messageId ?? undefined}
className="m-0 text-xs leading-4 text-[var(--ds-color-muted-foreground)]"
>
{helperText}
</p>
)
)}
</div>
) : (
<div className={cx("relative flex w-full max-w-xs items-center gap-1", className)} {...rest}>
{children}
{hiddenInput}
</div>
)}
</DatePickerRefsContext.Provider>
</DatePickerContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* Field: input + trigger */
/* ------------------------------------------------------------------------ */
export type DatePickerInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
"value" | "defaultValue" | "onChange" | "onSelect" | "size"
>;
export function DatePickerInput({
className,
id,
onClick,
onKeyDown,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
...rest
}: DatePickerInputProps): ReactElement {
const ctx = useDatePicker();
const refs = useContext(DatePickerRefsContext);
return (
<input
ref={refs.inputRef}
type="text"
readOnly
id={id ?? ctx.inputId}
value={ctx.displayValue}
placeholder={ctx.placeholder}
disabled={ctx.disabled}
aria-label={ariaLabel ?? ctx.inputAriaLabel}
aria-haspopup="dialog"
aria-expanded={ctx.open}
aria-controls={ctx.open ? ctx.panelId : undefined}
aria-required={ctx.required || undefined}
aria-invalid={ctx.hasError || undefined}
aria-describedby={cx(ariaDescribedBy, ctx.describedBy) || undefined}
onClick={(event) => {
if (!ctx.open) ctx.requestOpen("input");
onClick?.(event);
}}
onKeyDown={(event) => {
if (
!ctx.open &&
(event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
ctx.requestOpen("input");
}
onKeyDown?.(event);
}}
className={cx(INPUT_CLASSES, ctx.hasError && INPUT_ERROR_CLASSES, className)}
{...rest}
/>
);
}
export function DatePickerTrigger({
className,
children,
onClick,
"aria-label": ariaLabel,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement>): ReactElement {
const ctx = useDatePicker();
const refs = useContext(DatePickerRefsContext);
return (
<button
ref={refs.triggerRef}
type="button"
aria-label={ariaLabel ?? ctx.triggerLabel}
aria-haspopup="dialog"
aria-expanded={ctx.open}
aria-controls={ctx.open ? ctx.panelId : undefined}
disabled={ctx.disabled || ctx.readOnly}
onClick={(event) => {
ctx.toggleOpen("trigger");
onClick?.(event);
}}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{children ?? <CalendarGlyphIcon />}
</button>
);
}
/* ------------------------------------------------------------------------ */
/* Popover content */
/* ------------------------------------------------------------------------ */
export interface DatePickerContentProps extends HTMLAttributes<HTMLDivElement> {
/**
* Mobile presentation: below the `sm` breakpoint the panel docks as a
* full-width bottom sheet over a dimmed overlay (pure CSS — no viewport
* JavaScript). At `sm` and up it stays a popover.
*/
mobileSheet?: boolean;
}
export function DatePickerContent({
mobileSheet = false,
className,
children,
onKeyDown,
...rest
}: DatePickerContentProps): ReactElement | null {
const ctx = useDatePicker();
const refs = useContext(DatePickerRefsContext);
const [side, setSide] = useState<"bottom" | "top">("bottom");
const [align, setAlign] = useState<"start" | "end">("start");
// Viewport correction: flip above the field when the space below runs out,
// and pin to the field's right edge when the panel would overflow the
// viewport's right edge. Class-driven (no inline styles). Placement is
// computed from the field rect + the panel's own size only — never from
// the panel's current placement — so the result is stable. Two passes: the
// layout effect catches the common case pre-paint; the setTimeout pass
// re-measures after runtime-injected CSS (e.g. a Tailwind CDN build) has
// settled — with compiled CSS the second pass is a no-op.
useLayoutEffect(() => {
if (!ctx.open) return;
const measure = () => {
const panel = refs.panelRef.current;
const box = panel?.parentElement;
if (!panel || !box) return;
const field = box.getBoundingClientRect();
const panelHeight = panel.offsetHeight;
const panelWidth = panel.offsetWidth;
const margin = 8;
const fitsBelow = field.bottom + panelHeight + margin <= window.innerHeight;
const fitsAbove = field.top - panelHeight - margin >= 0;
setSide(!fitsBelow && fitsAbove ? "top" : "bottom");
setAlign(field.left + panelWidth + margin > window.innerWidth ? "end" : "start");
};
measure();
const refine = window.setTimeout(measure, 0);
return () => window.clearTimeout(refine);
}, [ctx.open, ctx.month, ctx.view]);
// Dismissal: Escape anywhere closes and restores focus; pointer interaction
// outside the panel + field closes (restoring focus when the click landed
// somewhere non-focusable, so focus is never stranded on <body>).
const requestClose = ctx.requestClose;
const open = ctx.open;
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") requestClose(true);
};
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
const panel = refs.panelRef.current;
const input = refs.inputRef.current;
const trigger = refs.triggerRef.current;
if (panel && panel.contains(target)) return;
if (input && input.contains(target)) return;
if (trigger && trigger.contains(target)) return;
const focusable =
target instanceof Element
? target.closest("button, a, input, select, textarea, [tabindex]")
: null;
if (!focusable) event.preventDefault();
requestClose(!focusable);
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onPointerDown);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointerDown);
};
}, [open, requestClose, refs]);
if (!open) return null;
const dialogLabel =
ctx.mode === "range"
? ctx.withTime
? "Choose date and time range"
: "Choose date range"
: ctx.withTime
? "Choose date and time"
: "Choose date";
const handleTab = (event: ReactKeyboardEvent<HTMLDivElement>) => {
// Tab is never trapped: leaving the panel in either direction closes it
// and lets the browser continue the natural focus order.
if (event.key !== "Tab") return;
const panel = refs.panelRef.current;
if (!panel) return;
const focusables = Array.from(
panel.querySelectorAll<HTMLElement>(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), [tabindex="0"]',
),
).filter((el) => el.getClientRects().length > 0);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) requestClose(false);
else if (!event.shiftKey && document.activeElement === last) requestClose(false);
};
return (
<>
{mobileSheet && <div aria-hidden="true" className={SHEET_OVERLAY_CLASSES} />}
<div
ref={refs.panelRef}
role="dialog"
aria-label={dialogLabel}
id={ctx.panelId}
onKeyDown={(event) => {
handleTab(event);
onKeyDown?.(event);
}}
className={cx(
PANEL_CLASSES,
side === "top" ? "bottom-full mb-1" : "top-full mt-1",
align === "end" ? "right-0" : "left-0",
mobileSheet && PANEL_SHEET_CLASSES,
className,
)}
{...rest}
>
{children ?? (
<>
<DatePickerHeader />
<DatePickerCalendar />
</>
)}
</div>
</>
);
}
/* ------------------------------------------------------------------------ */
/* Header (month / year navigation) */
/* ------------------------------------------------------------------------ */
export type DatePickerPartProps = HTMLAttributes<HTMLDivElement>;
export function DatePickerHeader({ className, children, ...rest }: DatePickerPartProps): ReactElement {
const ctx = useDatePicker();
return (
<div className={cx("mb-1 flex items-center justify-between gap-1", className)} {...rest}>
{children ?? (
<>
<button
type="button"
aria-label={ctx.previousLabel}
disabled={!ctx.canGoPrevious}
onClick={ctx.goToPrevious}
className={NAV_BUTTON_CLASSES}
>
<ChevronLeftIcon />
</button>
<h2 aria-live="polite" aria-atomic="true" className={cx(HEADING_CLASSES, "flex-1 text-center")}>
{ctx.view === "years" ? (
ctx.headingLabel
) : (
<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>
<button
type="button"
aria-label={ctx.nextLabel}
disabled={!ctx.canGoNext}
onClick={ctx.goToNext}
className={NAV_BUTTON_CLASSES}
>
<ChevronRightIcon />
</button>
</>
)}
</div>
);
}
/* ------------------------------------------------------------------------ */
/* Calendar (day matrix + month / year pickers) */
/* ------------------------------------------------------------------------ */
export type DatePickerCalendarProps = HTMLAttributes<HTMLDivElement>;
export function DatePickerCalendar({ className, ...rest }: DatePickerCalendarProps): ReactElement {
const ctx = useDatePicker();
if (ctx.view === "months") return <MonthsPanel className={className} />;
if (ctx.view === "years") return <YearsPanel className={className} />;
const months = Array.from({ length: ctx.numberOfMonths }, (_, i) =>
startOfMonth(addMonths(ctx.month, i)),
);
return (
<div className={cx("flex flex-col gap-4 sm:flex-row", className)} {...rest}>
{months.map((gridMonth) => (
<MonthGrid key={dayKey(gridMonth)} gridMonth={gridMonth} />
))}
</div>
);
}
function MonthGrid({ gridMonth }: { gridMonth: Date }): ReactElement {
const ctx = useDatePicker();
const sizeClasses = SIZE_CLASSES[ctx.size];
const weeks = buildMonthWeeks(gridMonth, ctx.weekStartsOn);
const weekdays = weekdayNames(ctx.locale, ctx.weekStartsOn);
return (
<div role="grid" aria-label={monthYearLabel(gridMonth, ctx.locale)}>
<div role="row" className="flex">
{weekdays.map((day, i) => (
<div
role="columnheader"
key={i}
aria-label={day.long}
className={cx(
"flex items-center justify-center font-medium uppercase tracking-[0.04em] text-[var(--ds-color-muted-foreground)]",
sizeClasses.cell,
sizeClasses.weekday,
)}
>
{day.short}
</div>
))}
</div>
{weeks.map((week, rowIndex) => (
<div role="row" className="flex" key={rowIndex}>
{week.map((date) => (
<DayCell key={dayKey(date)} date={date} gridMonth={gridMonth} />
))}
</div>
))}
</div>
);
}
function DayCell({ date, gridMonth }: { date: Date; gridMonth: Date }): ReactElement {
const ctx = useDatePicker();
const sizeClasses = SIZE_CLASSES[ctx.size];
const outside = date.getMonth() !== gridMonth.getMonth();
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 previewMiddle = ctx.isPreviewMiddle(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={sizeClasses.cell}>
<button
type="button"
disabled={isDisabledDay}
tabIndex={ctx.tabbableDayKey === dayKey(date) ? 0 : -1}
data-dp-focus={key}
aria-label={fullDateLabel(date, ctx.locale)}
aria-current={isToday ? "date" : undefined}
onClick={() => ctx.handleDayClick(date)}
onPointerEnter={() => ctx.handleDayPointerEnter(date)}
onPointerLeave={ctx.handleDayPointerLeave}
onFocus={() => ctx.setFocusKey(key)}
onKeyDown={(event) => ctx.handleDayKeyDown(event, date)}
className={cx(
DAY_BASE_CLASSES,
sizeClasses.cell,
sizeClasses.text,
rangeMiddle && RANGE_MIDDLE_CLASSES,
!rangeMiddle && previewMiddle && PREVIEW_MIDDLE_CLASSES,
!rangeMiddle && !previewMiddle && filled && SELECTED_DAY_CLASSES,
!rangeMiddle && !previewMiddle && !filled &&
(outside ? "text-[var(--ds-color-muted-foreground)]" : "text-[var(--ds-color-foreground)]"),
!rangeMiddle && !previewMiddle && !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 }: { className?: string }): ReactElement {
const ctx = useDatePicker();
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-dp-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 = useDatePicker();
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-dp-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 + actions */
/* ------------------------------------------------------------------------ */
export function DatePickerFooter({ className, children, ...rest }: DatePickerPartProps): ReactElement {
return (
<div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export function DatePickerToday({
className,
children,
onClick,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement>): ReactElement {
const ctx = useDatePicker();
return (
<button
type="button"
disabled={ctx.todayDisabled}
onClick={(event) => {
ctx.selectToday();
onClick?.(event);
}}
className={cx(ACTION_BUTTON_CLASSES, className)}
{...rest}
>
{children ?? "Today"}
</button>
);
}
export function DatePickerClear({
className,
children,
onClick,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement>): ReactElement {
const ctx = useDatePicker();
return (
<button
type="button"
disabled={ctx.committedValue === null && ctx.stagedValue === null}
onClick={(event) => {
ctx.clearValue();
onClick?.(event);
}}
className={cx(ACTION_BUTTON_CLASSES, className)}
{...rest}
>
{children ?? "Clear"}
</button>
);
}
export function DatePickerApply({
className,
children,
onClick,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement>): ReactElement {
const ctx = useDatePicker();
return (
<button
type="button"
disabled={!ctx.canApply}
onClick={(event) => {
ctx.applyDraft();
onClick?.(event);
}}
className={cx(APPLY_BUTTON_CLASSES, className)}
{...rest}
>
{children ?? (ctx.requireApply ? "Apply" : "Done")}
</button>
);
}
/* ------------------------------------------------------------------------ */
/* Presets */
/* ------------------------------------------------------------------------ */
export interface DatePickerPresetsProps extends HTMLAttributes<HTMLDivElement> {
/** The preset options. Each `getValue(today)` must match the picker's `mode`. */
presets: DatePickerPreset[];
/** Accessible group label. Default "Date presets". */
label?: string;
}
export function DatePickerPresets({
presets,
label = "Date presets",
className,
...rest
}: DatePickerPresetsProps): ReactElement {
const ctx = useDatePicker();
return (
<div
role="group"
aria-label={label}
className={cx("flex flex-row flex-wrap gap-1 sm:w-36 sm:flex-col", className)}
{...rest}
>
{presets.map((preset) => (
<button
key={preset.label}
type="button"
title={preset.description}
aria-current={ctx.isPresetActive(preset) ? "date" : undefined}
onClick={() => ctx.applyPreset(preset)}
className={cx(PRESET_CLASSES, ctx.isPresetActive(preset) && PRESET_ACTIVE_CLASSES)}
>
{preset.label}
</button>
))}
</div>
);
}
/* ------------------------------------------------------------------------ */
/* Time section (withTime) */
/* ------------------------------------------------------------------------ */
export function DatePickerTime({ className, ...rest }: HTMLAttributes<HTMLDivElement>): ReactElement {
const ctx = useDatePicker();
const step = Math.max(1, Math.min(60, ctx.timeStep));
const minuteOptions: number[] = [];
for (let m = 0; m < 60; m += step) minuteOptions.push(m);
if (ctx.time && !minuteOptions.includes(ctx.time.minutes)) {
minuteOptions.push(ctx.time.minutes);
minuteOptions.sort((a, b) => a - b);
}
const noDate = ctx.time === null;
return (
<div className={cx("mt-2 border-t border-[var(--ds-color-border-subtle)] pt-3", className)} {...rest}>
<div className="flex items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.04em] text-[var(--ds-color-muted-foreground)]">
Hours
</span>
<select
value={ctx.time ? String(ctx.time.hours) : "12"}
disabled={noDate}
onChange={(event) => ctx.setTime(Number(event.target.value), ctx.time?.minutes ?? 0)}
className={TIME_SELECT_CLASSES}
>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{String(h).padStart(2, "0")}
</option>
))}
</select>
</label>
<span aria-hidden="true" className="pb-2 text-sm text-[var(--ds-color-muted-foreground)]">
:
</span>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.04em] text-[var(--ds-color-muted-foreground)]">
Minutes
</span>
<select
value={ctx.time ? String(ctx.time.minutes) : "0"}
disabled={noDate}
onChange={(event) => ctx.setTime(ctx.time?.hours ?? 12, Number(event.target.value))}
className={TIME_SELECT_CLASSES}
>
{minuteOptions.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, "0")}
</option>
))}
</select>
</label>
{noDate && (
<p className="m-0 pb-2 text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
Select a date to set the time.
</p>
)}
</div>
</div>
);
}
export default DatePicker; /* 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,
useId,
useLayoutEffect,
useMemo,
useRef,
useState
} from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
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 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 formatISODate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function formatISODateTime(date) {
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${formatISODate(date)}T${hh}:${mm}`;
}
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 DatePickerContext = createContext(null);
function useDatePicker() {
const ctx = useContext(DatePickerContext);
if (!ctx) throw new Error("DatePicker components must be composed inside <DatePicker>.");
return ctx;
}
const DatePickerRefsContext = createContext({
inputRef: { current: null },
triggerRef: { current: null },
panelRef: { current: null }
});
const INPUT_CLASSES = "h-9 w-full cursor-default rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-input)] px-3 text-sm leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none";
const INPUT_ERROR_CLASSES = "border-[var(--ds-color-destructive)] hover:border-[var(--ds-color-destructive)]";
const TRIGGER_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 PANEL_CLASSES = "absolute z-50 w-max max-w-[calc(100vw-1rem)] rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-3 shadow-[var(--ds-shadow-md)]";
const PANEL_SHEET_CLASSES = "max-sm:fixed max-sm:inset-x-3 max-sm:bottom-3 max-sm:top-auto max-sm:mt-0 max-sm:w-auto max-sm:max-w-none max-sm:max-h-[75dvh] max-sm:overflow-y-auto";
const SHEET_OVERLAY_CLASSES = "fixed inset-0 z-40 bg-[var(--ds-color-overlay)] sm:hidden";
const NAV_BUTTON_CLASSES = TRIGGER_CLASSES;
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 DAY_BASE_CLASSES = "inline-flex items-center justify-center rounded-[var(--ds-radius-sm)] border border-transparent 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 flex-wrap items-center justify-between gap-2 border-t border-[var(--ds-color-border-subtle)] pt-2";
const PRESET_CLASSES = "inline-flex h-8 w-full items-center justify-start rounded-[var(--ds-radius-sm)] px-2 text-left text-sm leading-5 text-[var(--ds-color-foreground)] 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 PRESET_ACTIVE_CLASSES = "bg-[var(--ds-color-surface-active)] font-medium";
const ACTION_BUTTON_CLASSES = "inline-flex h-8 items-center justify-center gap-1.5 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-2.5 text-xs font-medium leading-4 text-[var(--ds-color-foreground)] 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)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const APPLY_BUTTON_CLASSES = "inline-flex h-8 items-center justify-center gap-1.5 rounded-[var(--ds-radius-sm)] border border-transparent bg-[var(--ds-color-primary)] px-2.5 text-xs font-medium leading-4 text-[var(--ds-color-primary-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,var(--ds-color-primary)_88%,#000)] 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 TIME_SELECT_CLASSES = "h-9 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-input)] px-2 text-sm leading-5 text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none";
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 PREVIEW_MIDDLE_CLASSES = "rounded-none bg-[var(--ds-color-surface-hover)] text-[var(--ds-color-foreground)]";
const PICKER_SELECTED_CLASSES = "bg-[var(--ds-color-primary)] font-medium text-[var(--ds-color-primary-foreground)]";
const SIZE_CLASSES = {
md: { cell: "size-9", text: "text-sm", weekday: "text-[11px]" },
lg: { cell: "size-11", text: "text-sm", weekday: "text-xs" }
};
function CalendarGlyphIcon() {
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">
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
<line x1="16" x2="16" y1="2" y2="6" />
<line x1="8" x2="8" y1="2" y2="6" />
<line x1="3" x2="21" y1="10" y2="10" />
</svg>;
}
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 firstDateOf(value) {
if (!value) return null;
return value instanceof Date ? value : value.from;
}
function DatePicker(props) {
const {
open: controlledOpen,
defaultOpen = false,
onOpenChange,
minDate,
maxDate,
disabledDates,
locale = "en-US",
weekStartsOn = 0,
formatDate: formatDateProp,
placeholder: placeholderProp,
disabled = false,
readOnly = false,
required = false,
name,
label,
description,
helperText,
error,
triggerLabel = "Open calendar",
inputAriaLabel,
requireApply = false,
withTime = false,
timeStep = 5,
defaultMonth,
numberOfMonths = 1,
defaultView = "days",
size = "md",
className,
children,
// Value props are consumed through the typed slices below — destructured
// here so they never leak onto the DOM via `...rest` (`onChange` also
// collides with the native React event handler).
mode: _mode,
value: _value,
defaultValue: _defaultValue,
onChange: _onChange,
...rest
} = props;
const mode = _mode ?? "single";
const singleSlice = props;
const rangeSlice = props;
const [singleValue, setSingleValue] = useControllableState(
mode === "single" ? singleSlice.value : undefined,
(mode === "single" ? singleSlice.defaultValue : null) ?? null,
mode === "single" ? singleSlice.onChange : undefined
);
const [rangeValue, setRangeValue] = useControllableState(
mode === "range" ? rangeSlice.value : undefined,
(mode === "range" ? rangeSlice.defaultValue : undefined) ?? null,
mode === "range" ? rangeSlice.onChange : undefined
);
const committedValue = mode === "single" ? singleValue : rangeValue;
const commitValue = useCallback(
(next) => {
if (mode === "single") setSingleValue(next === null || next instanceof Date ? next : next.from);
else setRangeValue(next instanceof Date ? { from: next, to: null } : next);
},
[mode, setSingleValue, setRangeValue]
);
const [open, setOpen] = useControllableState(controlledOpen, defaultOpen, onOpenChange);
const [today] = useState(() => /* @__PURE__ */ new Date());
const formatDate = useCallback(
(date) => {
if (formatDateProp) return formatDateProp(date);
return new Intl.DateTimeFormat(
locale,
withTime ? { dateStyle: "medium", timeStyle: "short", hourCycle: "h23" } : { dateStyle: "medium" }
).format(date);
},
[formatDateProp, locale, withTime]
);
const formatValue = useCallback(
(value) => {
if (!value) return "";
if (value instanceof Date) return formatDate(value);
if (!value.to) return `${formatDate(value.from)} \u2013 \u2026`;
return `${formatDate(value.from)} \u2013 ${formatDate(value.to)}`;
},
[formatDate]
);
const placeholder = placeholderProp ?? (mode === "range" ? "Select date range" : "Select date");
const resolvedInputAriaLabel = inputAriaLabel ?? (mode === "range" ? "Date range" : "Date");
const [monthState, setMonthState] = useState(
() => startOfMonth(defaultMonth ?? firstDateOf(committedValue) ?? today)
);
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(null);
const [hoveredDate, setHoveredDate] = useState(null);
const inputRef = useRef(null);
const triggerRef = useRef(null);
const panelRef = useRef(null);
const restoreTargetRef = useRef(null);
const restoreRequestedRef = useRef(false);
const isDisabled = useCallback(
(date) => {
if (minDate && compareDays(date, minDate) < 0) return true;
if (maxDate && compareDays(date, maxDate) > 0) return true;
return disabledDates ? disabledDates(date) : false;
},
[minDate, maxDate, disabledDates]
);
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 isRenderedDate = useCallback(
(date) => compareDays(date, month) >= 0 && compareDays(date, endOfMonth(lastVisibleMonth)) <= 0,
[month, lastVisibleMonth]
);
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]
);
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 requestOpen = useCallback(
(_origin) => {
if (disabled || readOnly || open) return;
restoreTargetRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setOpen(true);
},
[disabled, readOnly, open, setOpen]
);
const requestClose = useCallback(
(restoreFocus) => {
if (!open) return;
if (restoreFocus) restoreRequestedRef.current = true;
setOpen(false);
},
[open, setOpen]
);
const toggleOpen = useCallback(
(origin) => {
if (open) requestClose(true);
else requestOpen(origin);
},
[open, requestOpen, requestClose]
);
const committedRef = useRef(committedValue);
committedRef.current = committedValue;
useEffect(() => {
if (!open) return;
setHoveredDate(null);
setView(defaultView);
const selected = firstDateOf(committedRef.current);
const reveal = selected ? monthToReveal(selected) : month;
if (selected) goToMonth(reveal);
if (!restoreTargetRef.current) return;
if (defaultView === "months") {
requestFocus(`m${reveal.getMonth()}`);
return;
}
if (defaultView === "years") {
requestFocus(`y${reveal.getFullYear()}`);
return;
}
const renderedEnd = endOfMonth(startOfMonth(addMonths(reveal, numberOfMonths - 1)));
const rendered = (d) => compareDays(d, reveal) >= 0 && compareDays(d, renderedEnd) <= 0;
let target = null;
if (selected && rendered(selected) && !isDisabled(selected)) target = selected;
else if (rendered(today) && !isDisabled(today)) target = today;
else {
for (let i = 0; i < numberOfMonths && !target; i += 1) {
const gridMonth = startOfMonth(addMonths(reveal, i));
const count = daysInMonth(gridMonth.getFullYear(), gridMonth.getMonth());
for (let d = 0; d < count && !target; d += 1) {
const candidate = addDays(gridMonth, d);
if (!isDisabled(candidate)) target = candidate;
}
}
}
if (target) requestFocus(String(dayKey(target)));
}, [open]);
useEffect(() => {
if (open || !restoreRequestedRef.current) return;
restoreRequestedRef.current = false;
const target = restoreTargetRef.current;
restoreTargetRef.current = null;
if (target && target.isConnected) target.focus();
}, [open]);
const [draft, setDraft] = useState(committedValue);
useEffect(() => {
if (!open) setDraft(committedValue);
}, [open, committedValue]);
const stagedValue = requireApply ? draft : committedValue;
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 withCurrentTime = useCallback(
(day) => {
if (!withTime) return day;
const base = firstDateOf(stagedValue);
const hours = base ? base.getHours() : 12;
const minutes = base ? base.getMinutes() : 0;
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), hours, minutes);
},
[withTime, stagedValue]
);
const selectDate = useCallback(
(date) => {
if (isDisabled(date)) return;
const day = withCurrentTime(date);
if (mode === "single") {
if (requireApply) {
setDraft(day);
return;
}
if (singleValue && isSameDay(singleValue, day)) {
if (!withTime) requestClose(true);
return;
}
commitValue(day);
if (!withTime) requestClose(true);
return;
}
const current = stagedValue instanceof Date || stagedValue === null ? null : stagedValue;
let next;
if (!current || current.to) {
next = { from: day, to: null };
} else if (isSameDay(day, current.from)) {
next = { from: current.from, to: current.from };
} else if (compareDays(day, current.from) < 0 || rangeCrossesDisabled(current.from, day)) {
next = { from: day, to: null };
} else {
next = { from: current.from, to: day };
}
if (requireApply) setDraft(next);
else commitValue(next);
setHoveredDate(null);
if (next.to && !requireApply && !withTime) requestClose(true);
},
[
isDisabled,
withCurrentTime,
mode,
requireApply,
singleValue,
withTime,
requestClose,
commitValue,
stagedValue,
rangeCrossesDisabled
]
);
const clearValue = useCallback(() => {
commitValue(null);
setDraft(null);
setHoveredDate(null);
}, [commitValue]);
const canApply = mode === "single" || stagedValue === null || stagedValue instanceof Date || stagedValue.to !== null;
const applyDraft = useCallback(() => {
if (!canApply) return;
if (requireApply) commitValue(stagedValue);
requestClose(true);
}, [canApply, requireApply, commitValue, stagedValue, requestClose]);
const todayDisabled = isDisabled(today);
const selectToday = useCallback(() => {
if (todayDisabled) return;
goToMonth(monthToReveal(today));
requestFocus(String(dayKey(today)));
selectDate(today);
}, [todayDisabled, goToMonth, monthToReveal, today, selectDate]);
const applyPreset = useCallback(
(preset) => {
const value = preset.getValue(today);
const anchor = value instanceof Date ? value : value.from;
const matches = mode === "single" ? value instanceof Date : !(value instanceof Date);
if (!matches) return;
if (requireApply) setDraft(value);
else commitValue(value);
goToMonth(monthToReveal(anchor));
requestFocus(String(dayKey(anchor)));
if (!requireApply) requestClose(true);
},
[today, mode, requireApply, commitValue, goToMonth, monthToReveal, requestClose]
);
const isPresetActive = useCallback(
(preset) => {
const value = preset.getValue(today);
const current = stagedValue;
if (value instanceof Date) return current instanceof Date && isSameDay(value, current);
if (current === null || current instanceof Date || current.to === null) return false;
return isSameDay(value.from, current.from) && isSameDay(value.to ?? value.from, current.to);
},
[today, stagedValue]
);
const time = useMemo(() => {
if (!withTime) return null;
const base = firstDateOf(stagedValue);
return base ? { hours: base.getHours(), minutes: base.getMinutes() } : null;
}, [withTime, stagedValue]);
const setTime = useCallback(
(hours, minutes) => {
if (!withTime) return;
const base = stagedValue;
if (!base) return;
const merge = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), hours, minutes);
const next = base instanceof Date ? merge(base) : { from: merge(base.from), to: base.to ? merge(base.to) : null };
if (requireApply) setDraft(next);
else commitValue(next);
},
[withTime, stagedValue, requireApply, commitValue]
);
const rangeState = stagedValue instanceof Date || stagedValue === null ? null : stagedValue;
const isRangeStart = useCallback(
(date) => mode === "range" && rangeState !== null && rangeState.to !== null && !isSameDay(rangeState.from, rangeState.to) && isSameDay(rangeState.from, date),
[mode, rangeState]
);
const isRangeEnd = useCallback(
(date) => mode === "range" && rangeState !== null && rangeState.to !== null && !isSameDay(rangeState.from, rangeState.to) && isSameDay(rangeState.to, date),
[mode, rangeState]
);
const isRangeMiddle = useCallback(
(date) => mode === "range" && rangeState !== null && rangeState.to !== null && compareDays(date, rangeState.from) > 0 && compareDays(date, rangeState.to) < 0,
[mode, rangeState]
);
const isPreviewMiddle = useCallback(
(date) => {
if (mode !== "range" || !rangeState || rangeState.to !== null || !hoveredDate) return false;
if (compareDays(hoveredDate, rangeState.from) <= 0) return false;
return compareDays(date, rangeState.from) > 0 && compareDays(date, hoveredDate) < 0;
},
[mode, rangeState, hoveredDate]
);
const isSelected = useCallback(
(date) => {
if (mode === "single") return stagedValue instanceof Date && isSameDay(stagedValue, date);
return rangeState !== null && (isSameDay(rangeState.from, date) || rangeState.to !== null && compareDays(date, rangeState.from) > 0 && compareDays(date, rangeState.to) <= 0);
},
[mode, stagedValue, rangeState]
);
const handleDayClick = useCallback((date) => selectDate(date), [selectDate]);
const handleDayPointerEnter = useCallback(
(date) => {
if (mode === "range" && rangeState && rangeState.to === null) setHoveredDate(date);
},
[mode, rangeState]
);
const handleDayPointerLeave = useCallback(() => setHoveredDate(null), []);
const focusedDay = useMemo(() => {
if (focusKey && /^\d{8}$/.test(focusKey)) return dateFromKey(Number(focusKey));
return null;
}, [focusKey]);
const firstStagedDate = firstDateOf(stagedValue);
const tabbableDayKey = useMemo(() => {
const candidates = [focusedDay, firstStagedDate, 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, firstStagedDate, 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 = key;
setFocusKey(key);
}, []);
useEffect(() => {
if (!open || focusKey == null) return;
const panel = panelRef.current;
if (!panel) return;
const intent = focusIntentRef.current;
focusIntentRef.current = null;
if (!intent && !panel.contains(document.activeElement)) return;
const el = panel.querySelector(`[data-dp-focus="${intent ?? focusKey}"]`);
if (el && el !== document.activeElement) el.focus();
}, [open, 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 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 ?? firstStagedDate ?? 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, firstStagedDate, 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 baseId = useId().replace(/:/g, "");
const inputId = `${baseId}-input`;
const panelId = `${baseId}-panel`;
const descriptionId = description !== undefined ? `${baseId}-description` : null;
const hasError = typeof error === "string" && error.length > 0;
const messageId = hasError ? `${baseId}-error` : helperText !== undefined ? `${baseId}-helper` : null;
const describedBy = [descriptionId, messageId].filter(Boolean).join(" ") || undefined;
const isoValue = useMemo(() => {
if (!committedValue) return "";
if (committedValue instanceof Date) {
return withTime ? formatISODateTime(committedValue) : formatISODate(committedValue);
}
const from = formatISODate(committedValue.from);
const to = committedValue.to ? formatISODate(committedValue.to) : "";
return `${from}/${to}`;
}, [committedValue, withTime]);
const displayValue = formatValue(committedValue);
const contextValue = {
mode,
locale,
weekStartsOn,
size,
numberOfMonths,
withTime,
requireApply,
open,
requestOpen,
requestClose,
toggleOpen,
inputId,
panelId,
describedBy,
hasError,
disabled,
readOnly,
required,
placeholder,
triggerLabel,
inputAriaLabel: resolvedInputAriaLabel,
committedValue,
stagedValue,
displayValue,
formatValue,
view,
setView,
month,
today,
headingLabel,
goToPrevious,
goToNext,
canGoPrevious,
canGoNext,
previousLabel,
nextLabel,
isDisabled,
isSelected,
isRangeStart,
isRangeEnd,
isRangeMiddle,
isPreviewMiddle,
handleDayClick,
handleDayKeyDown,
handleDayPointerEnter,
handleDayPointerLeave,
tabbableDayKey,
monthEnabled,
yearEnabled,
monthActiveKey,
yearActiveKey,
yearPage,
selectMonth,
selectYear,
handleMonthKeyDown,
handleYearKeyDown,
setFocusKey,
clearValue,
applyDraft,
canApply,
selectToday,
todayDisabled,
applyPreset,
isPresetActive,
time,
setTime,
timeStep
};
const refsValue = useMemo(
() => ({ inputRef, triggerRef, panelRef }),
[]
);
const hasFieldChrome = label !== undefined || description !== undefined || helperText !== undefined || hasError;
const hiddenInput = name !== undefined && <input type="hidden" name={name} value={isoValue} disabled={disabled} readOnly />;
return <DatePickerContext.Provider value={contextValue}>
<DatePickerRefsContext.Provider value={refsValue}>
{hasFieldChrome ? <div className={cx("w-full max-w-xs space-y-1.5", className)} {...rest}>
{label !== undefined && <label
htmlFor={inputId}
className="block text-sm font-medium leading-5 text-[var(--ds-color-foreground)]"
>
{label}
{required && <span aria-hidden="true" className="text-[var(--ds-color-destructive)]">
{" *"}
</span>}
</label>}
{description !== undefined && <p
id={descriptionId ?? undefined}
className="m-0 text-xs leading-4 text-[var(--ds-color-muted-foreground)]"
>
{description}
</p>}
<div className="relative flex items-center gap-1">
{children}
{hiddenInput}
</div>
{hasError ? <p
id={messageId ?? undefined}
role="alert"
className="m-0 text-xs font-medium leading-4 text-[var(--ds-color-destructive)]"
>
{error}
</p> : helperText !== undefined && <p
id={messageId ?? undefined}
className="m-0 text-xs leading-4 text-[var(--ds-color-muted-foreground)]"
>
{helperText}
</p>}
</div> : <div className={cx("relative flex w-full max-w-xs items-center gap-1", className)} {...rest}>
{children}
{hiddenInput}
</div>}
</DatePickerRefsContext.Provider>
</DatePickerContext.Provider>;
}
function DatePickerInput({
className,
id,
onClick,
onKeyDown,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
...rest
}) {
const ctx = useDatePicker();
const refs = useContext(DatePickerRefsContext);
return <input
ref={refs.inputRef}
type="text"
readOnly
id={id ?? ctx.inputId}
value={ctx.displayValue}
placeholder={ctx.placeholder}
disabled={ctx.disabled}
aria-label={ariaLabel ?? ctx.inputAriaLabel}
aria-haspopup="dialog"
aria-expanded={ctx.open}
aria-controls={ctx.open ? ctx.panelId : undefined}
aria-required={ctx.required || undefined}
aria-invalid={ctx.hasError || undefined}
aria-describedby={cx(ariaDescribedBy, ctx.describedBy) || undefined}
onClick={(event) => {
if (!ctx.open) ctx.requestOpen("input");
onClick?.(event);
}}
onKeyDown={(event) => {
if (!ctx.open && (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ")) {
event.preventDefault();
ctx.requestOpen("input");
}
onKeyDown?.(event);
}}
className={cx(INPUT_CLASSES, ctx.hasError && INPUT_ERROR_CLASSES, className)}
{...rest}
/>;
}
function DatePickerTrigger({
className,
children,
onClick,
"aria-label": ariaLabel,
...rest
}) {
const ctx = useDatePicker();
const refs = useContext(DatePickerRefsContext);
return <button
ref={refs.triggerRef}
type="button"
aria-label={ariaLabel ?? ctx.triggerLabel}
aria-haspopup="dialog"
aria-expanded={ctx.open}
aria-controls={ctx.open ? ctx.panelId : undefined}
disabled={ctx.disabled || ctx.readOnly}
onClick={(event) => {
ctx.toggleOpen("trigger");
onClick?.(event);
}}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{children ?? <CalendarGlyphIcon />}
</button>;
}
function DatePickerContent({
mobileSheet = false,
className,
children,
onKeyDown,
...rest
}) {
const ctx = useDatePicker();
const refs = useContext(DatePickerRefsContext);
const [side, setSide] = useState("bottom");
const [align, setAlign] = useState("start");
useLayoutEffect(() => {
if (!ctx.open) return;
const measure = () => {
const panel = refs.panelRef.current;
const box = panel?.parentElement;
if (!panel || !box) return;
const field = box.getBoundingClientRect();
const panelHeight = panel.offsetHeight;
const panelWidth = panel.offsetWidth;
const margin = 8;
const fitsBelow = field.bottom + panelHeight + margin <= window.innerHeight;
const fitsAbove = field.top - panelHeight - margin >= 0;
setSide(!fitsBelow && fitsAbove ? "top" : "bottom");
setAlign(field.left + panelWidth + margin > window.innerWidth ? "end" : "start");
};
measure();
const refine = window.setTimeout(measure, 0);
return () => window.clearTimeout(refine);
}, [ctx.open, ctx.month, ctx.view]);
const requestClose = ctx.requestClose;
const open = ctx.open;
useEffect(() => {
if (!open) return;
const onKey = (event) => {
if (event.key === "Escape") requestClose(true);
};
const onPointerDown = (event) => {
const target = event.target;
if (!target) return;
const panel = refs.panelRef.current;
const input = refs.inputRef.current;
const trigger = refs.triggerRef.current;
if (panel && panel.contains(target)) return;
if (input && input.contains(target)) return;
if (trigger && trigger.contains(target)) return;
const focusable = target instanceof Element ? target.closest("button, a, input, select, textarea, [tabindex]") : null;
if (!focusable) event.preventDefault();
requestClose(!focusable);
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onPointerDown);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointerDown);
};
}, [open, requestClose, refs]);
if (!open) return null;
const dialogLabel = ctx.mode === "range" ? ctx.withTime ? "Choose date and time range" : "Choose date range" : ctx.withTime ? "Choose date and time" : "Choose date";
const handleTab = (event) => {
if (event.key !== "Tab") return;
const panel = refs.panelRef.current;
if (!panel) return;
const focusables = Array.from(
panel.querySelectorAll(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), [tabindex="0"]'
)
).filter((el) => el.getClientRects().length > 0);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) requestClose(false);
else if (!event.shiftKey && document.activeElement === last) requestClose(false);
};
return <>
{mobileSheet && <div aria-hidden="true" className={SHEET_OVERLAY_CLASSES} />}
<div
ref={refs.panelRef}
role="dialog"
aria-label={dialogLabel}
id={ctx.panelId}
onKeyDown={(event) => {
handleTab(event);
onKeyDown?.(event);
}}
className={cx(
PANEL_CLASSES,
side === "top" ? "bottom-full mb-1" : "top-full mt-1",
align === "end" ? "right-0" : "left-0",
mobileSheet && PANEL_SHEET_CLASSES,
className
)}
{...rest}
>
{children ?? <>
<DatePickerHeader />
<DatePickerCalendar />
</>}
</div>
</>;
}
function DatePickerHeader({ className, children, ...rest }) {
const ctx = useDatePicker();
return <div className={cx("mb-1 flex items-center justify-between gap-1", className)} {...rest}>
{children ?? <>
<button
type="button"
aria-label={ctx.previousLabel}
disabled={!ctx.canGoPrevious}
onClick={ctx.goToPrevious}
className={NAV_BUTTON_CLASSES}
>
<ChevronLeftIcon />
</button>
<h2 aria-live="polite" aria-atomic="true" className={cx(HEADING_CLASSES, "flex-1 text-center")}>
{ctx.view === "years" ? ctx.headingLabel : <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>
<button
type="button"
aria-label={ctx.nextLabel}
disabled={!ctx.canGoNext}
onClick={ctx.goToNext}
className={NAV_BUTTON_CLASSES}
>
<ChevronRightIcon />
</button>
</>}
</div>;
}
function DatePickerCalendar({ className, ...rest }) {
const ctx = useDatePicker();
if (ctx.view === "months") return <MonthsPanel className={className} />;
if (ctx.view === "years") return <YearsPanel className={className} />;
const months = Array.from(
{ length: ctx.numberOfMonths },
(_, i) => startOfMonth(addMonths(ctx.month, i))
);
return <div className={cx("flex flex-col gap-4 sm:flex-row", className)} {...rest}>
{months.map((gridMonth) => <MonthGrid key={dayKey(gridMonth)} gridMonth={gridMonth} />)}
</div>;
}
function MonthGrid({ gridMonth }) {
const ctx = useDatePicker();
const sizeClasses = SIZE_CLASSES[ctx.size];
const weeks = buildMonthWeeks(gridMonth, ctx.weekStartsOn);
const weekdays = weekdayNames(ctx.locale, ctx.weekStartsOn);
return <div role="grid" aria-label={monthYearLabel(gridMonth, ctx.locale)}>
<div role="row" className="flex">
{weekdays.map((day, i) => <div
role="columnheader"
key={i}
aria-label={day.long}
className={cx(
"flex items-center justify-center font-medium uppercase tracking-[0.04em] text-[var(--ds-color-muted-foreground)]",
sizeClasses.cell,
sizeClasses.weekday
)}
>
{day.short}
</div>)}
</div>
{weeks.map((week, rowIndex) => <div role="row" className="flex" key={rowIndex}>
{week.map((date) => <DayCell key={dayKey(date)} date={date} gridMonth={gridMonth} />)}
</div>)}
</div>;
}
function DayCell({ date, gridMonth }) {
const ctx = useDatePicker();
const sizeClasses = SIZE_CLASSES[ctx.size];
const outside = date.getMonth() !== gridMonth.getMonth();
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 previewMiddle = ctx.isPreviewMiddle(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={sizeClasses.cell}>
<button
type="button"
disabled={isDisabledDay}
tabIndex={ctx.tabbableDayKey === dayKey(date) ? 0 : -1}
data-dp-focus={key}
aria-label={fullDateLabel(date, ctx.locale)}
aria-current={isToday ? "date" : undefined}
onClick={() => ctx.handleDayClick(date)}
onPointerEnter={() => ctx.handleDayPointerEnter(date)}
onPointerLeave={ctx.handleDayPointerLeave}
onFocus={() => ctx.setFocusKey(key)}
onKeyDown={(event) => ctx.handleDayKeyDown(event, date)}
className={cx(
DAY_BASE_CLASSES,
sizeClasses.cell,
sizeClasses.text,
rangeMiddle && RANGE_MIDDLE_CLASSES,
!rangeMiddle && previewMiddle && PREVIEW_MIDDLE_CLASSES,
!rangeMiddle && !previewMiddle && filled && SELECTED_DAY_CLASSES,
!rangeMiddle && !previewMiddle && !filled && (outside ? "text-[var(--ds-color-muted-foreground)]" : "text-[var(--ds-color-foreground)]"),
!rangeMiddle && !previewMiddle && !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 = useDatePicker();
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-dp-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 = useDatePicker();
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-dp-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 DatePickerFooter({ className, children, ...rest }) {
return <div className={cx(FOOTER_CLASSES, className)} {...rest}>
{children}
</div>;
}
function DatePickerToday({
className,
children,
onClick,
...rest
}) {
const ctx = useDatePicker();
return <button
type="button"
disabled={ctx.todayDisabled}
onClick={(event) => {
ctx.selectToday();
onClick?.(event);
}}
className={cx(ACTION_BUTTON_CLASSES, className)}
{...rest}
>
{children ?? "Today"}
</button>;
}
function DatePickerClear({
className,
children,
onClick,
...rest
}) {
const ctx = useDatePicker();
return <button
type="button"
disabled={ctx.committedValue === null && ctx.stagedValue === null}
onClick={(event) => {
ctx.clearValue();
onClick?.(event);
}}
className={cx(ACTION_BUTTON_CLASSES, className)}
{...rest}
>
{children ?? "Clear"}
</button>;
}
function DatePickerApply({
className,
children,
onClick,
...rest
}) {
const ctx = useDatePicker();
return <button
type="button"
disabled={!ctx.canApply}
onClick={(event) => {
ctx.applyDraft();
onClick?.(event);
}}
className={cx(APPLY_BUTTON_CLASSES, className)}
{...rest}
>
{children ?? (ctx.requireApply ? "Apply" : "Done")}
</button>;
}
function DatePickerPresets({
presets,
label = "Date presets",
className,
...rest
}) {
const ctx = useDatePicker();
return <div
role="group"
aria-label={label}
className={cx("flex flex-row flex-wrap gap-1 sm:w-36 sm:flex-col", className)}
{...rest}
>
{presets.map((preset) => <button
key={preset.label}
type="button"
title={preset.description}
aria-current={ctx.isPresetActive(preset) ? "date" : undefined}
onClick={() => ctx.applyPreset(preset)}
className={cx(PRESET_CLASSES, ctx.isPresetActive(preset) && PRESET_ACTIVE_CLASSES)}
>
{preset.label}
</button>)}
</div>;
}
function DatePickerTime({ className, ...rest }) {
const ctx = useDatePicker();
const step = Math.max(1, Math.min(60, ctx.timeStep));
const minuteOptions = [];
for (let m = 0; m < 60; m += step) minuteOptions.push(m);
if (ctx.time && !minuteOptions.includes(ctx.time.minutes)) {
minuteOptions.push(ctx.time.minutes);
minuteOptions.sort((a, b) => a - b);
}
const noDate = ctx.time === null;
return <div className={cx("mt-2 border-t border-[var(--ds-color-border-subtle)] pt-3", className)} {...rest}>
<div className="flex items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.04em] text-[var(--ds-color-muted-foreground)]">
Hours
</span>
<select
value={ctx.time ? String(ctx.time.hours) : "12"}
disabled={noDate}
onChange={(event) => ctx.setTime(Number(event.target.value), ctx.time?.minutes ?? 0)}
className={TIME_SELECT_CLASSES}
>
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>
{String(h).padStart(2, "0")}
</option>)}
</select>
</label>
<span aria-hidden="true" className="pb-2 text-sm text-[var(--ds-color-muted-foreground)]">
:
</span>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.04em] text-[var(--ds-color-muted-foreground)]">
Minutes
</span>
<select
value={ctx.time ? String(ctx.time.minutes) : "0"}
disabled={noDate}
onChange={(event) => ctx.setTime(ctx.time?.hours ?? 12, Number(event.target.value))}
className={TIME_SELECT_CLASSES}
>
{minuteOptions.map((m) => <option key={m} value={m}>
{String(m).padStart(2, "0")}
</option>)}
</select>
</label>
{noDate && <p className="m-0 pb-2 text-xs leading-4 text-[var(--ds-color-muted-foreground)]">
Select a date to set the time.
</p>}
</div>
</div>;
}
export { daysInMonth, isLeapYear, compareDays, isSameDay, addDays, addMonths, startOfMonth, endOfMonth, buildMonthWeeks, formatISODate, formatISODateTime, useDatePicker, DatePicker, DatePickerInput, DatePickerTrigger, DatePickerContent, DatePickerHeader, DatePickerCalendar, DatePickerFooter, DatePickerToday, DatePickerClear, DatePickerApply, DatePickerPresets, DatePickerTime };
export default DatePicker; # Date Picker — with Disabled Dates
Constraint enforcement end to end: disabled weekends + a holiday hold + a min/max booking window. Disabled days are genuinely non-selectable — pointer, keyboard, and range completion all respect them.
## 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 and no positioning library** — the component ships its own small, typed date utilities (`addDays`, `addMonths`, `compareDays`, `isSameDay`, `daysInMonth`, `isLeapYear`, `buildMonthWeeks`, `startOfMonth`, `endOfMonth`, `formatISODate`, `formatISODateTime`), which are also exported for reuse.
The component consumes DevSnips `--ds-*` design tokens through Tailwind arbitrary values (for example `bg-[var(--ds-color-surface-elevated)]`). 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 MIN = new Date(2026, 7, 3);
const MAX = new Date(2026, 8, 25);
const HOLD = new Date(2026, 7, 15);
const isWeekend = (d) => d.getDay() === 0 || d.getDay() === 6;
const disabledDates = (d) => isWeekend(d) || isSameDay(d, HOLD);
<DatePicker
minDate={MIN}
maxDate={MAX}
disabledDates={disabledDates}
value={date}
onChange={setDate}
>
<DatePickerInput />
<DatePickerTrigger />
<DatePickerContent>
<DatePickerHeader />
<DatePickerCalendar />
<DatePickerFooter>
<p className="m-0 text-xs leading-4 text-[var(--ds-color-muted-foreground)]">Weekdays only.</p>
<DatePickerClear />
</DatePickerFooter>
</DatePickerContent>
</DatePicker>
```
## 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 MIN = new Date(2026, 7, 3);
const MAX = new Date(2026, 8, 25);
const HOLD = new Date(2026, 7, 15);
const isWeekend = (d) => d.getDay() === 0 || d.getDay() === 6;
const disabledDates = (d) => isWeekend(d) || isSameDay(d, HOLD);
<DatePicker
minDate={MIN}
maxDate={MAX}
disabledDates={disabledDates}
value={date}
onChange={setDate}
>
<DatePickerInput />
<DatePickerTrigger />
<DatePickerContent>
<DatePickerHeader />
<DatePickerCalendar />
<DatePickerFooter>
<p className="m-0 text-xs leading-4 text-[var(--ds-color-muted-foreground)]">Weekdays only.</p>
<DatePickerClear />
</DatePickerFooter>
</DatePickerContent>
</DatePicker>
```
## Props
### `<DatePicker>`
| Name | Type | Default | Description |
|---|---|---|---|
| `mode` | `"single" \| "range"` | `"single"` | Selection mode. Determines the shape of `value` / `defaultValue` / `onChange` (discriminated union). |
| `value` | `Date \| null` · `DateRange \| null` | — | The committed value (controlled). Shape follows `mode`. |
| `defaultValue` | same as `value` | `null` | Initial value (uncontrolled). |
| `onChange` | `(date) => void` · `(range) => void` | — | Called on every committed change. With `requireApply`, only Apply/Clear/Today/presets commit. |
| `open` | `boolean` | — | Popover open state (controlled). |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled). Never steals focus on mount. |
| `onOpenChange` | `(open: boolean) => void` | — | Called whenever the popover opens or closes. |
| `minDate` | `Date` | — | Earliest selectable calendar day (inclusive). |
| `maxDate` | `Date` | — | Latest selectable calendar day (inclusive). |
| `disabledDates` | `(date: Date) => boolean` | — | Matcher for individual disabled dates; composes with `minDate` / `maxDate`. |
| `locale` | `string` | `"en-US"` | BCP-47 tag for calendar labels and the default display format (`Intl.DateTimeFormat`). |
| `weekStartsOn` | `0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6` | `0` | First weekday column (0 = Sunday, 1 = Monday, …). |
| `formatDate` | `(date: Date) => string` | `Intl` medium (+ short time) | Display formatter for the committed value in the input. |
| `placeholder` | `string` | `"Select date"` / `"Select date range"` | Input placeholder. |
| `disabled` | `boolean` | `false` | Disables the control: no popover, not focusable, not form-submitted. |
| `readOnly` | `boolean` | `false` | Freezes the value: focusable + submitted, but the popover cannot open. |
| `required` | `boolean` | `false` | `aria-required` + required marker on the root-rendered label. |
| `name` | `string` | — | Form field name: a hidden input submits the ISO value (`yyyy-mm-dd`, `yyyy-mm-ddThh:mm` with `withTime`, `from/to` for ranges). |
| `label` | `string` | — | Label rendered by the root, associated via `htmlFor` + `id`. |
| `description` | `string` | — | Description above the control, referenced via `aria-describedby`. |
| `helperText` | `string` | — | Helper below the control (hidden while `error` is set), referenced via `aria-describedby`. |
| `error` | `string` | — | Error message: `role="alert"`, `aria-invalid` on the input, referenced via `aria-describedby`. |
| `triggerLabel` | `string` | `"Open calendar"` | Accessible name of the trigger button. |
| `inputAriaLabel` | `string` | `"Date"` / `"Date range"` | Accessible name of the input when no `label` is rendered. |
| `requireApply` | `boolean` | `false` | Stage selection as a draft; only `<DatePickerApply>` commits it. |
| `withTime` | `boolean` | `false` | Adds hour/minute selection; the popover stays open until Apply/Done. |
| `timeStep` | `number` | `5` | Minute step for the time controls (clamped to 1–60; the current value is always offered). |
| `defaultMonth` | `Date` | selection month, else today | Initial visible month (uncontrolled). |
| `numberOfMonths` | `number` | `1` | Consecutive month grids in the popover (stack below `sm`). |
| `defaultView` | `"days" \| "months" \| "years"` | `"days"` | Initial picker view. The heading button cycles days → months → years regardless. |
| `size` | `"md" \| "lg"` | `"md"` | 36px cells (`md`) or 44px touch targets (`lg`). |
| `className` | `string` | — | Extra classes on the field / wrapper. |
| `children` | `ReactNode` | — | The composed parts (input, trigger, content). |
### Parts
| Part | Props | Renders |
|---|---|---|
| `DatePickerInput` | native input attrs (forwarded) | The read-only display input (`aria-haspopup="dialog"`). |
| `DatePickerTrigger` | native button attrs (forwarded) | The icon toggle button. |
| `DatePickerContent` | `mobileSheet`, native div attrs | The `role="dialog"` popover panel; default chrome = header + calendar. |
| `DatePickerHeader` | native div attrs | Previous / heading (view cycle) / next navigation row. |
| `DatePickerCalendar` | native div attrs | One `role="grid"` per visible month, or the month/year picker panel. |
| `DatePickerFooter` | native div attrs | Hairline-separated action region. |
| `DatePickerPresets` | `presets: DatePickerPreset[]`, `label` | Real-button preset group; active preset tracked with `aria-current="date"`. |
| `DatePickerToday` / `DatePickerClear` / `DatePickerApply` | native button attrs | Footer actions (auto-disabled when unusable). |
| `DatePickerTime` | native div attrs | Hour/minute `<select>` section (disabled until a date exists). |
`useDatePicker()` returns the root context for composed children — value (`committedValue`, `stagedValue`), `formatValue`, `open`, actions (`clearValue`, `applyDraft`, `selectToday`, `applyPreset`), calendar helpers (`isDisabled`, `goToPrevious`/`goToNext`, …).
### 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)`, `buildMonthWeeks(month, weekStartsOn)`, `formatISODate(date)`, `formatISODateTime(date)` — small, typed, local-calendar-date helpers (the same model the Calendar family ships), exported for reuse.
## Compound Components
- `DatePicker` — the root provider. Owns the value (controlled `value` + `onChange`, or uncontrolled `defaultValue`), the open state (controlled `open` + `onOpenChange`, or uncontrolled `defaultOpen`), the visible month (uncontrolled, seeded by `defaultMonth`), the picker view, the roving focus key, the constraints (`minDate`, `maxDate`, `disabledDates`), the locale/format, and the staged draft under `requireApply`. Renders the field chrome (label / description / helper / error) when those props are set.
- `DatePickerInput` — the real text input (textually `readOnly`, so it stays keyboard-focusable and form-submittable without free-text parsing). Opens the popover on click / ArrowDown / Enter / Space. Carries `aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`, `aria-required`, `aria-invalid`, `aria-describedby`.
- `DatePickerTrigger` — the real icon button toggling the popover ("Open calendar", `aria-haspopup="dialog"` + `aria-expanded`).
- `DatePickerContent` — the popover panel (`role="dialog"`, non-modal). Handles Escape, outside pointer interaction, Tab leave, and the pre-paint viewport flip. Without children it renders the default `DatePickerHeader` + `DatePickerCalendar` chrome; pass children to compose presets / footer / time sections. `mobileSheet` docks it as a bottom sheet below `sm` (pure CSS).
- `DatePickerHeader` — the navigation row: previous / heading / next. The heading button cycles the days → months → years picker views.
- `DatePickerCalendar` — one `role="grid"` month matrix per visible month (`numberOfMonths`), or the month / year picker panel in those views.
- `DatePickerFooter` — a hairline-separated action / summary region.
- `DatePickerPresets` — a real-button preset group. Clicking a preset sets the actual value (staged under `requireApply`, committed otherwise).
- `DatePickerToday` — moves to and selects today (respecting constraints).
- `DatePickerClear` — clears the value AND the staged draft immediately.
- `DatePickerApply` — commits the staged draft and closes ("Apply"; without `requireApply` it is a "Done" button that just closes). Disabled while a range is incomplete.
- `DatePickerTime` — the hour/minute section (`withTime` only). Two real labelled `<select>` controls, disabled until a date exists.
`useDatePicker()` exposes the context to composed children (for example a staged-value readout in the footer).
## Controlled Usage
Pass `value` (+ `onChange`) to own the value, and/or `open` (+ `onOpenChange`) to own the popover:
```tsx
const [date, setDate] = useState<Date | null>(null);
const [open, setOpen] = useState(false);
<DatePicker value={date} onChange={setDate} open={open} onOpenChange={setOpen}>
<DatePickerInput />
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>
```
A component never mixes controlled and uncontrolled halves of one state: pass `value` to control it, `defaultValue` to seed it. The range mode's `value` is always a `DateRange | null` (`{ from: Date; to: Date | null }`) — never an ambiguous string.
## Uncontrolled Usage
Pass `defaultValue` / `defaultOpen` / `defaultMonth` to seed without owning state:
```tsx
<DatePicker defaultValue={new Date(2026, 7, 15)} onChange={logChange}>
<DatePickerInput />
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>
```
Selection still reports through `onChange` — uncontrolled only changes who stores the value.
## Date and Value Representation
The value model is a discriminated union on `mode` — TypeScript rejects a `value` shape that does not match the mode.
| Mode | `value` / `defaultValue` | `onChange` | Display |
|---|---|---|---|
| `single` (default) | `Date \| null` | `(date: Date \| null) => void` | One formatted date ("Aug 15, 2026"). Clicking the selected date is a no-op — never an accidental deselect; clearing is the explicit `DatePickerClear` / parent action. |
| `range` | `DateRange \| null` (`{ from: Date; to: Date \| null }`) | `(range: DateRange \| null) => void` | "from – to"; while incomplete (pointer or keyboard has only picked `from`) it reads "from – …". |
Dates are **local calendar dates**, never UTC timestamps. Identity and ordering use the numeric `dayKey` (`year*10000 + (month+1)*100 + day`), arithmetic uses `new Date(y, m, d + n)` constructor normalization — deterministic across DST transitions, leap years, and month/year boundaries. `Date` objects are never mutated. The form value (`name` prop) is a `yyyy-mm-dd` string built from local parts via `formatISODate` — `toISOString()` is never used anywhere (it converts to UTC and can shift the day). Range form values serialize as `from/to` (the `to` side empty while the range is incomplete); `withTime` values serialize as `yyyy-mm-ddThh:mm`.
The demo anchors a booking window (`minDate` = Aug 3, `maxDate` = Sep 25, 2026) plus a matcher that rejects weekends and the Aug 15 hold. Constraints are evaluated on every render — nothing is pre-filtered or string-compared.
## Validation
Constraint and validation behavior:
- `minDate` / `maxDate` are inclusive calendar-day boundaries. They disable day cells (native `disabled` — not focusable, not activatable), the navigation buttons when the target month/year/page holds no selectable day, the month/year picker options outside the range, and keyboard movement (arrows / PageUp / PageDown skip disabled dates and never land on one).
- `disabledDates` is a matcher `(date: Date) => boolean` composing with `minDate` / `maxDate` — a date is disabled when any rule rejects it. Disabled days stay visible with reduced opacity; they are genuinely non-selectable, not merely muted. Range completion across a disabled day is rejected (the click restarts the range instead of producing a range spanning an unselectable day).
- `error` sets `aria-invalid="true"` on the input and renders the message with `role="alert"`, referenced through `aria-describedby`. Clear the error when the user resolves it (the demos clear on change).
- `required` sets `aria-required="true"` and renders a required marker (visual only, `aria-hidden`) on the root-rendered label. Native `required` form validation does not apply — the display input is `readOnly` (barred from constraint validation) and the hidden input carries the ISO value — so validate in the form's submit handler, as the `date-picker-with-error` demo does.
- Selection can never bypass constraints: every selection path (click, Enter/Space, Today, presets) goes through the same `isDisabled` guard.
This variant is the constraint showcase: weekends + hold via `disabledDates`, outer window via `minDate` / `maxDate`. The navigation clamps at the window edges (the previous button disables at the earliest month), the month/year pickers disable out-of-window options, and keyboard movement skips disabled days automatically. Range-crossing guard: completing a range over a disabled day restarts the range.
## Popover Behavior
The popover is a non-modal `role="dialog"` panel (no focus trap, no scroll lock):
- Opens from the input (click, `ArrowDown`, `Enter`, `Space`) or the trigger button; closing restores focus to the element that opened it.
- `Escape` closes and restores focus. Outside pointer interaction closes — when the click landed on a non-focusable surface, focus returns to the opener; when it landed on another control, focus follows the click naturally.
- `Tab` / `Shift+Tab` leave the panel from either end and close it — focus is never trapped and the natural order continues.
- In `single` mode the popover closes after selection; in `range` mode it stays open until the range completes; with `requireApply` / `withTime` it stays open until Apply/Done.
- The panel flips above the field when the space below runs out, and pins to the field's right edge when it would overflow the right viewport edge (pre-paint measurement, class-driven — no positioning library, no inline styles). `mobileSheet` docks it as a full-width bottom sheet below the `sm` breakpoint with a dimmed overlay, purely with `max-sm:` Tailwind variants — no JavaScript viewport detection.
## Keyboard Interaction
| Key | Input (closed) | Day grid | Month picker | Year picker |
|---|---|---|---|---|
| `ArrowDown` / `ArrowUp` | Opens the popover | Same weekday, previous / next week | Same month ± 1 year-quarter row (± 3) | ± 3 years |
| `ArrowLeft` / `ArrowRight` | — | Previous / next day | Previous / next month | Previous / next year |
| `Enter` / `Space` | Opens the popover | Select the focused date | Choose the month | Choose the year |
| `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 | — | — |
| `Escape` | — | Close the popover, restore focus to the opener | same | same |
| `Tab` / `Shift+Tab` | Natural order | Leaves / closes the popover — exactly one tabbable day cell (roving tabindex) | same | same |
Focus model: roving `tabIndex` — only one cell in the grid is tabbable at a time (the focused date, else the staged/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. The previous/next buttons, the heading button, footer actions, and preset buttons are ordinary tab stops with visible focus rings.
## Accessibility
The popover follows the WAI-ARIA date-picker dialog pattern (a non-modal dialog containing a date grid — NOT the menu pattern):
- The input is a real `<input type="text">` (textually read-only) with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` (referenced only while open). The trigger is a real `<button>`. There are no clickable divs and no nested interactive elements.
- The panel is `role="dialog"` with an accessible name ("Choose date" / "Choose date range" / "Choose date and time"), containing a `role="grid"` day matrix (`role="row"`, `role="columnheader"` weekday labels with full names in `aria-label`, `role="gridcell"` carrying `aria-selected`).
- Every day is a real `<button>` with a full locale-aware accessible name ("Friday, August 15, 2026") — never a bare number.
- Today carries `aria-current="date"` — distinguishable from the *selected* date (`aria-selected` + filled treatment), and today is never auto-selected.
- Disabled dates use native `disabled`; the month/year heading is `aria-live="polite"`. Selected / hover / today / range start / range middle / range end / range hover preview / disabled are distinguished by more than color alone (fill + weight, squared range edges, border + weight, opacity).
- Field wiring is real: root-rendered `label` uses `htmlFor` + `id`; `description` / `helperText` / `error` register their generated ids in the input's `aria-describedby`; the error uses `role="alert"`; `required` uses `aria-required`; `aria-invalid` tracks the error state. State is never communicated by color alone.
Disabled days remain visible with reduced opacity and carry native `disabled` — assistive technology reports them as unavailable, and they leave the tab order entirely. Constraints are never communicated by color alone (opacity + cursor + native semantics).
## Responsive Behavior
A single month grid is 252px wide at the default `md` size (7 × 36px cells; 308px at `lg` 44px cells) and fits a 375px viewport. The panel's width is `max-w-[calc(100vw-1rem)]` and long localized dates wrap in the input instead of breaking the layout. With `numberOfMonths: 2` (and with presets) the popover content stacks vertically below `sm` instead of overflowing. The `mobileSheet` presentation docks the panel full-width at the bottom of the viewport below `sm`. Every variant is verified overflow-free at 375 / 768 / 1280px, open and closed.
## 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-elevated)]`). 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 date picker follows the Calendar / Date Picker token rules: neutral surfaces, strong typography, thin borders, restrained radius (`radius-sm` controls, `radius-md` panel), compact 36px controls (44px at `size="lg"`), a clear selected state, a 2px `focus-ring` ring, `surface-elevated` + `shadow-md` for the floating panel, `color.overlay` for the mobile sheet backdrop, and light/dark parity.
## Notes
Composability matters: a single `isDisabled` predicate merges `minDate`, `maxDate`, and the matcher, and every selection path (click, keyboard, Today, presets) consults it. Matchers receive local `Date` objects — decide with `getDay()` / `isSameDay`, not string parsing.
## Limitations
- The display input is intentionally read-only: there is no free-text date parsing (locale ambiguity and invalid-date handling belong to a dedicated masked-input component). Pick from the grid, the month/year pickers, Today, or presets.
- The visible month is uncontrolled (`defaultMonth` seeds it); there is no controlled `month` prop — month navigation is internal and resets to reveal the selection on each open.
- `withTime` covers hour/minute selection in 24-hour presentation (`hourCycle: "h23"`); seconds, 12-hour dials, and timezone selection are out of scope.
- `required` is conveyed accessibly (`aria-required` + marker) but native `required` constraint validation does not apply to the read-only display input — validate in the form submit handler (see the error variant).
- Range + `withTime` is supported by the core (time applies to both endpoints) but is not a shipped variant; the presets variant demonstrates the canonical range + presets composition. 2193 lines UTF-8 · LF · Spaces: 2
Continue browsing