'use client' import * as React from 'react' import { format } from 'date-fns' import { Calendar, type CalendarProps, type DateRange, type Matcher } from '../calendar/calendar' import { DateField } from '../date-field/date-field' import { TimeField } from '../time-field/time-field' import { Input, inputVariants } from '../input' import { Popover, PopoverContent, PopoverTrigger, type PopoverContentProps } from '../popover/popover' import { buttonVariants } from '../button/button-variants' import { useFieldRootContext } from '@base-ui/react/internals/field-root-context' import { cn } from '../../internal/utils' type DatePickerSize = 'sm' | 'md' | 'lg' type DatePickerVariant = 'outline' | 'soft' type DatePickerMode = 'single' | 'range' | 'multiple' type DistributiveOmit = T extends unknown ? Omit : never type CalendarPassthrough = DistributiveOmit< CalendarProps, | 'mode' | 'selected' | 'onSelect' | 'month' | 'onMonthChange' | 'size' | 'classNames' | 'components' | 'className' | 'disabled' > type DatePickerBase = CalendarPassthrough & { /** * Height, padding, and calendar cell scale. * @default 'md' */ size?: DatePickerSize /** * Field appearance - bordered or filled. * @default 'outline' */ variant?: DatePickerVariant /** * date-fns format for the date field(s). * @default 'MM/dd/yyyy' */ dateFormat?: string /** * date-fns format for the time field(s). * @default 'HH:mm:ss' */ timeFormat?: string /** * Show a clear button (multiple mode). * @default false */ clearable?: boolean /** Content pinned to the start edge. */ startSlot?: React.ReactNode /** Content pinned to the end edge. */ endSlot?: React.ReactNode /** * Disable the whole control. * @default false */ disabled?: boolean /** Dates that can't be selected in the calendar. */ disabledDates?: Matcher | Matcher[] /** * Make the typeable fields read-only. * @default false */ readOnly?: boolean /** * Mark the form field as required. * @default false */ required?: boolean /** * Hidden input name for form submission. `range` mode emits `name[from]` / `name[to]`; with `showTime` the value is a * full datetime. */ name?: string /** Flag the field invalid - sets `data-invalid` for styling + conveys it to assistive tech. */ 'aria-invalid'?: boolean /** Classes on the root wrapper - use for footprint/layout (e.g. `max-w-60`). Merged via `tailwind-merge`. */ className?: string /** * Classes on the inner field box - use to override field styling (border, radius, background). Merged via * `tailwind-merge`. */ inputClassName?: string /** * Separator between the range's two fields. * @default '–' */ rangeSeparator?: React.ReactNode /** Controlled open state. Pair with `onOpenChange`. */ open?: boolean /** * Uncontrolled initial open state. * @default false */ defaultOpen?: boolean /** Fires when the popover opens or closes. */ onOpenChange?: (open: boolean) => void /** * Preferred popover side. * @default 'bottom' */ side?: 'top' | 'bottom' | 'left' | 'right' /** * Popover alignment. * @default 'end' */ align?: 'start' | 'center' | 'end' /** * Gap between the field and the popover. * @default 6 */ sideOffset?: number /** Escape hatch forwarded to the inner `PopoverContent` (collision props, `className`, …). */ popoverProps?: Partial /** * Override auto-close. By default only single mode closes on pick; range and multiple stay open (dismiss on * outside-click/Escape). * @default auto */ closeOnSelect?: boolean /** * Add a `TimeField` (single/range modes). * @default false */ showTime?: boolean /** * Icon for the popover trigger button. * @default calendar icon */ triggerIcon?: React.ReactNode /** * Accessible label for the trigger button. * @default 'Open calendar' */ triggerAriaLabel?: string } type DatePickerProps = | (DatePickerBase & { mode?: 'single' value?: Date | undefined defaultValue?: Date | undefined onValueChange?: (v: Date | undefined) => void }) | (DatePickerBase & { mode: 'range' value?: DateRange | undefined defaultValue?: DateRange | undefined onValueChange?: (v: DateRange | undefined) => void }) | (DatePickerBase & { /** * Selection behavior; determines the `value` shape. * @default 'single' */ mode: 'multiple' /** Controlled value; shape matches `mode`. Pair with `onValueChange`. */ value?: Date[] | undefined /** Uncontrolled initial value. */ defaultValue?: Date[] | undefined /** Fires when the selection changes; the argument shape matches `mode`. */ onValueChange?: (v: Date[] | undefined) => void /** Placeholder for the multiple-mode summary field. */ placeholder?: string /** Customize the multiple-mode summary text. */ formatValue?: (value: Date[] | undefined) => string }) const DEFAULT_TRIGGER_ICON = ( ) const TRIGGER_BUTTON_OVERRIDES: Record = { sm: 'size-6 rounded-xs -me-1.25', md: 'size-8 rounded-sm -me-1.75', lg: 'size-10 rounded-md -me-2.25', } const TRIGGER_BUTTON_SIZES: Record = { sm: 'icon-sm', md: 'icon-md', lg: 'icon-lg', } function DatePicker(props: DatePickerProps) { const { size = 'md', mode = 'single', variant = 'outline', value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, showTime = false, dateFormat = 'MM/dd/yyyy', timeFormat = 'HH:mm:ss', placeholder, clearable, startSlot, endSlot, disabled: disabledProp, disabledDates, readOnly, required, name: nameProp, side = 'bottom', align = 'end', sideOffset = 6, popoverProps, closeOnSelect, rangeSeparator = '–', triggerIcon = DEFAULT_TRIGGER_ICON, triggerAriaLabel = 'Open calendar', formatValue, 'aria-invalid': _ariaInvalid, className, inputClassName, ...calendarProps } = props as DatePickerBase & { mode?: DatePickerMode value?: Date | Date[] | DateRange defaultValue?: Date | Date[] | DateRange onValueChange?: (v: unknown) => void placeholder?: string formatValue?: (value: Date[] | undefined) => string } const field = useFieldRootContext(true) const disabled = disabledProp || field.disabled const name = nameProp ?? field.name const invalid = props['aria-invalid'] === true || field.invalid === true const wasControlledRef = React.useRef(value !== undefined) if (value !== undefined) wasControlledRef.current = true const isValueControlled = wasControlledRef.current const [internalValue, setInternalValue] = React.useState(defaultValue) const currentValue = isValueControlled ? value : internalValue const isOpenControlled = open !== undefined const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false) const currentOpen = isOpenControlled ? open : internalOpen const [calendarMonth, setCalendarMonth] = React.useState(() => { const fromValue = deriveCalendarMonth(mode, defaultValue ?? value) if (fromValue) return fromValue return (calendarProps as { defaultMonth?: Date }).defaultMonth }) const derivedMonth = deriveCalendarMonth(mode, currentValue) const lastDerivedKeyRef = React.useRef(monthKey(derivedMonth)) React.useEffect(() => { const key = monthKey(derivedMonth) if (key !== lastDerivedKeyRef.current) { lastDerivedKeyRef.current = key if (derivedMonth) setCalendarMonth(derivedMonth) } }, [derivedMonth]) const setOpen = React.useCallback( (next: boolean) => { if (!isOpenControlled) setInternalOpen(next) onOpenChange?.(next) }, [isOpenControlled, onOpenChange], ) const setValue = React.useCallback( (next: Date | Date[] | DateRange | undefined) => { if (!isValueControlled) setInternalValue(next) onValueChange?.(next) if (shouldAutoClose(mode, next, closeOnSelect)) setOpen(false) }, [isValueControlled, mode, closeOnSelect, onValueChange, setOpen], ) const handleDateFieldChange = (nextDate: Date | null) => { if (mode !== 'single') return if (!nextDate) { setValue(undefined) return } setValue(mergeDateAndTime(nextDate, currentValue as Date | undefined)) } const handleTimeFieldChange = (nextTime: string | null) => { if (mode !== 'single' || !showTime) return const cur = currentValue as Date | undefined if (!cur) return setValue(applyTimeString(cur, nextTime)) } const handleRangeFromDateChange = (nextDate: Date | null) => { if (mode !== 'range') return const cur = currentValue as DateRange | undefined const merged = nextDate ? mergeDateAndTime(nextDate, cur?.from) : undefined setValue({ from: merged, to: cur?.to }) } const handleRangeToDateChange = (nextDate: Date | null) => { if (mode !== 'range') return const cur = currentValue as DateRange | undefined const merged = nextDate ? mergeDateAndTime(nextDate, cur?.to) : undefined setValue({ from: cur?.from, to: merged }) } const handleRangeFromTimeChange = (nextTime: string | null) => { if (mode !== 'range' || !showTime) return const cur = currentValue as DateRange | undefined if (!cur?.from) return setValue({ from: applyTimeString(cur.from, nextTime), to: cur.to }) } const handleRangeToTimeChange = (nextTime: string | null) => { if (mode !== 'range' || !showTime) return const cur = currentValue as DateRange | undefined if (!cur?.to) return setValue({ from: cur.from, to: applyTimeString(cur.to, nextTime) }) } const handleCalendarSelect = (nextRaw: Date | Date[] | DateRange | undefined) => { if (mode === 'single' && nextRaw instanceof Date) { setValue(mergeDateAndTime(nextRaw, currentValue as Date | undefined)) } else if (mode === 'range' && nextRaw && !(nextRaw instanceof Date) && !Array.isArray(nextRaw)) { const cur = currentValue as DateRange | undefined const r = nextRaw as DateRange setValue({ from: r.from ? mergeDateAndTime(r.from, cur?.from) : undefined, to: r.to ? mergeDateAndTime(r.to, cur?.to) : undefined, }) } else { setValue(nextRaw) } } const clearAll = React.useCallback(() => setValue(undefined), [setValue]) const anchorRef = React.useRef(null) const triggerButton = ( ( )} /> ) const sharedFieldProps = { variant, size, disabled, readOnly, required, 'aria-invalid': invalid || undefined, } as const let fields: React.ReactNode if (mode === 'single') { const date = currentValue as Date | undefined if (showTime) { fields = ( ) } else { fields = ( {endSlot} {triggerButton} } className={inputClassName} {...sharedFieldProps} /> ) } } else if (mode === 'range') { const range = currentValue as DateRange | undefined fields = ( {showTime && ( )} {showTime && ( )} ) } else { const days = currentValue as Date[] | undefined const display = formatValue ? formatValue(days) : defaultMultipleFormat(days, dateFormat) fields = ( 0} onClear={clearAll} startSlot={startSlot} endSlot={ <> {endSlot} {triggerButton} } disabled={disabled} required={required} name={name} aria-invalid={invalid || undefined} className={inputClassName} /> ) } let hiddenInputs: React.ReactNode = null if (name && mode === 'single') { hiddenInputs = } else if (name && mode === 'range') { const r = currentValue as DateRange | undefined hiddenInputs = ( <> ) } return (
{fields} {hiddenInputs}
)} month={calendarMonth} onMonthChange={setCalendarMonth} disabled={disabledDates} />
) } interface UnifiedWrapperProps { variant: DatePickerVariant size: DatePickerSize disabled?: boolean ariaInvalid?: boolean className?: string startSlot?: React.ReactNode endSlot?: React.ReactNode trigger: React.ReactNode children: React.ReactNode } function UnifiedWrapper({ variant, size, disabled, ariaInvalid, className, startSlot, endSlot, trigger, children, }: UnifiedWrapperProps) { return (
{startSlot ? (
{startSlot}
) : null} {children} {endSlot ? (
{endSlot}
) : null}
{trigger}
) } function shouldAutoClose( mode: DatePickerMode, next: Date | Date[] | DateRange | undefined, override?: boolean, ): boolean { if (override !== undefined) return override if (mode === 'single') return next instanceof Date return false } function mergeDateAndTime(nextDate: Date, withTime: Date | undefined): Date { if (!withTime) return nextDate const out = new Date(nextDate) out.setHours(withTime.getHours(), withTime.getMinutes(), withTime.getSeconds(), withTime.getMilliseconds()) return out } function toFormValue(date: Date | undefined, showTime: boolean): string { if (!date) return '' return format(date, showTime ? "yyyy-MM-dd'T'HH:mm:ss" : 'yyyy-MM-dd') } function applyTimeString(base: Date, timeStr: string | null): Date { const out = new Date(base) if (!timeStr) { out.setHours(0, 0, 0, 0) return out } const [h, m, s] = timeStr.split(':').map((n) => Number(n) || 0) out.setHours(h ?? 0, m ?? 0, s ?? 0, 0) return out } function deriveCalendarMonth(mode: DatePickerMode, value: Date | Date[] | DateRange | undefined): Date | undefined { if (!value) return undefined if (mode === 'single') return value instanceof Date ? value : undefined if (mode === 'range') { const r = value as DateRange return r.from ?? r.to ?? undefined } if (mode === 'multiple') { const days = value as Date[] return days[days.length - 1] } return undefined } function monthKey(d: Date | undefined): string { if (!d) return '' return `${d.getFullYear()}-${d.getMonth()}` } function defaultMultipleFormat(days: Date[] | undefined, dateFormat: string): string { if (!days || days.length === 0) return '' const first = format(days[0]!, dateFormat) return days.length === 1 ? first : `${first} (+${days.length - 1} more)` } export { DatePicker } export type { DatePickerProps, DateRange }