← Dashboard
Zubeyr··Componentsinputsdate

Date Picker

Date, date range, date + time, time and month pickers that share the Date Range Picker’s calendar and panel: typed entry in plain words, dayjs-powered locale formatting, translatable labels, min and max limits, Today, Now and This month shortcuts, clear, keyboard navigation and a panel that flips to stay on screen.

Live demo ↗

Installation

Run the shadcn CLI in a project that already has components.json.

pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/date-picker.json
  • →Installs lucide-react from npm.
  • →Installs dayjs from npm.
  • →Pulls in date-range-picker.
  • →Files land in components/dashboard/date-picker.

Installing by hand? Add the dependencies, then copy the source code below.

terminal.sh
pnpm add lucide-react dayjs

Usage

example.tsx
'use client'

import { useState } from 'react'

import { Field } from '@/registry/dashboard/components/input/input'

import { DatePicker } from '@/components/dashboard/date-picker/date-picker'
import { MonthPicker } from '@/components/dashboard/date-picker/month-picker'
import { TimePicker } from '@/components/dashboard/date-picker/time-picker'

export default function Demo() {
  const [date, setDate] = useState('')
  const [time, setTime] = useState('09:30')
  const [month, setMonth] = useState('')

  return (
    <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-3">
      <Field label="Delivery date" htmlFor="date" hint="From today onwards">
        <DatePicker id="date" value={date} onChange={setDate} min={today()} />
      </Field>
      <Field label="Arrival time" htmlFor="time" hint="Between 7 AM and 6 PM">
        <TimePicker id="time" value={time} onChange={setTime} step={30} min="07:00" max="18:00" />
      </Field>
      <Field label="Billing month" htmlFor="month">
        <MonthPicker id="month" value={month} onChange={setMonth} />
      </Field>
    </div>
  )
}

function today() {
  const d = new Date()
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}

Source code

date-picker.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { Calendar } from 'lucide-react'

import {
  CalendarMonth,
  monthTitle,
} from '@/registry/dashboard/components/date-range-picker/calendar-month'
import {
  addDays,
  fromInputValue,
  sameMonth,
  startOfDay,
  startOfMonth,
  toInputValue,
} from '@/registry/dashboard/components/date-range-picker/dates'

import {
  FooterButton,
  NavButton,
  Picker,
  PickerFooter,
  PickerInput,
  type PickerBaseProps,
  usePickerClose,
} from './picker'
import { formatDay, label, parseDay, type PickerLabels } from './locale'

type DatePickerProps = PickerBaseProps & {
  value: string
  onChange: (value: string) => void
  min?: string
  max?: string
}

const keyOffsets: Record<string, number> = {
  ArrowLeft: -1,
  ArrowRight: 1,
  ArrowUp: -7,
  ArrowDown: 7,
}

export function parseDate(text: string, locale?: string) {
  return parseDay(text, locale)
}

export function DatePicker({
  value,
  onChange,
  min,
  max,
  placeholder,
  locale,
  labels,
  ...props
}: DatePickerProps) {
  const selected = fromInputValue(value)
  return (
    <Picker
      icon={<Calendar />}
      text={selected ? formatDay(selected, locale) : ''}
      label={label(labels, 'chooseDate')}
      placeholder={placeholder ?? label(labels, 'pickDate')}
      labels={labels}
      panelClassName="flex flex-col gap-3 p-3"
      onClear={() => onChange('')}
      {...props}
    >
      <DatePanel
        locale={locale}
        labels={labels}
        value={value}
        min={min}
        max={max}
        onPick={onChange}
      />
    </Picker>
  )
}

function DatePanel({
  locale,
  labels,
  value,
  min,
  max,
  onPick,
}: {
  value: string
  min?: string
  max?: string
  onPick: (value: string) => void
  locale?: string
  labels?: Partial<PickerLabels>
}) {
  const close = usePickerClose()
  const done = (v: string) => {
    onPick(v)
    close()
  }
  const [today] = useState(() => startOfDay(new Date()))
  const selected = fromInputValue(value)
  const minDate = (min && fromInputValue(min)) || undefined
  const maxDate = (max && fromInputValue(max)) || undefined
  const allowed = (d: Date) => (!minDate || d >= minDate) && (!maxDate || d <= maxDate)
  const start = selected ?? (allowed(today) ? today : (minDate ?? maxDate ?? today))

  const [view, setView] = useState(() => startOfMonth(start))
  const [focused, setFocused] = useState(start)
  const [focusTick, setFocusTick] = useState(1)
  const [typed, setTyped] = useState(selected ? toInputValue(selected) : '')
  const [typedError, setTypedError] = useState(false)
  const grid = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!focusTick) return
    grid.current?.querySelector<HTMLButtonElement>(`[data-date="${focused.getTime()}"]`)?.focus()
  }, [focusTick, focused, view])

  const pick = (date: Date) => allowed(date) && done(toInputValue(date))

  const commitTyped = () => {
    const date = parseDate(typed, locale)
    if (!date || !allowed(date)) return setTypedError(!!typed.trim())
    pick(date)
  }

  return (
    <>
      <PickerInput
        value={typed}
        placeholder={label(labels, 'typeDate', { example: formatDay(today, locale) })}
        label={label(labels, 'chooseDate')}
        invalid={typedError}
        onChange={(v) => {
          setTyped(v)
          setTypedError(false)
          setFocusTick(0)
          const date = parseDate(v, locale)
          if (date && !sameMonth(date, view)) setView(startOfMonth(date))
        }}
        onCommit={commitTyped}
        onKeyDown={(e) => {
          if (e.key !== 'ArrowDown') return
          e.preventDefault()
          setFocusTick(focusTick + 1)
        }}
      />
      <div ref={grid}>
        <CalendarMonth
          month={view}
          start={selected}
          end={selected}
          preview={null}
          focused={focused}
          today={today}
          minDate={minDate}
          maxDate={maxDate}
          onSelect={pick}
          locale={locale}
          onHover={() => {}}
          onKeyDown={(event, date) => {
            const page = event.key === 'PageUp' ? -1 : event.key === 'PageDown' ? 1 : 0
            const offset = keyOffsets[event.key]
            if (offset === undefined && !page) return
            event.preventDefault()
            const next = page
              ? new Date(date.getFullYear(), date.getMonth() + page, date.getDate())
              : addDays(date, offset)
            setFocused(next)
            setFocusTick(focusTick + 1)
            if (!sameMonth(next, view)) setView(startOfMonth(next))
          }}
          header={
            <>
              <NavButton
                direction={-1}
                label={label(labels, 'previousMonth')}
                onClick={() => setView(startOfMonth(view, -1))}
              />
              <span className="flex-1 text-center text-sm font-semibold">
                {monthTitle(view, locale)}
              </span>
              <NavButton
                direction={1}
                label={label(labels, 'nextMonth')}
                onClick={() => setView(startOfMonth(view, 1))}
              />
            </>
          }
        />
      </div>
      <PickerFooter>
        <FooterButton onClick={() => pick(today)} disabled={!allowed(today)}>
          {label(labels, 'today')}
        </FooterButton>
        <FooterButton onClick={() => done('')} disabled={!value}>
          {label(labels, 'clear')}
        </FooterButton>
      </PickerFooter>
    </>
  )
}
date-range-field.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { CalendarRange } from 'lucide-react'

import { cn } from '@/lib/utils'
import {
  CalendarMonth,
  monthTitle,
} from '@/registry/dashboard/components/date-range-picker/calendar-month'
import {
  addDays,
  fromInputValue,
  sameMonth,
  startOfDay,
  startOfMonth,
  toInputValue,
  type DatePreset,
} from '@/registry/dashboard/components/date-range-picker/dates'

import { parseDate } from './date-picker'
import { formatDay, label, type PickerLabels } from './locale'
import {
  FooterButton,
  NavButton,
  Picker,
  PickerFooter,
  PickerInput,
  usePickerClose,
  type PickerBaseProps,
} from './picker'

type DateRangeFieldProps = PickerBaseProps & {
  value: string
  onChange: (value: string) => void
  min?: string
  max?: string
  presets?: DatePreset[]
}

const keyOffsets: Record<string, number> = {
  ArrowLeft: -1,
  ArrowRight: 1,
  ArrowUp: -7,
  ArrowDown: 7,
}

const rangeText = (start: Date, end: Date, locale?: string) =>
  `${formatDay(start, locale)} – ${formatDay(end, locale)}`

export const splitRange = (value: string) => {
  const [a = '', b = ''] = value.split('/')
  return [fromInputValue(a), fromInputValue(b)] as const
}

export function DateRangeField({
  value,
  onChange,
  min,
  max,
  presets,
  placeholder,
  locale,
  labels,
  ...props
}: DateRangeFieldProps) {
  const [start, end] = splitRange(value)
  return (
    <Picker
      icon={<CalendarRange />}
      text={start && end ? rangeText(start, end, locale) : ''}
      label={label(labels, 'chooseRange')}
      placeholder={placeholder ?? label(labels, 'pickDates')}
      labels={labels}
      panelClassName="flex"
      onClear={() => onChange('')}
      {...props}
    >
      <RangePanel
        locale={locale}
        labels={labels}
        value={value}
        min={min}
        max={max}
        presets={presets}
        onPick={onChange}
      />
    </Picker>
  )
}

function RangePanel({
  locale,
  labels,
  value,
  min,
  max,
  presets,
  onPick,
}: {
  value: string
  min?: string
  max?: string
  presets?: DatePreset[]
  onPick: (value: string) => void
  locale?: string
  labels?: Partial<PickerLabels>
}) {
  const close = usePickerClose()
  const [today] = useState(() => startOfDay(new Date()))
  const [initialStart, initialEnd] = splitRange(value)
  const minDate = (min && fromInputValue(min)) || undefined
  const maxDate = (max && fromInputValue(max)) || undefined
  const allowed = (d: Date) => (!minDate || d >= minDate) && (!maxDate || d <= maxDate)

  const [start, setStart] = useState<Date | null>(initialStart)
  const [end, setEnd] = useState<Date | null>(initialEnd)
  const [preview, setPreview] = useState<Date | null>(null)
  const anchor = initialStart ?? (allowed(today) ? today : (minDate ?? today))
  const [view, setView] = useState(() => startOfMonth(anchor))
  const [focused, setFocused] = useState(anchor)
  const [focusTick, setFocusTick] = useState(1)
  const [inputs, setInputs] = useState({
    start: initialStart ? toInputValue(initialStart) : '',
    end: initialEnd ? toInputValue(initialEnd) : '',
  })
  const grid = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!focusTick) return
    grid.current?.querySelector<HTMLButtonElement>(`[data-date="${focused.getTime()}"]`)?.focus()
  }, [focusTick, focused, view])

  const commit = (a: Date, b: Date) => {
    onPick(`${toInputValue(a)}/${toInputValue(b)}`)
    close()
  }

  const show = (a: Date | null, b: Date | null) => {
    setStart(a)
    setEnd(b)
    setInputs({ start: a ? toInputValue(a) : '', end: b ? toInputValue(b) : '' })
  }

  const select = (date: Date) => {
    if (!allowed(date)) return
    setFocused(date)
    if (!start || end) return show(date, null)
    const [a, b] = date < start ? [date, start] : [start, date]
    show(a, b)
    commit(a, b)
  }

  const commitInput = (key: 'start' | 'end') => {
    const date = parseDate(inputs[key], locale)
    if (!date || !allowed(date)) return show(start, end)
    const a = key === 'start' ? date : (start ?? date)
    const b = key === 'end' ? date : (end ?? date)
    const [s, e] = a <= b ? [a, b] : [b, a]
    show(s, e)
    if (!sameMonth(date, view)) setView(startOfMonth(date))
  }

  const month = (offset: number, header: React.ReactNode) => (
    <CalendarMonth
      month={startOfMonth(view, offset)}
      start={start}
      end={end}
      preview={start && !end ? preview : null}
      focused={focused}
      today={today}
      minDate={minDate}
      maxDate={maxDate}
      onSelect={select}
      onHover={setPreview}
      locale={locale}
      onKeyDown={(event, date) => {
        const offsetDays = keyOffsets[event.key]
        if (offsetDays === undefined) return
        event.preventDefault()
        const next = addDays(date, offsetDays)
        setFocused(next)
        setFocusTick(focusTick + 1)
        if (start && !end) setPreview(next)
        if (!sameMonth(next, view) && !sameMonth(next, startOfMonth(view, 1)))
          setView(startOfMonth(next, next < view ? 0 : -1))
      }}
      header={header}
    />
  )

  const title = (offset: number) => (
    <span className="flex-1 text-center text-sm font-semibold">
      {monthTitle(startOfMonth(view, offset), locale)}
    </span>
  )

  return (
    <>
      {presets?.length ? (
        <ul className="hidden w-40 shrink-0 flex-col gap-px overflow-y-auto border-r p-2 md:flex">
          {presets.map((preset) => (
            <li key={preset.label}>
              <button
                type="button"
                onClick={() => {
                  const r = preset.range(today)
                  commit(r.start, r.end)
                }}
                className="flex h-8 w-full items-center rounded-lg px-2.5 text-left text-sm hover:bg-muted"
              >
                {preset.label}
              </button>
            </li>
          ))}
        </ul>
      ) : null}
      <div className="flex flex-col gap-3 p-3">
        <div className="flex items-center gap-2">
          <PickerInput
            value={inputs.start}
            placeholder={label(labels, 'start')}
            label={label(labels, 'startDate')}
            onChange={(v) => {
              setInputs({ ...inputs, start: v })
              setFocusTick(0)
            }}
            onCommit={() => commitInput('start')}
          />
          <span className="text-muted-foreground">→</span>
          <PickerInput
            value={inputs.end}
            placeholder={label(labels, 'end')}
            label={label(labels, 'endDate')}
            onChange={(v) => {
              setInputs({ ...inputs, end: v })
              setFocusTick(0)
            }}
            onCommit={() => commitInput('end')}
          />
        </div>
        <div ref={grid} className="flex gap-6">
          {month(
            0,
            <>
              <NavButton
                direction={-1}
                label={label(labels, 'previousMonth')}
                onClick={() => setView(startOfMonth(view, -1))}
              />
              {title(0)}
              <span className="size-8 max-md:hidden" />
              <span className="md:hidden">
                <NavButton
                  direction={1}
                  label={label(labels, 'nextMonth')}
                  onClick={() => setView(startOfMonth(view, 1))}
                />
              </span>
            </>,
          )}
          <div className="max-md:hidden">
            {month(
              1,
              <>
                <span className="size-8" />
                {title(1)}
                <NavButton
                  direction={1}
                  label={label(labels, 'nextMonth')}
                  onClick={() => setView(startOfMonth(view, 1))}
                />
              </>,
            )}
          </div>
        </div>
        <PickerFooter>
          <span
            className={cn('text-xs', start && end ? 'text-foreground' : 'text-muted-foreground')}
          >
            {start && end
              ? rangeText(start, end, locale)
              : start
                ? label(labels, 'pickEnd')
                : label(labels, 'pickStart')}
          </span>
          <div className="flex gap-1">
            {start && end && (
              <FooterButton onClick={() => commit(start, end)}>
                {label(labels, 'apply')}
              </FooterButton>
            )}
            <FooterButton
              onClick={() => {
                onPick('')
                close()
              }}
              disabled={!value}
            >
              {label(labels, 'clear')}
            </FooterButton>
          </div>
        </PickerFooter>
      </div>
    </>
  )
}
date-time-picker.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { CalendarClock } from 'lucide-react'

import { cn } from '@/lib/utils'
import {
  CalendarMonth,
  monthTitle,
} from '@/registry/dashboard/components/date-range-picker/calendar-month'
import {
  addDays,
  fromInputValue,
  sameMonth,
  startOfDay,
  startOfMonth,
  toInputValue,
} from '@/registry/dashboard/components/date-range-picker/dates'

import {
  FooterButton,
  NavButton,
  Picker,
  PickerFooter,
  usePickerClose,
  type PickerBaseProps,
} from './picker'
import { formatDay, label, type PickerLabels } from './locale'
import { formatTime } from './time-picker'

type DateTimePickerProps = PickerBaseProps & {
  value: string
  onChange: (value: string) => void
  step?: number
  min?: string
  max?: string
}

const pad = (n: number) => String(n).padStart(2, '0')
const keyOffsets: Record<string, number> = {
  ArrowLeft: -1,
  ArrowRight: 1,
  ArrowUp: -7,
  ArrowDown: 7,
}

export function formatDateTime(value: string, locale?: string) {
  const [date, time] = value.split('T')
  const d = fromInputValue(date ?? '')
  if (!d) return value
  return time ? `${formatDay(d, locale)}, ${formatTime(time, locale)}` : formatDay(d, locale)
}

export function DateTimePicker({
  value,
  onChange,
  step = 15,
  min,
  max,
  placeholder,
  locale,
  labels,
  ...props
}: DateTimePickerProps) {
  return (
    <Picker
      icon={<CalendarClock />}
      text={value ? formatDateTime(value, locale) : ''}
      label={label(labels, 'chooseDateTime')}
      placeholder={placeholder ?? label(labels, 'pickDateTime')}
      labels={labels}
      panelClassName="flex flex-col gap-3 p-3"
      onClear={() => onChange('')}
      {...props}
    >
      <DateTimePanel
        locale={locale}
        labels={labels}
        value={value}
        step={step}
        min={min}
        max={max}
        onPick={onChange}
      />
    </Picker>
  )
}

function DateTimePanel({
  locale,
  labels,
  value,
  step,
  min,
  max,
  onPick,
}: {
  value: string
  step: number
  min?: string
  max?: string
  onPick: (value: string) => void
  locale?: string
  labels?: Partial<PickerLabels>
}) {
  const close = usePickerClose()
  const [today] = useState(() => startOfDay(new Date()))
  const [initialDate, initialTime = ''] = value.split('T')
  const minDay = min ? fromInputValue(min.slice(0, 10)) : null
  const maxDay = max ? fromInputValue(max.slice(0, 10)) : null
  const dayAllowed = (d: Date) => (!minDay || d >= minDay) && (!maxDay || d <= maxDay)

  const startDay =
    fromInputValue(initialDate ?? '') ?? (dayAllowed(today) ? today : (minDay ?? today))
  const [day, setDay] = useState<Date | null>(fromInputValue(initialDate ?? ''))
  const [time, setTime] = useState(initialTime)
  const [view, setView] = useState(() => startOfMonth(startDay))
  const [focused, setFocused] = useState(startDay)
  const [focusTick, setFocusTick] = useState(1)
  const grid = useRef<HTMLDivElement>(null)
  const list = useRef<HTMLUListElement>(null)

  const slots = Array.from({ length: Math.ceil(1440 / step) }, (_, i) => {
    const m = i * step
    return `${pad(Math.floor(m / 60))}:${pad(m % 60)}`
  })
  const stamp = (d: Date, t: string) => `${toInputValue(d)}T${t}`
  const allowed = (d: Date, t: string) =>
    (!min || stamp(d, t) >= min) && (!max || stamp(d, t) <= max)

  useEffect(() => {
    if (!focusTick) return
    grid.current?.querySelector<HTMLButtonElement>(`[data-date="${focused.getTime()}"]`)?.focus()
  }, [focusTick, focused, view])

  useEffect(() => {
    const target = initialTime || slots[Math.floor((9 * 60) / step)]
    list.current
      ?.querySelector<HTMLElement>(`[data-time="${target}"]`)
      ?.scrollIntoView({ block: 'center' })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  const finish = (d: Date, t: string) => {
    onPick(stamp(d, t))
    close()
  }

  return (
    <>
      <div className="flex gap-3">
        <div ref={grid}>
          <CalendarMonth
            month={view}
            start={day}
            end={day}
            preview={null}
            focused={focused}
            today={today}
            minDate={minDay ?? undefined}
            maxDate={maxDay ?? undefined}
            onSelect={(d) => {
              setDay(d)
              setFocused(d)
              setFocusTick(0)
              if (time && allowed(d, time)) finish(d, time)
            }}
            onHover={() => {}}
            locale={locale}
            onKeyDown={(event, date) => {
              const offset = keyOffsets[event.key]
              if (offset === undefined) return
              event.preventDefault()
              const next = addDays(date, offset)
              setFocused(next)
              setFocusTick(focusTick + 1)
              if (!sameMonth(next, view)) setView(startOfMonth(next))
            }}
            header={
              <>
                <NavButton
                  direction={-1}
                  label={label(labels, 'previousMonth')}
                  onClick={() => setView(startOfMonth(view, -1))}
                />
                <span className="flex-1 text-center text-sm font-semibold">
                  {monthTitle(view, locale)}
                </span>
                <NavButton
                  direction={1}
                  label={label(labels, 'nextMonth')}
                  onClick={() => setView(startOfMonth(view, 1))}
                />
              </>
            }
          />
        </div>
        <div className="flex w-32 flex-col gap-2 border-l pl-3">
          <span className="flex h-8 items-center text-sm font-semibold">
            {label(labels, 'time')}
          </span>
          <ul
            ref={list}
            role="listbox"
            aria-label="Times"
            className="-mr-1 flex max-h-[17rem] [scrollbar-width:thin] [scrollbar-gutter:stable] flex-col gap-px overflow-y-auto py-0.5 pr-1.5"
          >
            {slots.map((t) => {
              const ok = !day || allowed(day, t)
              return (
                <li key={t}>
                  <button
                    type="button"
                    role="option"
                    data-time={t}
                    aria-selected={t === time}
                    disabled={!ok}
                    onClick={() => {
                      setTime(t)
                      if (day) finish(day, t)
                    }}
                    className={cn(
                      'flex h-8 w-full items-center rounded-lg px-2 text-sm tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-not-allowed disabled:opacity-30',
                      t === time ? 'bg-primary text-primary-foreground' : 'hover:bg-muted',
                    )}
                  >
                    {formatTime(t, locale)}
                  </button>
                </li>
              )
            })}
          </ul>
        </div>
      </div>
      <PickerFooter>
        <span className="text-xs text-muted-foreground">
          {day && time
            ? formatDateTime(stamp(day, time), locale)
            : day
              ? label(labels, 'pickTimeNext')
              : time
                ? label(labels, 'pickDayNext')
                : label(labels, 'pickDayTime')}
        </span>
        <div className="flex gap-1">
          <FooterButton
            onClick={() => {
              const now = new Date()
              const n = Math.ceil((now.getHours() * 60 + now.getMinutes()) / step) * step
              const t = `${pad(Math.floor(Math.min(n, 1439) / 60))}:${pad(Math.min(n, 1439) % 60)}`
              finish(startOfDay(now), t)
            }}
          >
            {label(labels, 'now')}
          </FooterButton>
          <FooterButton
            onClick={() => {
              onPick('')
              close()
            }}
            disabled={!value}
          >
            {label(labels, 'clear')}
          </FooterButton>
        </div>
      </PickerFooter>
    </>
  )
}
locale.ts
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import localizedFormat from 'dayjs/plugin/localizedFormat'

dayjs.extend(customParseFormat)
dayjs.extend(localizedFormat)

export { dayjs }

export const pickerLabels = {
  today: 'Today',
  now: 'Now',
  thisMonth: 'This month',
  clear: 'Clear',
  apply: 'Apply',
  time: 'Time',
  start: 'Start',
  end: 'End',
  startDate: 'Start date',
  endDate: 'End date',
  typeDate: 'Type a date, e.g. {example}',
  typeTime: 'Type a time, e.g. {example}',
  pickDate: 'Pick a date',
  pickTime: 'Pick a time',
  pickMonth: 'Pick a month',
  pickDates: 'Pick dates',
  pickDateTime: 'Pick a date and time',
  pickStart: 'Pick the start date',
  pickEnd: 'Now pick the end date',
  pickDayTime: 'Pick a day and a time',
  pickTimeNext: 'Now pick a time',
  pickDayNext: 'Now pick a day',
  use: 'Use {time}',
  noTimes: 'No matching times',
  previousMonth: 'Previous month',
  nextMonth: 'Next month',
  previousYear: 'Previous year',
  nextYear: 'Next year',
  chooseDate: 'Choose a date',
  chooseTime: 'Choose a time',
  chooseMonth: 'Choose a month',
  chooseRange: 'Choose a date range',
  chooseDateTime: 'Choose a date and time',
  clearValue: 'Clear',
}

export type PickerLabels = typeof pickerLabels

export const label = (
  labels: Partial<PickerLabels> | undefined,
  key: keyof PickerLabels,
  vars: Record<string, string> = {},
) => (labels?.[key] ?? pickerLabels[key]).replace(/\{(\w+)\}/g, (_, k: string) => vars[k] ?? '')

const DATE_INPUTS = [
  'YYYY-MM-DD',
  'D/M/YYYY',
  'DD/MM/YYYY',
  'M/D/YYYY',
  'MM/DD/YYYY',
  'D.M.YYYY',
  'D MMM YYYY',
  'D MMMM YYYY',
  'MMM D YYYY',
  'MMM D, YYYY',
  'MMMM D YYYY',
  'MMMM D, YYYY',
]

const TIME_INPUTS = [
  'H:mm',
  'HH:mm',
  'h:mm a',
  'h:mma',
  'h a',
  'ha',
  'hmm a',
  'hmma',
  'HHmm',
  'Hmm',
  'H',
]

export function parseDay(text: string, locale?: string): Date | null {
  const t = text.trim()
  if (!t) return null
  for (const format of DATE_INPUTS) {
    const d = locale ? dayjs(t, format, locale, true) : dayjs(t, format, true)
    if (d.isValid()) return d.startOf('day').toDate()
  }
  return null
}

export function parseClock(text: string, locale?: string): string | null {
  const t = text
    .trim()
    .toLowerCase()
    .replace(/\.(?=m)/g, '')
    .replace(/([ap])$/, '$1m')
  if (!t) return null
  for (const format of TIME_INPUTS) {
    const d = locale ? dayjs(t, format, locale, true) : dayjs(t, format, true)
    if (d.isValid()) return d.format('HH:mm')
  }
  return null
}

export const formatDay = (date: Date, locale?: string) =>
  (locale ? dayjs(date).locale(locale) : dayjs(date)).format('ll')

export const formatClock = (hhmm: string, locale?: string) => {
  const d = dayjs(hhmm, 'HH:mm', true)
  return d.isValid() ? (locale ? d.locale(locale) : d).format('LT') : hhmm
}

export const formatMonthYear = (date: Date, locale?: string) =>
  (locale ? dayjs(date).locale(locale) : dayjs(date)).format('MMM YYYY')

export const monthName = (date: Date, locale?: string) =>
  (locale ? dayjs(date).locale(locale) : dayjs(date)).format('MMMM YYYY')

export const shortMonths = (locale?: string) =>
  Array.from({ length: 12 }, (_, i) =>
    (locale ? dayjs().locale(locale) : dayjs()).month(i).format('MMM'),
  )

export const fullMonth = (year: number, month: number, locale?: string) =>
  (locale ? dayjs().locale(locale) : dayjs()).year(year).month(month).format('MMMM YYYY')
month-picker.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { CalendarDays } from 'lucide-react'

import { cn } from '@/lib/utils'

import {
  FooterButton,
  NavButton,
  Picker,
  PickerFooter,
  type PickerBaseProps,
  usePickerClose,
} from './picker'
import { formatMonthYear, fullMonth, label, shortMonths, type PickerLabels } from './locale'

type MonthPickerProps = PickerBaseProps & {
  value: string
  onChange: (value: string) => void
  min?: string
  max?: string
}

const pad = (n: number) => String(n).padStart(2, '0')
const key = (year: number, month: number) => `${year}-${pad(month + 1)}`

export function formatMonth(value: string, locale?: string) {
  const [y, m] = value.split('-').map(Number)
  return y && m ? formatMonthYear(new Date(y, m - 1, 1), locale) : value
}

export function MonthPicker({
  value,
  onChange,
  min,
  max,
  placeholder,
  locale,
  labels,
  ...props
}: MonthPickerProps) {
  return (
    <Picker
      icon={<CalendarDays />}
      text={value ? formatMonth(value, locale) : ''}
      label={label(labels, 'chooseMonth')}
      placeholder={placeholder ?? label(labels, 'pickMonth')}
      labels={labels}
      panelClassName="flex w-64 flex-col gap-3 p-3"
      onClear={() => onChange('')}
      {...props}
    >
      <MonthPanel
        locale={locale}
        labels={labels}
        value={value}
        min={min}
        max={max}
        onPick={onChange}
      />
    </Picker>
  )
}

function MonthPanel({
  locale,
  labels,
  value,
  min,
  max,
  onPick,
}: {
  value: string
  min?: string
  max?: string
  onPick: (value: string) => void
  locale?: string
  labels?: Partial<PickerLabels>
}) {
  const close = usePickerClose()
  const months = shortMonths(locale)
  const done = (v: string) => {
    onPick(v)
    close()
  }
  const [now] = useState(() => new Date())
  const thisMonth = key(now.getFullYear(), now.getMonth())
  const [y, m] = value.split('-').map(Number)
  const [year, setYear] = useState(y || now.getFullYear())
  const [focused, setFocused] = useState(m ? m - 1 : now.getMonth())
  const grid = useRef<HTMLDivElement>(null)

  useEffect(() => {
    grid.current?.querySelector<HTMLButtonElement>(`[data-month="${focused}"]`)?.focus()
  }, [focused, year])

  const allowed = (k: string) => (!min || k >= min) && (!max || k <= max)
  const pick = (k: string) => allowed(k) && done(k)

  return (
    <>
      <div className="flex h-8 items-center justify-between">
        <NavButton
          direction={-1}
          label={label(labels, 'previousYear')}
          onClick={() => setYear(year - 1)}
        />
        <span className="text-sm font-semibold tabular-nums">{year}</span>
        <NavButton
          direction={1}
          label={label(labels, 'nextYear')}
          onClick={() => setYear(year + 1)}
        />
      </div>
      <div ref={grid} role="grid" aria-label={String(year)} className="grid grid-cols-3 gap-1">
        {months.map((name, i) => {
          const k = key(year, i)
          const on = k === value
          return (
            <button
              key={name}
              type="button"
              data-month={i}
              tabIndex={i === focused ? 0 : -1}
              disabled={!allowed(k)}
              aria-pressed={on}
              aria-label={fullMonth(year, i, locale)}
              onClick={() => pick(k)}
              onKeyDown={(e) => {
                const moves: Record<string, number> = {
                  ArrowLeft: -1,
                  ArrowRight: 1,
                  ArrowUp: -3,
                  ArrowDown: 3,
                }
                if (!(e.key in moves)) return
                e.preventDefault()
                const next = i + moves[e.key]
                if (next < 0) setYear(year - 1)
                if (next > 11) setYear(year + 1)
                setFocused((next + 12) % 12)
              }}
              className={cn(
                'h-9 rounded-full text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-not-allowed disabled:opacity-30',
                on
                  ? 'bg-primary text-primary-foreground'
                  : 'hover:bg-muted hover:ring-1 hover:ring-border',
                k === thisMonth && 'font-bold',
              )}
            >
              {name}
            </button>
          )
        })}
      </div>
      <PickerFooter>
        <FooterButton onClick={() => pick(thisMonth)} disabled={!allowed(thisMonth)}>
          {label(labels, 'thisMonth')}
        </FooterButton>
        <FooterButton onClick={() => done('')} disabled={!value}>
          {label(labels, 'clear')}
        </FooterButton>
      </PickerFooter>
    </>
  )
}
picker.tsx
'use client'

import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'

import { cn } from '@/lib/utils'

import { label as labelOf, type PickerLabels } from './locale'

export type PickerBaseProps = {
  id?: string
  placeholder?: string
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  clearable?: boolean
  className?: string
  locale?: string
  labels?: Partial<PickerLabels>
}

const CloseContext = createContext<() => void>(() => {})

export const usePickerClose = () => useContext(CloseContext)

type PickerProps = PickerBaseProps & {
  icon: React.ReactNode
  text?: string
  label: string
  panelClassName?: string
  onClear?: () => void
  children: React.ReactNode
}

export function Picker({
  id,
  icon,
  text,
  label,
  placeholder,
  disabled,
  invalid,
  describedBy,
  clearable = true,
  className,
  panelClassName,
  onClear,
  children,
  labels,
}: PickerProps) {
  const [open, setOpen] = useState(false)
  const [place, setPlace] = useState<{ top: number; left: number } | null>(null)
  const root = useRef<HTMLDivElement>(null)
  const trigger = useRef<HTMLButtonElement>(null)
  const panel = useRef<HTMLDivElement>(null)

  const close = () => {
    setOpen(false)
    trigger.current?.focus()
  }

  useLayoutEffect(() => {
    if (!open) return
    const update = () => {
      if (!panel.current || !trigger.current) return
      const t = trigger.current.getBoundingClientRect()
      const p = panel.current.getBoundingClientRect()
      const gap = 6
      const below = window.innerHeight - t.bottom - gap
      const above = t.top - gap
      const up = below < p.height && above > below
      const left = Math.max(8, Math.min(t.left, window.innerWidth - p.width - 8))
      setPlace({ top: Math.max(8, up ? t.top - gap - p.height : t.bottom + gap), left })
    }
    update()
    window.addEventListener('scroll', update, true)
    window.addEventListener('resize', update)
    return () => {
      window.removeEventListener('scroll', update, true)
      window.removeEventListener('resize', update)
    }
  }, [open])

  useEffect(() => {
    if (!open) return
    const onPointer = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    document.addEventListener('pointerdown', onPointer)
    return () => document.removeEventListener('pointerdown', onPointer)
  }, [open])

  const showClear = clearable && !!text && !disabled && !!onClear

  return (
    <div
      ref={root}
      className={cn('relative w-full min-w-0', className)}
      onKeyDown={(event) => {
        if (event.key !== 'Escape' || !open) return
        event.stopPropagation()
        setOpen(false)
        trigger.current?.focus()
      }}
    >
      <button
        ref={trigger}
        id={id}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-describedby={describedBy}
        data-invalid={invalid || undefined}
        disabled={disabled}
        onClick={() => setOpen(!open)}
        onKeyDown={(e) => {
          if (e.key === 'ArrowDown' && !open) {
            e.preventDefault()
            setOpen(true)
          }
        }}
        className={cn(
          'flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-input bg-card pl-2.5 text-left text-sm whitespace-nowrap text-foreground transition-[border-color] duration-150 outline-none hover:border-ring focus-visible:border-ring disabled:cursor-default disabled:bg-muted/40 disabled:hover:border-input data-invalid:border-destructive [&_svg]:size-3.5 [&_svg]:shrink-0',
          showClear ? 'pr-8' : 'pr-2.5',
          open && 'border-ring',
        )}
      >
        <span className="flex text-muted-foreground">{icon}</span>
        <span className={cn('min-w-0 flex-1 truncate', !text && 'text-muted-foreground')}>
          {text || placeholder}
        </span>
      </button>
      {showClear && (
        <button
          type="button"
          aria-label={labelOf(labels, 'clearValue')}
          onClick={() => {
            onClear()
            trigger.current?.focus()
          }}
          className="absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3"
        >
          <X />
        </button>
      )}
      {open && (
        <div
          ref={panel}
          style={{
            top: place?.top ?? 0,
            left: place?.left ?? 0,
            visibility: place ? 'visible' : 'hidden',
          }}
          role="dialog"
          aria-label={label}
          className={cn(
            'fixed z-[60] rounded-xl bg-popover text-popover-foreground shadow-(--shadow-overlay)',
            panelClassName,
          )}
        >
          <CloseContext value={close}>{children}</CloseContext>
        </div>
      )}
    </div>
  )
}

export function NavButton({
  direction,
  label,
  onClick,
}: {
  direction: -1 | 1
  label: string
  onClick: () => void
}) {
  return (
    <button
      type="button"
      aria-label={label}
      onClick={onClick}
      className="flex size-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
    >
      <svg viewBox="0 0 16 16" fill="none" className="size-4" aria-hidden>
        <path
          d={direction < 0 ? 'M10 3 5 8l5 5' : 'm6 3 5 5-5 5'}
          stroke="currentColor"
          strokeWidth="1.6"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    </button>
  )
}

export function PickerInput({
  value,
  placeholder,
  label,
  invalid,
  autoFocus,
  onChange,
  onCommit,
  onKeyDown,
  inputRef,
}: {
  value: string
  placeholder: string
  label: string
  invalid?: boolean
  autoFocus?: boolean
  onChange: (value: string) => void
  onCommit: () => void
  onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void
  inputRef?: React.Ref<HTMLInputElement>
}) {
  return (
    <input
      ref={inputRef}
      value={value}
      placeholder={placeholder}
      aria-label={label}
      aria-invalid={invalid || undefined}
      autoFocus={autoFocus}
      onChange={(e) => onChange(e.target.value)}
      onKeyDown={(e) => {
        if (e.key === 'Enter') {
          e.preventDefault()
          onCommit()
        }
        onKeyDown?.(e)
      }}
      className="h-8 w-full min-w-0 rounded-lg border border-input bg-card px-2.5 text-sm tabular-nums outline-none placeholder:text-muted-foreground focus:border-ring aria-invalid:border-destructive"
    />
  )
}

export function PickerFooter({ children }: { children: React.ReactNode }) {
  return <div className="flex items-center justify-between gap-2 border-t pt-3">{children}</div>
}

export function FooterButton({
  children,
  onClick,
  disabled,
}: {
  children: React.ReactNode
  onClick: () => void
  disabled?: boolean
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
    >
      {children}
    </button>
  )
}
time-picker.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { Clock } from 'lucide-react'

import { cn } from '@/lib/utils'

import {
  FooterButton,
  Picker,
  PickerFooter,
  PickerInput,
  type PickerBaseProps,
  usePickerClose,
} from './picker'
import { formatClock, label, parseClock, type PickerLabels } from './locale'

type TimePickerProps = PickerBaseProps & {
  value: string
  onChange: (value: string) => void
  step?: number
  min?: string
  max?: string
}

const pad = (n: number) => String(n).padStart(2, '0')
const minutesOf = (t: string) => {
  const [h, m] = t.split(':').map(Number)
  return h * 60 + m
}
const fromMinutes = (n: number) => `${pad(Math.floor(n / 60))}:${pad(n % 60)}`

export function formatTime(hhmm: string, locale?: string) {
  return formatClock(hhmm, locale)
}

export function parseTime(text: string, locale?: string) {
  const viaDayjs = parseClock(text, locale)
  if (viaDayjs) return viaDayjs
  const t = text.trim().toLowerCase().replace(/\s+/g, '').replace(/\./g, '')
  if (!t) return null
  const match =
    /^(\d{1,2})(?::(\d{2}))?(a|p|am|pm)?$/.exec(t) ?? /^(\d{1,2})(\d{2})(a|p|am|pm)?$/.exec(t)
  if (!match) return null
  let h = Number(match[1])
  const m = Number(match[2] ?? 0)
  const meridiem = match[3]?.[0]
  if (meridiem) {
    if (h < 1 || h > 12) return null
    h = (h % 12) + (meridiem === 'p' ? 12 : 0)
  }
  if (h > 23 || m > 59) return null
  return `${pad(h)}:${pad(m)}`
}

export function TimePicker({
  value,
  onChange,
  step = 15,
  min,
  max,
  placeholder,
  locale,
  labels,
  ...props
}: TimePickerProps) {
  return (
    <Picker
      icon={<Clock />}
      text={value ? formatTime(value, locale) : ''}
      label={label(labels, 'chooseTime')}
      placeholder={placeholder ?? label(labels, 'pickTime')}
      labels={labels}
      panelClassName="flex w-56 flex-col gap-2 p-2"
      onClear={() => onChange('')}
      {...props}
    >
      <TimePanel
        locale={locale}
        labels={labels}
        value={value}
        step={step}
        min={min}
        max={max}
        onPick={onChange}
      />
    </Picker>
  )
}

function TimePanel({
  locale,
  labels,
  value,
  step,
  min,
  max,
  onPick,
}: {
  value: string
  step: number
  min?: string
  max?: string
  onPick: (value: string) => void
  locale?: string
  labels?: Partial<PickerLabels>
}) {
  const close = usePickerClose()
  const done = (v: string) => {
    onPick(v)
    close()
  }
  const [typed, setTyped] = useState('')
  const [typedError, setTypedError] = useState(false)
  const [now] = useState(() => {
    const d = new Date()
    const n = Math.round((d.getHours() * 60 + d.getMinutes()) / step) * step
    return fromMinutes(Math.min(n, 1440 - step))
  })
  const list = useRef<HTMLUListElement>(null)
  const input = useRef<HTMLInputElement>(null)

  const allowed = (t: string) => (!min || t >= min) && (!max || t <= max)
  const slots = Array.from({ length: Math.ceil(1440 / step) }, (_, i) =>
    fromMinutes(i * step),
  ).filter(allowed)

  const q = typed.trim().toLowerCase().replace(/\s/g, '')
  const exact = parseTime(typed, locale)
  const shown = q
    ? slots.filter(
        (s) =>
          formatTime(s, locale).toLowerCase().replace(/\s/g, '').startsWith(q) || s.startsWith(q),
      )
    : slots

  const [anchor] = useState(() => {
    const target = minutesOf(value || now)
    return slots.reduce(
      (best, s) =>
        Math.abs(minutesOf(s) - target) < Math.abs(minutesOf(best) - target) ? s : best,
      slots[0],
    )
  })

  useEffect(() => {
    list.current
      ?.querySelector<HTMLElement>(`[data-time="${anchor}"]`)
      ?.scrollIntoView({ block: 'center' })
  }, [anchor])

  const pick = (t: string) => (allowed(t) ? done(t) : setTypedError(true))

  const focusSlot = (index: number) => {
    const buttons = list.current?.querySelectorAll<HTMLButtonElement>('button')
    if (!buttons?.length) return
    buttons[Math.max(0, Math.min(buttons.length - 1, index))].focus()
  }

  return (
    <>
      <PickerInput
        inputRef={input}
        autoFocus
        value={typed}
        placeholder={label(labels, 'typeTime', { example: formatTime('14:30', locale) })}
        label={label(labels, 'chooseTime')}
        invalid={typedError}
        onChange={(v) => {
          setTyped(v)
          setTypedError(false)
        }}
        onCommit={() => {
          const t = exact ?? shown[0]
          if (t) pick(t)
          else setTypedError(true)
        }}
        onKeyDown={(e) => {
          if (e.key !== 'ArrowDown') return
          e.preventDefault()
          const index = shown.indexOf(value || anchor)
          focusSlot(index >= 0 ? index : 0)
        }}
      />
      {exact && !shown.includes(exact) && allowed(exact) && (
        <button
          type="button"
          onClick={() => pick(exact)}
          className="flex h-8 items-center rounded-lg px-2.5 text-left text-sm hover:bg-muted"
        >
          {label(labels, 'use', { time: formatTime(exact, locale) })}
        </button>
      )}
      <ul
        ref={list}
        role="listbox"
        aria-label="Times"
        className="flex max-h-56 [scrollbar-width:thin] [scrollbar-gutter:stable] flex-col gap-px overflow-y-auto py-0.5 pr-1.5"
        onKeyDown={(e) => {
          const moves: Record<string, number> = {
            ArrowDown: 1,
            ArrowUp: -1,
            PageDown: 8,
            PageUp: -8,
          }
          if (!(e.key in moves)) return
          e.preventDefault()
          const buttons = [...(list.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])]
          const index = buttons.indexOf(e.target as HTMLButtonElement)
          if (index <= 0 && moves[e.key] < 0) return input.current?.focus()
          focusSlot(index + moves[e.key])
        }}
      >
        {shown.map((t) => (
          <li key={t}>
            <button
              type="button"
              role="option"
              data-time={t}
              aria-selected={t === value}
              onClick={() => pick(t)}
              className={cn(
                'flex h-8 w-full items-center justify-between rounded-lg px-2.5 text-sm tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring/60',
                t === value ? 'bg-primary text-primary-foreground' : 'hover:bg-muted',
              )}
            >
              {formatTime(t, locale)}
              {t === now && t !== value && (
                <span className="text-xs text-muted-foreground">Now</span>
              )}
            </button>
          </li>
        ))}
        {!shown.length && !exact && (
          <li className="px-2.5 py-2 text-sm text-muted-foreground">{label(labels, 'noTimes')}</li>
        )}
      </ul>
      <PickerFooter>
        <FooterButton onClick={() => pick(now)} disabled={!allowed(now)}>
          {label(labels, 'now')}
        </FooterButton>
        <FooterButton onClick={() => done('')} disabled={!value}>
          {label(labels, 'clear')}
        </FooterButton>
      </PickerFooter>
    </>
  )
}