UIPackage
Menu

Date Picker

date-picker ui
Edit on GitHub

Date input that opens a Calendar in a Popover. Handles parsing, formatting, min/max bounds, and disabled dates. Use the Range Calendar version for from/to selections.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/date-picker.json
Named registry: npx shadcn@latest add @uipkge-react/date-picker Installs to: components/ui/date-picker/

Examples

Props

Name Type / Values Default Required
value

Controlled value. Shape depends on `type`. ISO `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm` when `showTime`).

SingleValue | MultipleValue | RangeValue optional
defaultValue

Uncontrolled initial value.

SingleValue | MultipleValue | RangeValue optional
onValueChange (value: SingleValue | MultipleValue | RangeValue) => void optional
type DatePickerType optional
placeholder string optional
disabled boolean optional
readOnly boolean optional
clearable boolean optional
format

Format for the trigger label. String presets or Intl.DateTimeFormatOptions. Ignored when `showTime`.

FormatValue optional
dateFormat

Backward-compat alias for `format`.

FormatValue optional
locale string optional
numberOfMonths number optional
weekStartsOn 0 | 1 | 2 | 3 | 4 | 5 | 6 optional
fixedWeeks boolean optional
minValue string | Date optional
maxValue string | Date optional
layout

Header layout: control which parts (month/year) become dropdowns.

DatePickerLayout optional
picker

Granularity of selection (single type only). - `day`: pick a day from the calendar grid. - `week`: pick a full week — value is the week's start date. - `month`: pick a whole month — value snaps to the 1st. - `quarter`: pick a quarter — value snaps to quarter start. - `year`: pick a whole year — value snaps to Jan 1.

DatePickerPicker optional
showCurrentDate

Show "Today" shortcut at popover top (single + day picker only).

boolean optional
needConfirm

Require clicking OK before applying the selected value.

boolean optional
status

Validation status — applies colored border to the trigger.

DatePickerStatus optional
size

Size variant of the trigger input.

DatePickerSize optional
placement

Placement of the popover relative to the trigger.

DatePickerPlacement optional
showTime

Pair the calendar with a time selector. Value becomes `YYYY-MM-DDTHH:mm`.

boolean optional
showSeconds

Show seconds column in time picker. Only used when `showTime`.

boolean optional
use24Hour

24h vs AM/PM column. Only used when `showTime`.

boolean optional
minuteStep

Minute step for the time column. Only used when `showTime`.

number optional
secondStep

Second step for the time column. Only used when `showTime` and `showSeconds`.

number optional
defaultTime

Default time for newly picked dates when `showTime` and no prior selection. `HH:mm` or `HH:mm:ss`.

string optional
presets

Preset shortcuts for quick selection.

DatePickerPreset[] optional
separator

Custom separator between range start and end dates.

string optional
disabledDate

Function to determine if a specific date should be disabled.

(current: Date) => boolean optional
disabledTime

Function to determine if specific times should be disabled. Only used when `showTime`.

(current?: Date) => DisabledTimeResult optional
renderCell

Custom cell renderer for day calendar cells.

(day: Date) => React.ReactNode optional
triggerClassName string optional
className string optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

RangeValue
type RangeValue { start: string | Date; end?: string | Date } | null

export interface DatePickerPreset {
  label: string
  value: SingleValue | MultipleValue | RangeValue
  category?: string
}
DisabledTimeResult
interface DisabledTimeResult {
  disabledHours?: () => number[]
  disabledMinutes?: (selectedHour: number) => number[]
  disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[]
}
TimeShape
type TimeShape { h: number; m: number; s: number }

export type InternalSingle = Date | undefined
export type InternalMultiple = Date[]
export type InternalRange = { start?: Date; end?: Date }

export function coerceDate(v: string | Date | null | undefined): Date | null {
  if (!v) return null
  if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(v)
  if (m) {
    const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
    return Number.isNaN(d.getTime()) ? null : d
  }
  const dt = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(v)
  if (dt) {
    const d = new Date(
      Number(dt[1]),
      Number(dt[2]) - 1,
      Number(dt[3]),
      Number(dt[4]),
      Number(dt[5]),
      dt[6] ? Number(dt[6]) : 0,
    )
    return Number.isNaN(d.getTime()) ? null : d
  }
  const d = new Date(v)
  return Number.isNaN(d.getTime()) ? null : d
}

Files installed (3)

  • components/ui/date-picker/date-picker.tsx 37 kB
    'use client'
    
    import * as React from 'react'
    import { Calendar as CalendarIcon, X } from 'lucide-react'
    import { DayButton as RdpDayButton, type DateRange, type Matcher } from 'react-day-picker'
    import { Button } from '@/components/ui/button'
    import { Calendar } from '@/components/ui/calendar'
    import { RangeCalendar } from '@/components/ui/range-calendar'
    import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
    import { TimeColumns } from '@/components/ui/time-picker'
    import { cn } from '@/lib/utils'
    import {
      coerceDate,
      coerceShape,
      defaultRangePresets,
      fmtDate,
      fmtMonth,
      fmtTime,
      stripTime,
      toISODate,
      toISODateTime,
      parseTimeShape,
      weekNumber,
      weekStart,
      withTime,
      type DatePickerLayout,
      type DatePickerPicker,
      type DatePickerPlacement,
      type DatePickerPreset,
      type DatePickerSize,
      type DatePickerStatus,
      type DatePickerType,
      type DisabledTimeResult,
      type FormatValue,
      type InternalMultiple,
      type InternalRange,
      type InternalSingle,
      type MultipleValue,
      type RangeValue,
      type SingleValue,
      type TimeShape,
    } from './date-picker-utils'
    
    export type {
      DatePickerType,
      DatePickerLayout,
      DatePickerPicker,
      DatePickerStatus,
      DatePickerSize,
      DatePickerPlacement,
      FormatValue,
      SingleValue,
      MultipleValue,
      RangeValue,
      DatePickerPreset,
      DisabledTimeResult,
    } from './date-picker-utils'
    
    type InternalValue = InternalSingle | InternalMultiple | InternalRange | undefined
    
    export interface DatePickerProps {
      /** Controlled value. Shape depends on `type`. ISO `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm` when `showTime`). */
      value?: SingleValue | MultipleValue | RangeValue
      /** Uncontrolled initial value. */
      defaultValue?: SingleValue | MultipleValue | RangeValue
      onValueChange?: (value: SingleValue | MultipleValue | RangeValue) => void
      type?: DatePickerType
      placeholder?: string
      disabled?: boolean
      readOnly?: boolean
      clearable?: boolean
      /** Format for the trigger label. String presets or Intl.DateTimeFormatOptions. Ignored when `showTime`. */
      format?: FormatValue
      /** Backward-compat alias for `format`. */
      dateFormat?: FormatValue
      locale?: string
      numberOfMonths?: number
      weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
      fixedWeeks?: boolean
      minValue?: string | Date
      maxValue?: string | Date
      /** Header layout: control which parts (month/year) become dropdowns. */
      layout?: DatePickerLayout
      /**
       * Granularity of selection (single type only).
       * - `day`: pick a day from the calendar grid.
       * - `week`: pick a full week — value is the week's start date.
       * - `month`: pick a whole month — value snaps to the 1st.
       * - `quarter`: pick a quarter — value snaps to quarter start.
       * - `year`: pick a whole year — value snaps to Jan 1.
       */
      picker?: DatePickerPicker
      /** Show "Today" shortcut at popover top (single + day picker only). */
      showCurrentDate?: boolean
      /** Require clicking OK before applying the selected value. */
      needConfirm?: boolean
      /** Validation status — applies colored border to the trigger. */
      status?: DatePickerStatus
      /** Size variant of the trigger input. */
      size?: DatePickerSize
      /** Placement of the popover relative to the trigger. */
      placement?: DatePickerPlacement
      /** Pair the calendar with a time selector. Value becomes `YYYY-MM-DDTHH:mm`. */
      showTime?: boolean
      /** Show seconds column in time picker. Only used when `showTime`. */
      showSeconds?: boolean
      /** 24h vs AM/PM column. Only used when `showTime`. */
      use24Hour?: boolean
      /** Minute step for the time column. Only used when `showTime`. */
      minuteStep?: number
      /** Second step for the time column. Only used when `showTime` and `showSeconds`. */
      secondStep?: number
      /** Default time for newly picked dates when `showTime` and no prior selection. `HH:mm` or `HH:mm:ss`. */
      defaultTime?: string
      /** Preset shortcuts for quick selection. */
      presets?: DatePickerPreset[]
      /** Custom separator between range start and end dates. */
      separator?: string
      /** Function to determine if a specific date should be disabled. */
      disabledDate?: (current: Date) => boolean
      /** Function to determine if specific times should be disabled. Only used when `showTime`. */
      disabledTime?: (current?: Date) => DisabledTimeResult
      /** Custom cell renderer for day calendar cells. */
      renderCell?: (day: Date) => React.ReactNode
      triggerClassName?: string
      className?: string
    }
    
    const QUARTER_LABELS = ['Q1', 'Q2', 'Q3', 'Q4']
    const QUARTER_MONTHS = [1, 4, 7, 10] as const
    
    function compareDates(a: Date, b: Date): number {
      return stripTime(a).getTime() - stripTime(b).getTime()
    }
    
    function fmtDateTime(
      d: Date,
      locale: string,
      format: FormatValue,
      showSeconds: boolean,
      use24Hour: boolean,
    ) {
      return `${fmtDate(d, locale, format)} ${fmtTime(d, showSeconds, use24Hour)}`.trim()
    }
    
    function quarterStart(year: number, quarterIdx: number): Date {
      return new Date(year, QUARTER_MONTHS[quarterIdx]! - 1, 1)
    }
    
    function weekAnchorForMonth(year: number, month: number, weekStartsOn: number): Date {
      return weekStart(new Date(year, month - 1, 1), weekStartsOn)
    }
    
    function layoutToCaptionLayout(
      layout: DatePickerLayout,
    ): 'dropdown' | 'dropdown-months' | 'dropdown-years' | undefined {
      if (layout === 'month-and-year') return 'dropdown'
      if (layout === 'month-only') return 'dropdown-months'
      if (layout === 'year-only') return 'dropdown-years'
      return undefined
    }
    
    function rangeFromCalendar(r: DateRange | undefined): InternalRange | undefined {
      if (!r?.from) return undefined
      return r.to ? { start: r.from, end: r.to } : { start: r.from }
    }
    
    function rangeToCalendar(r: InternalRange | undefined): DateRange | undefined {
      if (!r?.start) return undefined
      return { from: stripTime(r.start), to: r.end ? stripTime(r.end) : undefined }
    }
    
    function getLastTime(
      internal: InternalValue,
      type: DatePickerType,
      defaultTime: string,
    ): TimeShape {
      if (type === 'single' && internal instanceof Date) {
        if (internal.getHours() || internal.getMinutes() || internal.getSeconds()) {
          return { h: internal.getHours(), m: internal.getMinutes(), s: internal.getSeconds() }
        }
      }
      if (type === 'range') {
        const r = internal as InternalRange
        if (
          r?.start &&
          (r.start.getHours() || r.start.getMinutes() || r.start.getSeconds())
        ) {
          return { h: r.start.getHours(), m: r.start.getMinutes(), s: r.start.getSeconds() }
        }
      }
      return parseTimeShape(defaultTime, { h: 12, m: 0, s: 0 })
    }
    
    const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
      (
        {
          value,
          defaultValue = null,
          onValueChange,
          type = 'single',
          placeholder = 'Pick a date',
          disabled = false,
          readOnly = false,
          clearable = true,
          format = 'medium',
          dateFormat,
          locale = 'en-US',
          numberOfMonths,
          weekStartsOn = 0,
          fixedWeeks = false,
          minValue,
          maxValue,
          layout = 'default',
          picker = 'day',
          showCurrentDate = false,
          needConfirm = false,
          status,
          size = 'middle',
          placement = 'bottomLeft',
          showTime = false,
          showSeconds = false,
          use24Hour = false,
          minuteStep = 5,
          secondStep = 1,
          defaultTime = '12:00',
          presets,
          separator = '~',
          disabledDate,
          disabledTime,
          renderCell,
          triggerClassName,
          className,
        },
        ref,
      ) => {
        const [open, setOpen] = React.useState(false)
        const [previewValue, setPreviewValue] = React.useState<InternalValue>(undefined)
        const isControlled = value !== undefined
        const [internalState, setInternalState] = React.useState<InternalValue>(() =>
          coerceShape(type, defaultValue ?? null),
        )
    
        const internal = isControlled ? coerceShape(type, value) : internalState
        const activeValue = needConfirm ? (previewValue ?? internal) : internal
    
        const effectiveFormat = dateFormat ?? format
        const effectiveNumberOfMonths = numberOfMonths ?? (type === 'range' ? 2 : 1)
        const effectivePresets = React.useMemo(
          () => presets ?? (type === 'range' ? defaultRangePresets() : undefined),
          [presets, type],
        )
    
        const presetGroups = React.useMemo(() => {
          const groups = new Map<string | undefined, DatePickerPreset[]>()
          for (const p of effectivePresets ?? []) {
            const cat = p.category
            if (!groups.has(cat)) groups.set(cat, [])
            groups.get(cat)!.push(p)
          }
          return Array.from(groups.entries()).map(([category, presetList]) => ({
            category,
            presets: presetList,
          }))
        }, [effectivePresets])
    
        const minDate = React.useMemo(() => coerceDate(minValue) ?? undefined, [minValue])
        const maxDate = React.useMemo(() => coerceDate(maxValue) ?? undefined, [maxValue])
    
        const [monthYearAnchor, setMonthYearAnchor] = React.useState<Date>(() => {
          const v = coerceShape(type, defaultValue ?? null)
          if (v instanceof Date) return v
          return stripTime(new Date())
        })
    
        React.useEffect(() => {
          if (!open) setPreviewValue(undefined)
        }, [open])
    
        React.useEffect(() => {
          if (open) {
            const v = internal
            if (v instanceof Date) setMonthYearAnchor(v)
            else setMonthYearAnchor(stripTime(new Date()))
          }
        }, [open, internal])
    
        function serializeDate(d: Date) {
          return showTime ? toISODateTime(d, showSeconds) : toISODate(d)
        }
    
        function emitOut(v: InternalValue) {
          if (type === 'multiple') {
            const arr = (v as InternalMultiple) ?? []
            onValueChange?.(arr.map(serializeDate))
            return
          }
          if (type === 'range') {
            const r = v as InternalRange | undefined
            if (!r?.start || !r?.end) return
            onValueChange?.({ start: serializeDate(r.start), end: serializeDate(r.end) })
            return
          }
          const single = v as InternalSingle
          onValueChange?.(single ? serializeDate(single) : null)
        }
    
        function handleUpdate(v: InternalValue) {
          if (!isControlled) setInternalState(v)
          emitOut(v)
          if (needConfirm) return
          if (type === 'single' && v && !showTime) setOpen(false)
          if (type === 'range') {
            const r = v as InternalRange | undefined
            if (r?.start && r?.end && !showTime) setOpen(false)
          }
        }
    
        function clear(event: React.SyntheticEvent) {
          event.stopPropagation()
          if (disabled || readOnly) return
          if (type === 'multiple') handleUpdate([])
          else handleUpdate(undefined)
        }
    
        function applyPreset(preset: DatePickerPreset) {
          if (disabled || readOnly) return
          const v = preset.value
          if (!v) {
            handleUpdate(undefined)
            setOpen(false)
            return
          }
          if (type === 'multiple') {
            const arr = (v as MultipleValue) ?? []
            handleUpdate(arr.map(coerceDate).filter((x): x is Date => x != null))
            setOpen(false)
            return
          }
          if (type === 'range') {
            const r = v as RangeValue
            if (!r?.start) {
              handleUpdate(undefined)
              setOpen(false)
              return
            }
            const start = coerceDate(r.start)
            const end = r.end ? coerceDate(r.end) : undefined
            if (!start) {
              handleUpdate(undefined)
              setOpen(false)
              return
            }
            handleUpdate(end ? { start, end } : { start })
            setOpen(false)
            return
          }
          handleUpdate(coerceDate(v as SingleValue) ?? undefined)
          setOpen(false)
        }
    
        function commitPreview() {
          if (needConfirm && previewValue !== undefined) {
            handleUpdate(previewValue)
            setPreviewValue(undefined)
          }
          setOpen(false)
        }
    
        function cancelPreview() {
          setPreviewValue(undefined)
          setOpen(false)
        }
    
        const display = React.useMemo(() => {
          const v = internal
          if (!v) return ''
          if (type === 'multiple') {
            const arr = v as InternalMultiple
            if (!arr.length) return ''
            if (arr.length === 1) return fmtDate(arr[0]!, locale, effectiveFormat)
            if (arr.length <= 3) return arr.map((d) => fmtDate(d, locale, effectiveFormat)).join(', ')
            return `${arr.length} dates selected`
          }
          if (type === 'range') {
            const r = v as InternalRange
            const fmt = showTime
              ? (d: Date) => fmtDateTime(d, locale, effectiveFormat, showSeconds, use24Hour)
              : (d: Date) => fmtDate(d, locale, effectiveFormat)
            if (r.start && r.end) return `${fmt(r.start)} ${separator} ${fmt(r.end)}`
            if (r.start) return `${fmt(r.start)} ${separator}`
            return ''
          }
          if (picker === 'week') {
            const ws = weekStart(v as Date, weekStartsOn)
            return `Week ${weekNumber(ws)}, ${ws.getFullYear()}`
          }
          if (picker === 'month') return fmtMonth(v as Date, locale)
          if (picker === 'quarter') {
            const d = v as Date
            const q = Math.ceil((d.getMonth() + 1) / 3)
            return `Q${q} ${d.getFullYear()}`
          }
          if (picker === 'year') return String((v as Date).getFullYear())
          return showTime
            ? fmtDateTime(v as Date, locale, effectiveFormat, showSeconds, use24Hour)
            : fmtDate(v as Date, locale, effectiveFormat)
        }, [
          internal,
          type,
          locale,
          effectiveFormat,
          showTime,
          showSeconds,
          use24Hour,
          separator,
          picker,
          weekStartsOn,
        ])
    
        const hasValue = React.useMemo(() => {
          const v = internal
          if (!v) return false
          if (type === 'multiple') return (v as InternalMultiple).length > 0
          if (type === 'range') return Boolean((v as InternalRange).start)
          return true
        }, [internal, type])
    
        const lastTime = React.useMemo(
          () => getLastTime(internal, type, defaultTime),
          [internal, type, defaultTime],
        )
    
        function handleCalendarUpdate(v: unknown) {
          if (readOnly) return
          if (needConfirm) {
            if (type === 'multiple') {
              const arr = (v as Date[]) ?? []
              setPreviewValue(arr.map((d) => (showTime ? withTime(d, lastTime) : d)))
            } else if (type === 'range') {
              const r = rangeFromCalendar(v as DateRange | undefined)
              if (!r?.start) setPreviewValue(undefined)
              else {
                const start = showTime ? withTime(r.start, lastTime) : r.start
                const end = r.end ? (showTime ? withTime(r.end, lastTime) : r.end) : undefined
                setPreviewValue(end ? { start, end } : { start })
              }
            } else {
              const d = v as Date | undefined
              setPreviewValue(d ? (showTime ? withTime(d, lastTime) : d) : undefined)
            }
            return
          }
          if (!showTime) {
            if (type === 'range') handleUpdate(rangeFromCalendar(v as DateRange | undefined))
            else if (type === 'multiple') handleUpdate((v as Date[]) ?? [])
            else handleUpdate(v as InternalSingle)
            return
          }
          if (type === 'multiple') {
            const arr = (v as Date[]) ?? []
            handleUpdate(arr.map((d) => withTime(d, lastTime)))
            return
          }
          if (type === 'range') {
            const r = rangeFromCalendar(v as DateRange | undefined)
            if (!r?.start) return handleUpdate(undefined)
            const start = withTime(r.start, lastTime)
            const end = r.end ? withTime(r.end, lastTime) : undefined
            handleUpdate(end ? { start, end } : { start })
            return
          }
          const d = v as Date | undefined
          if (d) handleUpdate(withTime(d, lastTime))
        }
    
        function handleTimeUpdate(timeValue: string) {
          if (!internal) return
          const t = parseTimeShape(timeValue, lastTime)
          if (type === 'single') {
            handleUpdate(withTime(internal as Date, t))
            return
          }
          if (type === 'range') {
            const r = internal as InternalRange
            if (!r.start) return
            const start = withTime(r.start, t)
            const end = r.end ? withTime(r.end, t) : undefined
            handleUpdate(end ? { start, end } : { start })
          }
        }
    
        const timeForColumns = showSeconds
          ? `${String(lastTime.h).padStart(2, '0')}:${String(lastTime.m).padStart(2, '0')}:${String(lastTime.s).padStart(2, '0')}`
          : `${String(lastTime.h).padStart(2, '0')}:${String(lastTime.m).padStart(2, '0')}`
    
        const timeFormat = showSeconds ? 'HH:mm:ss' : 'HH:mm'
    
        const disabledTimeConfig = React.useMemo(() => {
          if (!disabledTime) return undefined
          let current: Date | undefined
          if (type === 'range') {
            const r = activeValue as InternalRange | undefined
            current = r?.start
          } else if (activeValue instanceof Date) {
            current = activeValue
          } else if (Array.isArray(activeValue)) {
            current = activeValue[0]
          }
          return disabledTime(current)
        }, [disabledTime, activeValue, type])
    
        const calendarDisabled = React.useMemo(() => {
          const matchers: Matcher[] = []
          if (minDate) matchers.push({ before: stripTime(minDate) })
          if (maxDate) matchers.push({ after: stripTime(maxDate) })
          if (disabledDate) matchers.push(disabledDate)
          return matchers.length ? matchers : undefined
        }, [minDate, maxDate, disabledDate])
    
        const calendarValue = React.useMemo(() => {
          const v = internal
          if (type === 'multiple') return (v as InternalMultiple | undefined)?.map(stripTime)
          if (type === 'range') return rangeToCalendar(v as InternalRange | undefined)
          if (v instanceof Date) return stripTime(v)
          return undefined
        }, [internal, type])
    
        const captionLayout = layoutToCaptionLayout(layout)
    
        const calendarComponents = React.useMemo(() => {
          if (!renderCell) return undefined
          return {
            DayButton: (props: React.ComponentProps<typeof RdpDayButton>) => {
              const { day, children: _children, ...rest } = props
              return (
                <RdpDayButton day={day} modifiers={props.modifiers} {...rest}>
                  {renderCell(day.date)}
                </RdpDayButton>
              )
            },
          }
        }, [renderCell])
    
        const placementMap: Record<
          DatePickerPlacement,
          { side: 'top' | 'bottom' | 'left' | 'right'; align: 'start' | 'center' | 'end' }
        > = {
          top: { side: 'top', align: 'center' },
          bottom: { side: 'bottom', align: 'center' },
          left: { side: 'left', align: 'center' },
          right: { side: 'right', align: 'center' },
          topLeft: { side: 'top', align: 'start' },
          topRight: { side: 'top', align: 'end' },
          bottomLeft: { side: 'bottom', align: 'start' },
          bottomRight: { side: 'bottom', align: 'end' },
        }
        const popoverPlacement = placementMap[placement] ?? { side: 'bottom', align: 'start' }
    
        const buttonSize = size === 'small' ? 'sm' : size === 'large' ? 'lg' : 'default'
    
        const triggerClasses = cn(
          showTime ? 'min-w-[280px]' : 'min-w-[240px]',
          'justify-start gap-2 text-left font-normal',
          !hasValue && 'text-muted-foreground',
          status === 'error' && 'border-destructive focus-visible:ring-destructive',
          status === 'warning' && 'border-warning focus-visible:ring-warning',
          triggerClassName,
          className,
        )
    
        function pickToday() {
          if (type !== 'single') return
          const t = stripTime(new Date())
          if (picker === 'week') {
            handleUpdate(weekStart(t, weekStartsOn))
            return
          }
          handleUpdate(t)
        }
    
        function shiftAnchor(dir: -1 | 1) {
          const y = monthYearAnchor.getFullYear()
          const m = monthYearAnchor.getMonth() + 1
          if (picker === 'month' || picker === 'week') {
            setMonthYearAnchor(new Date(y + dir, m - 1, 1))
          } else if (picker === 'year' || picker === 'quarter') {
            setMonthYearAnchor(new Date(y + dir, 0, 1))
          }
        }
    
        const monthLabels = React.useMemo(
          () =>
            Array.from({ length: 12 }, (_, idx) =>
              new Intl.DateTimeFormat(locale, { month: 'short' }).format(new Date(2024, idx, 1)),
            ),
          [locale],
        )
    
        const yearGrid = React.useMemo(() => {
          const y = monthYearAnchor.getFullYear()
          const start = y - (y % 12)
          return Array.from({ length: 12 }, (_, i) => start + i)
        }, [monthYearAnchor])
    
        const weekGrid = React.useMemo(() => {
          const y = monthYearAnchor.getFullYear()
          const m = monthYearAnchor.getMonth() + 1
          const start = weekAnchorForMonth(y, m, weekStartsOn)
          return Array.from({ length: 6 }, (_, i) => {
            const weekStartDate = new Date(start)
            weekStartDate.setDate(weekStartDate.getDate() + i * 7)
            const weekEndDate = new Date(weekStartDate)
            weekEndDate.setDate(weekEndDate.getDate() + 6)
            return {
              start: weekStartDate,
              end: weekEndDate,
              weekNum: weekNumber(weekStartDate),
            }
          })
        }, [monthYearAnchor, weekStartsOn])
    
        function isWeekSelected(ws: Date) {
          const v = activeValue
          if (!(v instanceof Date)) return false
          return compareDates(weekStart(v, weekStartsOn), ws) === 0
        }
    
        function isWeekDisabled(ws: Date) {
          if (minDate && compareDates(ws, minDate) < 0) return true
          const weekEnd = new Date(ws)
          weekEnd.setDate(weekEnd.getDate() + 6)
          if (maxDate && compareDates(weekEnd, maxDate) > 0) return true
          if (disabledDate?.(ws)) return true
          return false
        }
    
        function pickWeek(ws: Date) {
          if (isWeekDisabled(ws) || readOnly) return
          if (needConfirm) {
            setPreviewValue(ws)
            return
          }
          handleUpdate(ws)
        }
    
        function isQuarterSelected(qIdx: number) {
          const v = activeValue
          if (!(v instanceof Date)) return false
          return (
            v.getFullYear() === monthYearAnchor.getFullYear() &&
            Math.ceil((v.getMonth() + 1) / 3) === qIdx + 1
          )
        }
    
        function isQuarterDisabled(qIdx: number) {
          const d = quarterStart(monthYearAnchor.getFullYear(), qIdx)
          if (minDate && compareDates(d, minDate) < 0) return true
          const lastDay = new Date(d.getFullYear(), d.getMonth() + 3, 0)
          if (maxDate && compareDates(lastDay, maxDate) > 0) return true
          if (disabledDate?.(d)) return true
          return false
        }
    
        function pickQuarter(qIdx: number) {
          const d = quarterStart(monthYearAnchor.getFullYear(), qIdx)
          if (isQuarterDisabled(qIdx) || readOnly) return
          if (needConfirm) {
            setPreviewValue(d)
            return
          }
          handleUpdate(d)
        }
    
        function isMonthSelected(monthIdx: number) {
          const v = activeValue
          if (!(v instanceof Date)) return false
          return v.getFullYear() === monthYearAnchor.getFullYear() && v.getMonth() === monthIdx
        }
    
        function isMonthDisabled(monthIdx: number) {
          const d = new Date(monthYearAnchor.getFullYear(), monthIdx, 1)
          if (minDate && compareDates(d, minDate) < 0) return true
          if (maxDate && compareDates(d, maxDate) > 0) return true
          if (disabledDate?.(d)) return true
          return false
        }
    
        function pickMonth(monthIdx: number) {
          const d = new Date(monthYearAnchor.getFullYear(), monthIdx, 1)
          if (isMonthDisabled(monthIdx) || readOnly) return
          if (needConfirm) {
            setPreviewValue(d)
            return
          }
          handleUpdate(d)
        }
    
        function isYearSelected(year: number) {
          const v = activeValue
          return v instanceof Date && v.getFullYear() === year
        }
    
        function isYearDisabled(year: number) {
          const d = new Date(year, 0, 1)
          const lastDay = new Date(year, 11, 31)
          if (minDate && compareDates(lastDay, minDate) < 0) return true
          if (maxDate && compareDates(d, maxDate) > 0) return true
          if (disabledDate?.(d)) return true
          return false
        }
    
        function pickYear(year: number) {
          const d = new Date(year, 0, 1)
          if (isYearDisabled(year) || readOnly) return
          if (picker === 'year') {
            if (needConfirm) {
              setPreviewValue(d)
              return
            }
            handleUpdate(d)
          } else {
            setMonthYearAnchor(d)
          }
        }
    
        const monthYearLabel =
          picker === 'week'
            ? new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' }).format(monthYearAnchor)
            : picker === 'quarter' || picker === 'month'
              ? String(monthYearAnchor.getFullYear())
              : `${yearGrid[0]}${yearGrid[yearGrid.length - 1]}`
    
        const showAlternatePicker = picker !== 'day' && type === 'single'
    
        return (
          <Popover open={open} onOpenChange={setOpen}>
            <PopoverTrigger asChild>
              <Button
                ref={ref}
                type="button"
                variant="outline"
                size={buttonSize}
                disabled={disabled}
                className={triggerClasses}
                data-uipkge=""
                data-slot="date-picker"
              >
                <CalendarIcon className="size-4" aria-hidden="true" />
                <span className="flex-1 truncate">{display || placeholder}</span>
                {clearable && hasValue && !disabled && !readOnly && (
                  <span
                    role="button"
                    tabIndex={0}
                    className="text-muted-foreground hover:text-foreground focus-visible:ring-ring -mr-1 inline-flex size-9 cursor-pointer items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none"
                    aria-label="Clear date"
                    onClick={(e) => {
                      e.stopPropagation()
                      clear(e)
                    }}
                    onKeyDown={(e) => {
                      if (e.key === 'Enter' || e.key === ' ') {
                        e.preventDefault()
                        e.stopPropagation()
                        clear(e)
                      }
                    }}
                  >
                    <X className="size-3.5" />
                  </span>
                )}
              </Button>
            </PopoverTrigger>
            <PopoverContent className="w-auto p-0" side={popoverPlacement.side} align={popoverPlacement.align}>
              {showCurrentDate && type === 'single' && (picker === 'day' || picker === 'week') && (
                <div className="flex justify-end border-b px-3 py-2">
                  <button
                    type="button"
                    className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded px-2 py-1.5 text-xs focus-visible:ring-2 focus-visible:outline-none"
                    onClick={pickToday}
                  >
                    Today
                  </button>
                </div>
              )}
    
              {showAlternatePicker ? (
                <div className="w-[260px] p-3" data-uipkge="" data-slot="month-year-picker">
                  <div className="mb-3 flex items-center justify-between">
                    <button
                      type="button"
                      className="hover:bg-accent focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none"
                      aria-label="Previous"
                      onClick={() => shiftAnchor(-1)}
                    >
                      <span className="text-muted-foreground text-sm"></span>
                    </button>
                    <span className="text-sm font-medium">{monthYearLabel}</span>
                    <button
                      type="button"
                      className="hover:bg-accent focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none"
                      aria-label="Next"
                      onClick={() => shiftAnchor(1)}
                    >
                      <span className="text-muted-foreground text-sm"></span>
                    </button>
                  </div>
    
                  {picker === 'week' && (
                    <div className="flex flex-col gap-1">
                      {weekGrid.map((week, i) => (
                        <button
                          key={i}
                          type="button"
                          data-uipkge=""
                          data-slot="week-picker-cell"
                          data-active={isWeekSelected(week.start) || undefined}
                          disabled={isWeekDisabled(week.start)}
                          aria-pressed={isWeekSelected(week.start)}
                          aria-disabled={isWeekDisabled(week.start) || undefined}
                          className={cn(
                            'hover:bg-accent focus-visible:ring-ring flex items-center justify-between rounded px-3 py-2 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent',
                            isWeekSelected(week.start) && 'bg-primary text-primary-foreground hover:bg-primary',
                          )}
                          onClick={() => pickWeek(week.start)}
                        >
                          <span className="font-medium">Week {week.weekNum}</span>
                          <span
                            className={cn(
                              'text-muted-foreground text-xs',
                              isWeekSelected(week.start) && 'text-primary-foreground',
                            )}
                          >
                            {week.start.getDate()}{' '}
                            {new Intl.DateTimeFormat(locale, { month: 'short' }).format(week.start)}{' '}
                            {week.end.getDate()}{' '}
                            {new Intl.DateTimeFormat(locale, { month: 'short' }).format(week.end)}
                          </span>
                        </button>
                      ))}
                    </div>
                  )}
    
                  {picker === 'quarter' && (
                    <div className="grid grid-cols-2 gap-2">
                      {QUARTER_LABELS.map((label, q) => (
                        <button
                          key={q}
                          type="button"
                          data-uipkge=""
                          data-slot="quarter-picker-cell"
                          data-active={isQuarterSelected(q) || undefined}
                          disabled={isQuarterDisabled(q)}
                          aria-pressed={isQuarterSelected(q)}
                          aria-disabled={isQuarterDisabled(q) || undefined}
                          className={cn(
                            'hover:bg-accent focus-visible:ring-ring rounded px-4 py-6 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent',
                            isQuarterSelected(q) && 'bg-primary text-primary-foreground hover:bg-primary',
                          )}
                          onClick={() => pickQuarter(q)}
                        >
                          {label}
                        </button>
                      ))}
                    </div>
                  )}
    
                  {picker === 'month' && (
                    <div className="grid grid-cols-3 gap-2">
                      {monthLabels.map((label, m) => (
                        <button
                          key={m}
                          type="button"
                          data-uipkge=""
                          data-slot="month-picker-cell"
                          data-active={isMonthSelected(m) || undefined}
                          disabled={isMonthDisabled(m)}
                          aria-pressed={isMonthSelected(m)}
                          aria-disabled={isMonthDisabled(m) || undefined}
                          className={cn(
                            'hover:bg-accent focus-visible:ring-ring rounded px-2 py-2 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent',
                            isMonthSelected(m) && 'bg-primary text-primary-foreground hover:bg-primary',
                          )}
                          onClick={() => pickMonth(m)}
                        >
                          {label}
                        </button>
                      ))}
                    </div>
                  )}
    
                  {picker === 'year' && (
                    <div className="grid grid-cols-3 gap-2">
                      {yearGrid.map((y) => (
                        <button
                          key={y}
                          type="button"
                          data-uipkge=""
                          data-slot="year-picker-cell"
                          data-active={isYearSelected(y) || undefined}
                          disabled={isYearDisabled(y)}
                          aria-pressed={isYearSelected(y)}
                          aria-disabled={isYearDisabled(y) || undefined}
                          className={cn(
                            'hover:bg-accent focus-visible:ring-ring rounded px-2 py-2 text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent',
                            isYearSelected(y) && 'bg-primary text-primary-foreground hover:bg-primary',
                          )}
                          onClick={() => pickYear(y)}
                        >
                          {y}
                        </button>
                      ))}
                    </div>
                  )}
                </div>
              ) : (
                <div className="flex">
                  {presetGroups.length > 0 && (
                    <aside className="flex w-40 flex-col gap-1 border-r p-2">
                      {presetGroups.map((group, gIdx) => (
                        <React.Fragment key={gIdx}>
                          {group.category && (
                            <div className="text-muted-foreground px-2 pt-1 text-xs font-semibold tracking-wider uppercase">
                              {group.category}
                            </div>
                          )}
                          {group.presets.map((p) => (
                            <button
                              key={p.label}
                              type="button"
                              className="hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1.5 text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
                              onClick={() => applyPreset(p)}
                            >
                              {p.label}
                            </button>
                          ))}
                        </React.Fragment>
                      ))}
                    </aside>
                  )}
    
                  {type === 'range' ? (
                    <RangeCalendar
                      selected={calendarValue as DateRange | undefined}
                      numberOfMonths={effectiveNumberOfMonths}
                      weekStartsOn={weekStartsOn}
                      fixedWeeks={fixedWeeks}
                      disabled={calendarDisabled}
                      startMonth={minDate ? stripTime(minDate) : undefined}
                      endMonth={maxDate ? stripTime(maxDate) : undefined}
                      onSelect={readOnly ? undefined : handleCalendarUpdate}
                    />
                  ) : (
                    <Calendar
                      mode={type === 'multiple' ? 'multiple' : 'single'}
                      selected={calendarValue as Date | Date[] | undefined}
                      numberOfMonths={effectiveNumberOfMonths}
                      weekStartsOn={weekStartsOn}
                      fixedWeeks={fixedWeeks}
                      disabled={calendarDisabled}
                      captionLayout={captionLayout}
                      startMonth={captionLayout && minDate ? stripTime(minDate) : undefined}
                      endMonth={captionLayout && maxDate ? stripTime(maxDate) : undefined}
                      components={calendarComponents}
                      onSelect={readOnly ? undefined : handleCalendarUpdate}
                    />
                  )}
    
                  {showTime && (
                    <div className="flex flex-col border-l">
                      <div className="flex items-center justify-between border-b px-3 py-2">
                        <span className="text-muted-foreground text-xs tracking-widest uppercase">Time</span>
                        <button
                          type="button"
                          className="text-primary focus-visible:ring-ring rounded px-2 py-1.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none"
                          onClick={() => setOpen(false)}
                        >
                          Done
                        </button>
                      </div>
                      <TimeColumns
                        value={timeForColumns}
                        format={timeFormat}
                        use24Hour={use24Hour}
                        minuteStep={minuteStep}
                        secondStep={secondStep}
                        visible={open}
                        disabledHours={disabledTimeConfig?.disabledHours}
                        disabledMinutes={disabledTimeConfig?.disabledMinutes}
                        disabledSeconds={disabledTimeConfig?.disabledSeconds}
                        onValueChange={handleTimeUpdate}
                      />
                    </div>
                  )}
                </div>
              )}
    
              {needConfirm && (
                <div className="flex items-center justify-end gap-2 border-t px-3 py-2">
                  <button
                    type="button"
                    className="hover:bg-accent focus-visible:ring-ring rounded px-3 py-1.5 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
                    onClick={cancelPreview}
                  >
                    Cancel
                  </button>
                  <button
                    type="button"
                    className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring rounded px-3 py-1.5 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
                    onClick={commitPreview}
                  >
                    OK
                  </button>
                </div>
              )}
            </PopoverContent>
          </Popover>
        )
      },
    )
    DatePicker.displayName = 'DatePicker'
    
    export { DatePicker }
  • components/ui/date-picker/date-picker-utils.ts 6.1 kB
    export type DatePickerType = 'single' | 'multiple' | 'range'
    export type DatePickerLayout = 'default' | 'month-and-year' | 'month-only' | 'year-only'
    export type DatePickerPicker = 'day' | 'week' | 'month' | 'quarter' | 'year'
    export type DatePickerStatus = 'error' | 'warning'
    export type DatePickerSize = 'small' | 'middle' | 'large'
    export type DatePickerPlacement =
      | 'top'
      | 'bottom'
      | 'left'
      | 'right'
      | 'topLeft'
      | 'topRight'
      | 'bottomLeft'
      | 'bottomRight'
    
    export type FormatValue = 'short' | 'medium' | 'long' | 'full' | Intl.DateTimeFormatOptions
    
    export type SingleValue = string | Date | null
    export type MultipleValue = (string | Date)[] | null
    export type RangeValue = { start: string | Date; end?: string | Date } | null
    
    export interface DatePickerPreset {
      label: string
      value: SingleValue | MultipleValue | RangeValue
      category?: string
    }
    
    export interface DisabledTimeResult {
      disabledHours?: () => number[]
      disabledMinutes?: (selectedHour: number) => number[]
      disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[]
    }
    
    export type TimeShape = { h: number; m: number; s: number }
    
    export type InternalSingle = Date | undefined
    export type InternalMultiple = Date[]
    export type InternalRange = { start?: Date; end?: Date }
    
    export function coerceDate(v: string | Date | null | undefined): Date | null {
      if (!v) return null
      if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v
      const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(v)
      if (m) {
        const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
        return Number.isNaN(d.getTime()) ? null : d
      }
      const dt = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(v)
      if (dt) {
        const d = new Date(
          Number(dt[1]),
          Number(dt[2]) - 1,
          Number(dt[3]),
          Number(dt[4]),
          Number(dt[5]),
          dt[6] ? Number(dt[6]) : 0,
        )
        return Number.isNaN(d.getTime()) ? null : d
      }
      const d = new Date(v)
      return Number.isNaN(d.getTime()) ? null : d
    }
    
    export function toISODate(d: Date): string {
      const y = d.getFullYear()
      const mo = String(d.getMonth() + 1).padStart(2, '0')
      const da = String(d.getDate()).padStart(2, '0')
      return `${y}-${mo}-${da}`
    }
    
    export function toISODateTime(d: Date, showSeconds: boolean): string {
      const date = toISODate(d)
      const h = String(d.getHours()).padStart(2, '0')
      const m = String(d.getMinutes()).padStart(2, '0')
      if (showSeconds) {
        const s = String(d.getSeconds()).padStart(2, '0')
        return `${date}T${h}:${m}:${s}`
      }
      return `${date}T${h}:${m}`
    }
    
    export function stripTime(d: Date): Date {
      return new Date(d.getFullYear(), d.getMonth(), d.getDate())
    }
    
    export function parseTimeShape(value: string, fallback: TimeShape): TimeShape {
      const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(value)
      if (!m) return fallback
      return { h: Number(m[1]), m: Number(m[2]), s: m[3] ? Number(m[3]) : 0 }
    }
    
    export function withTime(d: Date, t: TimeShape): Date {
      return new Date(d.getFullYear(), d.getMonth(), d.getDate(), t.h, t.m, t.s)
    }
    
    export function weekStart(d: Date, weekStartsOn: number): Date {
      const dayOfWeek = d.getDay()
      const offset = (dayOfWeek - weekStartsOn + 7) % 7
      const out = new Date(d)
      out.setDate(out.getDate() - offset)
      return stripTime(out)
    }
    
    export function weekNumber(d: Date): number {
      const jsDate = new Date(d)
      const target = new Date(jsDate.valueOf())
      const dayNr = (jsDate.getDay() + 6) % 7
      target.setDate(target.getDate() - dayNr + 3)
      const firstThursday = target.valueOf()
      target.setMonth(0, 1)
      if (target.getDay() !== 4) {
        target.setMonth(0, 1 + ((4 - target.getDay() + 7) % 7))
      }
      return 1 + Math.ceil((firstThursday - target.valueOf()) / 604800000)
    }
    
    export function defaultRangePresets(): DatePickerPreset[] {
      const t = stripTime(new Date())
      const yesterday = new Date(t)
      yesterday.setDate(yesterday.getDate() - 1)
      const last7 = new Date(t)
      last7.setDate(last7.getDate() - 6)
      const last30 = new Date(t)
      last30.setDate(last30.getDate() - 29)
      const monthStart = new Date(t.getFullYear(), t.getMonth(), 1)
      const lastMonthEnd = new Date(t.getFullYear(), t.getMonth(), 0)
      const lastMonthStart = new Date(lastMonthEnd.getFullYear(), lastMonthEnd.getMonth(), 1)
      const ytd = new Date(t.getFullYear(), 0, 1)
      return [
        { label: 'Today', value: { start: t, end: t } },
        { label: 'Yesterday', value: { start: yesterday, end: yesterday } },
        { label: 'Last 7 days', value: { start: last7, end: t } },
        { label: 'Last 30 days', value: { start: last30, end: t } },
        { label: 'This month', value: { start: monthStart, end: t } },
        { label: 'Last month', value: { start: lastMonthStart, end: lastMonthEnd } },
        { label: 'Year to date', value: { start: ytd, end: t } },
      ]
    }
    
    export function coerceShape(
      type: DatePickerType,
      v: SingleValue | MultipleValue | RangeValue | undefined,
    ): InternalSingle | InternalMultiple | InternalRange | undefined {
      if (type === 'multiple') {
        if (!Array.isArray(v)) return undefined
        return v.map(coerceDate).filter((x): x is Date => x != null)
      }
      if (type === 'range') {
        if (!v || Array.isArray(v) || typeof v === 'string' || !('start' in (v as object))) return undefined
        const r = v as { start: string | Date; end?: string | Date }
        const start = coerceDate(r.start)
        const end = r.end ? coerceDate(r.end) : undefined
        if (!start) return undefined
        return end ? { start, end } : { start }
      }
      return coerceDate(v as SingleValue) ?? undefined
    }
    
    export function fmtDate(d: Date, locale: string, format: FormatValue) {
      const opts: Intl.DateTimeFormatOptions =
        typeof format === 'object' ? format : { dateStyle: format }
      return new Intl.DateTimeFormat(locale, opts).format(d)
    }
    
    export function fmtMonth(d: Date, locale: string) {
      return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(d)
    }
    
    export function fmtTime(d: Date, showSeconds: boolean, use24Hour: boolean) {
      const opts: Intl.DateTimeFormatOptions = showSeconds
        ? { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: !use24Hour }
        : { hour: '2-digit', minute: '2-digit', hour12: !use24Hour }
      return new Intl.DateTimeFormat(undefined, opts).format(d)
    }
  • components/ui/date-picker/index.ts 0.3 kB
    export {
      DatePicker,
      type DatePickerProps,
      type DatePickerType,
      type DatePickerLayout,
      type DatePickerPicker,
      type DatePickerStatus,
      type DatePickerSize,
      type DatePickerPlacement,
      type FormatValue,
      type SingleValue,
      type MultipleValue,
      type RangeValue,
      type DatePickerPreset,
      type DisabledTimeResult,
    } from './date-picker'

Raw manifest: https://uipkge.dev/r/react/date-picker.json