Zubeyr··ComponentsinputsdateDate 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-reactfrom npm. - →Installs
dayjsfrom 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.
pnpm add lucide-react dayjsUsage
'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
'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>
</>
)
}'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>
</>
)
}'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>
</>
)
}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')'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>
</>
)
}'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>
)
}'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>
</>
)
}