"use client";
import * as Popover from "@radix-ui/react-popover";
import { ArrowUpDown, Calendar, ChevronLeft, ChevronRight } from "lucide-react";
import * as React from "react";
import {
DayPicker,
type DateRange,
type DayPickerProps,
type Matcher,
} from "react-day-picker";
import { useMdUp } from "../../hooks";
import { cn } from "../../utils/cn";
import { Button } from "./button";
import { FieldWrapper } from "./field-wrapper";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./select";
import type { SortDirection } from "./sort-column-item";
// ============================================================================
// Types
// ============================================================================
export type DatePickerMode = "single" | "range";
export interface DatePickerBaseProps {
/** Placeholder text when no date is selected */
placeholder?: string;
/** Format function for displaying the date */
formatDate?: (date: Date) => string;
/** Whether the picker is disabled */
disabled?: boolean;
/** Additional class name for the trigger button */
className?: string;
/** Number of months to display */
numberOfMonths?: 1 | 2;
/** Minimum selectable date */
fromDate?: Date;
/** Maximum selectable date */
toDate?: Date;
/** Locale for formatting */
locale?: DayPickerProps["locale"];
/** Label text displayed above the picker */
label?: string;
/** Error message displayed below the picker */
error?: string;
/** When true, renders red error border */
invalid?: boolean;
}
export interface SingleDatePickerProps extends DatePickerBaseProps {
mode: "single";
value?: Date;
onChange?: (date: Date | undefined) => void;
}
export interface RangeDatePickerProps extends DatePickerBaseProps {
mode: "range";
value?: DateRange;
onChange?: (range: DateRange | undefined) => void;
}
export type DatePickerProps = SingleDatePickerProps | RangeDatePickerProps;
// ============================================================================
// Helper functions
// ============================================================================
const defaultFormatDate = (date: Date): string => {
return date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
};
const formatDateRange = (
range: DateRange | undefined,
formatFn: (date: Date) => string
): string => {
if (!range?.from) return "";
if (!range.to) return formatFn(range.from);
return `${formatFn(range.from)} - ${formatFn(range.to)}`;
};
// ============================================================================
// Calendar Components
// ============================================================================
interface CalendarNavButtonProps {
direction: "left" | "right";
onClick?: () => void;
/** Set at a `fromDate` / `toDate` bound — there is nothing selectable past it. */
disabled?: boolean;
"aria-label"?: string;
}
/** The DS icon button, at its standard size in every placement — month
* navigation is a primary control, not chrome to be shrunk to fit. What has
* to give instead is the calendar's WIDTH: a `bare` host states one wide
* enough for the caption to sit between the two buttons (see the meeting
* scheduler's `CALENDAR_W`). */
function CalendarNavButton({ direction, onClick, disabled, "aria-label": ariaLabel }: CalendarNavButtonProps) {
return (
: }
/>
);
}
// ============================================================================
// DatePickerCalendar Component
// ============================================================================
export interface DatePickerCalendarProps {
mode: DatePickerMode;
selected: Date | DateRange | undefined;
onSelect: (value: Date | DateRange | undefined) => void;
numberOfMonths?: 1 | 2;
fromDate?: Date;
toDate?: Date;
locale?: DayPickerProps["locale"];
/**
* When true the calendar fills its container width and day cells flex to
* fit (instead of the fixed 40px cells). Used by DateFilterMenu so the grid
* lines up with the surrounding controls per the Figma filter-menu.
*/
fluid?: boolean;
/**
* The calendar ALONE — the Figma `Date Picker` component as drawn, with no
* card surface and no outer inset: the nav header flush to both edges, 8px,
* then a full-bleed grid. Seven columns divide the container, so the
* cells are as wide as the host makes them.
*
* FIXED 272px tall at every one of those widths (8 + the 48px icon-button
* header + a 24px weekday strip + six 32px rows; 268 on a phone, where the
* DS icon button is 44), which is the design's own contract — the
* component is drawn 240 tall at 340, 282 and 240 wide. Height that ignored
* the width is what lets a host place it in a card that states a height, and
* keeps a phone-width month from becoming a 356px band.
*
* The DAY inside each cell stays square, so the selection and today's tint
* read as rounded squares rather than as pills stretched across a wide
* cell.
*
* For hosts that place the calendar inside a panel they already own — the
* meeting scheduler — where the standalone card chrome reads as a card
* inside a card and its 16px inset fights the panel's own 24px one. The
* popover and filter-menu keep the surface; nothing about the type, colours
* or day states changes between the two.
*/
bare?: boolean;
/**
* Days to disable ON TOP of the `fromDate`/`toDate` bounds — a matcher, so a
* host can disable by predicate. "Inside the allowed window" and "actually
* selectable" are different questions, and only the host can answer the
* second (the meeting scheduler greys out days with no bookable slot).
*/
disabledDays?: Matcher | Matcher[];
/**
* Controlled visible month; pair with `onMonthChange`. Omit both and the
* calendar owns its month, which is the right default — a host needs these
* only when paging is ALSO a data event, e.g. the scheduler refetches
* availability per month and would otherwise show an unpopulated month.
*/
month?: Date;
onMonthChange?: (month: Date) => void;
}
/**
* The calendar surface behind every date control in the design system —
* `DatePicker`'s popover and `DateFilterMenu` — exported so a host that needs
* an always-visible day grid renders THIS one. A second hand-styled day grid
* is how the two drift apart.
*/
export function DatePickerCalendar({
mode,
selected,
onSelect,
numberOfMonths = 1,
fromDate,
toDate,
locale,
fluid = false,
bare = false,
disabledDays: extraDisabledDays,
month: monthProp,
onMonthChange,
}: DatePickerCalendarProps) {
const today = new Date();
const isMdUp = useMdUp() ?? true;
const monthsToShow = isMdUp ? numberOfMonths : 1;
const [draftRange, setDraftRange] = React.useState(
mode === "range" ? (selected as DateRange | undefined) : undefined
);
const [hoveredDate, setHoveredDate] = React.useState(undefined);
// Keep the internal range in sync when the consumer changes `selected`
// externally (e.g. DateFilterMenu Reset while the popover stays open).
React.useEffect(() => {
if (mode === "range") {
setDraftRange(selected as DateRange | undefined);
}
}, [mode, selected]);
const rangeSelected = draftRange;
const hasCompleteRange =
mode === "range" &&
!!rangeSelected?.from &&
!!rangeSelected?.to &&
rangeSelected.from.getTime() !== rangeSelected.to.getTime();
const isPreviewDate = (date: Date): boolean => {
if (!draftRange?.from || draftRange.to || !hoveredDate) return false;
const start = Math.min(draftRange.from.getTime(), hoveredDate.getTime());
const end = Math.max(draftRange.from.getTime(), hoveredDate.getTime());
return date.getTime() >= start && date.getTime() <= end;
};
const handleRangeSelect = (triggerDate: Date | undefined): void => {
if (!triggerDate) return;
if (!draftRange?.from || draftRange.to) {
// First click starts the range — propagate it so consumers can already
// act on a single-day selection (e.g. DateFilterMenu Apply/Reset).
const started: DateRange = { from: triggerDate, to: undefined };
setDraftRange(started);
onSelect(started);
return;
}
// Second click closes the range, ordering the two ends.
const start = draftRange.from;
const completed: DateRange =
triggerDate.getTime() < start.getTime()
? { from: triggerDate, to: start }
: { from: start, to: triggerDate };
setDraftRange(completed);
setHoveredDate(undefined);
onSelect(completed);
};
// Fixed 40px cells by default; in fluid mode cells flex to fill the width.
// `bare` is a fluid layout by definition — its cell size IS container/7.
const isFluid = fluid || bare;
// BARE: fixed row height, elastic width — the Figma component is 240px tall
// at every width it is drawn at (340 on a phone, 282 on a tablet, 240 on the
// desktop card), so a month occupies the same band whatever column it lands
// in. 8 + the 48px header + a 24px weekday strip + six 32px rows = 272.
//
// Height that does NOT follow the width is the whole point: seven square
// cells across a phone's 340px would be a 356px-tall month, half again what
// the design budgets, and the same rule handed a wide column a month tall
// enough to push a card that states its height.
const cellOuter = bare ? "flex-1 min-w-0 h-[32px]" : isFluid ? "flex-1 aspect-square min-w-0" : "size-10";
// ...but the DAY ITSELF stays square inside that wider cell: `h-full` gives
// the button the row's height and `aspect-square` turns it into a 32x32
// rounded square, centred by the cell. `w-auto` is load-bearing — it drops
// the width the button would otherwise take, leaving `aspect-square` inert.
//
// This is why the day STATES below are painted on the button in bare mode
// rather than on the cell: react-day-picker puts `selected`/`today` on the
// cell, and a cell 48px wide by 30 tall renders the selection as a stretched
// pill. Same colours, same radius, smaller box.
const cellInner = bare
? "h-full w-auto aspect-square rounded-[6px]"
: isFluid
? "size-full"
: "size-10";
// The weekday strip is a LABEL row, not a day: `bare` gives it the height of
// its own text rather than a full row.
const weekdayOuter = bare ? "flex-1 min-w-0 h-6" : cellOuter;
// Day states, per paint target (see `cellInner`). Range mode is never bare —
// a range MUST fill its cells or the middle would break into islands — so
// only these three single-day states fork.
const hoverClass = bare ? "[&>button]:hover:bg-ods-bg-surface" : "hover:bg-ods-bg-surface hover:rounded-[6px]";
const todayClass = bare
? "[&>button]:bg-ods-bg-surface [&>button]:hover:!bg-ods-bg-surface"
: "bg-ods-bg-surface rounded-[6px] hover:!bg-ods-bg-surface";
/**
* Today's tint applies only while today is NOT part of the selection.
*
* Both states paint the same box, and both have to use `!important` to beat
* react-day-picker's concatenated base classes — so on hover the two rules
* had equal specificity and the winner came down to their order in the
* generated stylesheet. Today's tint won, and a selected today turned GREY
* under the pointer, as if it had been deselected.
*
* Deciding it here rather than in CSS: "today" is a hint about where you
* are in the month, and once that day is chosen the selection is the
* stronger statement — there is nothing left for the tint to say.
*/
const dayNumber = (date: Date): number => date.getFullYear() * 10000 + date.getMonth() * 100 + date.getDate();
const isSelectedDay = (date: Date): boolean => {
const n = dayNumber(date);
if (mode === "single") {
const picked = selected as Date | undefined;
return !!picked && dayNumber(picked) === n;
}
const range = rangeSelected;
if (!range?.from) return false;
if (!range.to) return dayNumber(range.from) === n;
return n >= dayNumber(range.from) && n <= dayNumber(range.to);
};
const isUnclaimedToday = (date: Date): boolean => dayNumber(date) === dayNumber(today) && !isSelectedDay(date);
const selectedClass = bare
? "[&>button]:!bg-ods-accent [&>button]:!text-ods-card [&>button]:!font-bold [&>button]:hover:!bg-ods-accent"
: "!bg-ods-accent !text-ods-card !font-bold !rounded-[6px] hover:!bg-ods-accent";
// Surface + inset, per placement. Bare drops the card and the 16px inset
// (the host's panel provides both) and replaces the inset with the 8px the
// design puts between the header and the grid — the ONLY spacing it keeps.
const surfaceClass = bare
? "flex w-full flex-col"
: cn("bg-ods-card border border-ods-border rounded-[6px] overflow-hidden", isFluid && "w-full");
// Bare: an unpadded header row over the grid, 8px apart — the calendar's
// whole height is the two of them added up, never the container's.
const captionRowClass = cn("flex items-center justify-between gap-1", bare ? "shrink-0" : "px-4 pt-4");
// The caption is the only elastic thing in the header: it takes the space
// the two fixed buttons leave and truncates rather than pushing into them.
// "September 2026" is ~131px at text-h4, and the two 48px buttons plus their
// gaps take 104 — so a `bare` host has to state a width of ~256 or more, or
// the label and the next-month button collide (worse in a locale with longer
// month names).
const captionClass = "min-w-0 flex-1 truncate text-center text-h4 text-ods-text-primary";
const classNames: DayPickerProps["classNames"] = {
root: cn(
"date-picker-calendar",
bare ? "flex w-full flex-col pt-[var(--spacing-system-xsf)]" : cn("p-4", isFluid && "w-full"),
),
months: cn("flex gap-8", bare && "w-full"),
month: cn("flex flex-col", bare ? "w-full" : cn("gap-2", isFluid && "w-full")),
month_caption: "hidden",
nav: "hidden",
month_grid: cn("border-collapse", bare ? "flex w-full flex-col" : isFluid && "w-full"),
weekdays: "flex",
...(bare ? { weeks: "flex flex-col" } : {}),
weekday: cn(
weekdayOuter,
"flex items-center justify-center",
"text-h6 text-ods-text-secondary"
),
week: "flex",
day: cn(
cellOuter,
"flex items-center justify-center",
"text-h4 text-ods-text-primary",
"cursor-pointer",
"transition-colors duration-150",
hoverClass
),
day_button: cn(
cellInner,
"flex items-center justify-center",
"cursor-pointer bg-transparent border-none outline-none",
"text-inherit font-inherit"
),
// Painted through the `todayTint` modifier instead — see `isUnclaimedToday`.
today: "",
selected: cn(
selectedClass,
// In range mode, selected class should not override range_start/range_end/range_middle
mode === "range" && "range-selected"
),
outside: "text-ods-border opacity-50 hover:!bg-transparent",
// Out of bounds: greyed, un-hoverable, and not-allowed under the cursor.
//
// Every rule here is `!`, and the inner button is targeted explicitly —
// neither is decoration. react-day-picker CONCATENATES the modifier's classes
// onto the base `day` ones (no tailwind-merge), so `text-ods-text-disabled`
// and `text-ods-text-primary` both land on the cell and the winner comes down
// to their order in the generated stylesheet, not in the attribute; without
// `!` the disabled day rendered in full white, indistinguishable from a
// selectable one. The cursor needs the child selector on top of that: it is
// the `day_button` INSIDE the cell that carries `cursor-pointer`, and a
// `disabled`