Date Picker
EcsightTypeable DateInput, DatePicker and DateRangePicker (presets incl. ปีงบประมาณ, hover preview, max span).
Motion: Popover springs in; the range closes itself once both ends are picked.
ติดตั้ง
npx shadcn@latest add @ecsight/date-pickerSource
components/ui/date-picker.tsx
"use client"
import * as React from "react"
import { dateMatchModifiers, type DateRange, type Matcher } from "@daypicker/react"
import {
addDays,
differenceInCalendarDays,
endOfDay,
endOfMonth,
isSameDay,
startOfDay,
startOfMonth,
startOfQuarter,
startOfYear,
subDays,
subMonths,
} from "date-fns"
import { CalendarIcon, CloseCircleIcon } from "@solar-icons/react/linear"
import { cn } from "cn"
import { formatDate, formatDateRange, parseThaiDate } from "@/lib/format"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
type Era = "be" | "ce"
type TriggerProps = {
id?: string
className?: string
era?: Era
disabled?: boolean
"aria-invalid"?: boolean
"aria-describedby"?: string
}
/** Calendar options every picker passes through */
type CalendarOptions = {
/** Days that can't be picked, e.g. `{ after: new Date() }` (any DayPicker matcher) */
disabledDays?: Matcher | Matcher[]
/** `"dropdown"` adds month/year selects — use for dates far away (birthdays, contracts) */
captionLayout?: "label" | "dropdown" | "dropdown-months" | "dropdown-years"
/** First/last month reachable (also bounds the year dropdown) */
startMonth?: Date
endMonth?: Date
}
/* ─── DatePicker (button) ──────────────────────────────────────────────── */
/**
* One date from a button + popover — best for filters and short pickers.
* For form fields people fill in all day, prefer `DateInput` (typeable).
*/
function DatePicker({
value,
onChange,
placeholder = "เลือกวันที่",
era = "be",
clearable = true,
disabledDays,
captionLayout,
startMonth,
endMonth,
className,
...trigger
}: TriggerProps &
CalendarOptions & {
value?: Date
onChange?: (date: Date | undefined) => void
placeholder?: string
/** Show a × to clear the value (default true) */
clearable?: boolean
}) {
const [open, setOpen] = React.useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<TriggerFrame
className={className}
onClear={clearable && value && !trigger.disabled ? () => onChange?.(undefined) : undefined}
>
<PopoverTrigger asChild>
<DateTrigger {...trigger} empty={!value}>
{value ? formatDate(value, { era }) : placeholder}
</DateTrigger>
</PopoverTrigger>
</TriggerFrame>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
era={era}
selected={value}
defaultMonth={value}
disabled={disabledDays}
captionLayout={captionLayout}
startMonth={startMonth}
endMonth={endMonth}
onSelect={(d) => {
onChange?.(d)
setOpen(false)
}}
autoFocus
/>
</PopoverContent>
</Popover>
)
}
/* ─── DateInput (typeable) ─────────────────────────────────────────────── */
/**
* Typeable date field with a calendar button. Understands what people type —
* `1/10/2569`, `01-10-69`, `01102569`, `1 ต.ค. 69`, Thai digits, พ.ศ. or ค.ศ. —
* and rewrites it as `1 ต.ค. 2569` on blur or Enter. Text that isn't a valid
* (or allowed) date stays as typed, is marked invalid and reports `undefined`,
* so a required-field schema can show the message.
*/
function DateInput({
value,
onChange,
onBlur,
placeholder = "วว/ดด/ปปปป",
era = "be",
disabledDays,
captionLayout,
startMonth,
endMonth,
className,
id,
disabled,
"aria-invalid": ariaInvalid,
"aria-describedby": describedBy,
name,
}: TriggerProps &
CalendarOptions & {
value?: Date
onChange?: (date: Date | undefined) => void
/** Forwarded from react-hook-form's `field` to mark the field touched */
onBlur?: () => void
placeholder?: string
name?: string
}) {
const [open, setOpen] = React.useState(false)
// null = show the formatted value; a string = what the user is typing
const [draft, setDraft] = React.useState<string | null>(null)
const [badDraft, setBadDraft] = React.useState(false)
const text = draft ?? (value ? formatDate(value, { era }) : "")
const allowed = (d: Date) => !disabledDays || !dateMatchModifiers(d, disabledDays)
function commit() {
if (draft === null) return
if (draft.trim() === "") {
setDraft(null)
setBadDraft(false)
return onChange?.(undefined)
}
const parsed = parseThaiDate(draft, { era })
if (parsed && allowed(parsed)) {
setDraft(null)
setBadDraft(false)
onChange?.(parsed)
} else {
setBadDraft(true)
onChange?.(undefined)
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<InputGroup data-slot="date-input" className={className}>
<InputGroupInput
id={id}
name={name}
value={text}
placeholder={placeholder}
disabled={disabled}
inputMode="text"
autoComplete="off"
aria-invalid={ariaInvalid || badDraft}
aria-describedby={describedBy}
className="tabular-nums"
onChange={(e) => {
setDraft(e.target.value)
setBadDraft(false)
}}
onBlur={() => {
commit()
onBlur?.()
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
commit()
}
if (e.key === "ArrowDown" && e.altKey) setOpen(true)
}}
/>
<InputGroupAddon align="inline-end">
<PopoverTrigger asChild>
<InputGroupButton size="icon-xs" disabled={disabled} aria-label="เปิดปฏิทิน">
<CalendarIcon />
</InputGroupButton>
</PopoverTrigger>
</InputGroupAddon>
</InputGroup>
</PopoverAnchor>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
era={era}
selected={value}
defaultMonth={value}
disabled={disabledDays}
captionLayout={captionLayout}
startMonth={startMonth}
endMonth={endMonth}
onSelect={(d) => {
setDraft(null)
setBadDraft(false)
onChange?.(d)
setOpen(false)
}}
autoFocus
/>
</PopoverContent>
</Popover>
)
}
/* ─── DateRangePicker ──────────────────────────────────────────────────── */
export type DateRangePreset = { key: string; label: string; range: () => { from: Date; to: Date } }
/** Thai government fiscal year: 1 Oct – 30 Sep (ปีงบ 2570 starts 1 ต.ค. 2569) */
const fiscalYearStart = (d: Date) => new Date(d.getMonth() >= 9 ? d.getFullYear() : d.getFullYear() - 1, 9, 1)
/**
* Standard presets relative to `now` (default: the real current time).
* Pass a fixed date when the data has a known end, e.g. demos and reports.
*/
export function createDateRangePresets(now: () => Date = () => new Date()): DateRangePreset[] {
const today = () => startOfDay(now())
const untilToday = (from: Date) => ({ from, to: endOfDay(today()) })
return [
{ key: "today", label: "วันนี้", range: () => untilToday(today()) },
{ key: "7d", label: "7 วันล่าสุด", range: () => untilToday(subDays(today(), 6)) },
{ key: "30d", label: "30 วันล่าสุด", range: () => untilToday(subDays(today(), 29)) },
{ key: "mtd", label: "เดือนนี้", range: () => untilToday(startOfMonth(today())) },
{
key: "last-month",
label: "เดือนที่แล้ว",
range: () => ({ from: startOfMonth(subMonths(today(), 1)), to: endOfMonth(subMonths(today(), 1)) }),
},
{ key: "qtd", label: "ไตรมาสนี้", range: () => untilToday(startOfQuarter(today())) },
{ key: "ytd", label: "ปีนี้", range: () => untilToday(startOfYear(today())) },
{ key: "fytd", label: "ปีงบประมาณนี้", range: () => untilToday(fiscalYearStart(today())) },
]
}
export const defaultDateRangePresets = createDateRangePresets()
function matchPreset(value: DateRange | undefined, presets: DateRangePreset[]) {
const { from, to } = value ?? {}
if (!from || !to) return undefined
return presets.find((p) => {
const r = p.range()
return isSameDay(r.from, from) && isSameDay(r.to, to)
})
}
const ordered = (a: Date, b: Date) => (a <= b ? { from: a, to: b } : { from: b, to: a })
/**
* Date range with presets. Click a start day, then an end day (either order);
* the range previews under the pointer (or keyboard focus) and the popover
* closes once both ends are picked. Presets apply immediately.
* Two months on desktop, one on mobile.
*/
function DateRangePicker({
value,
onChange,
presets = defaultDateRangePresets,
placeholder = "เลือกช่วงวันที่",
era = "be",
align = "start",
clearable = true,
maxDays,
disabledDays,
captionLayout,
startMonth,
endMonth,
className,
...trigger
}: TriggerProps &
CalendarOptions & {
value?: DateRange
onChange?: (range: DateRange | undefined) => void
/** Pass `[]` to hide the preset list */
presets?: DateRangePreset[]
placeholder?: string
align?: "start" | "center" | "end"
/** Show a × to clear the range (default true). Turn off when "no range" isn't a valid state. */
clearable?: boolean
/** Longest range allowed, in days (inclusive). Presets longer than this are hidden. */
maxDays?: number
}) {
const isMobile = useIsMobile()
const [open, setOpen] = React.useState(false)
// First click picks an anchor; the second click completes the range.
const [anchor, setAnchor] = React.useState<Date>()
const [hovered, setHovered] = React.useState<Date>()
const visiblePresets = maxDays
? presets.filter((p) => {
const r = p.range()
return differenceInCalendarDays(r.to, r.from) + 1 <= maxDays
})
: presets
const active = matchPreset(value, visiblePresets)
// While picking, days farther than maxDays from the anchor are off limits
const disabled: Matcher[] = [
...(disabledDays ? [disabledDays].flat() : []),
...(anchor && maxDays ? [{ before: subDays(anchor, maxDays - 1) }, { after: addDays(anchor, maxDays - 1) }] : []),
]
const reset = () => {
setAnchor(undefined)
setHovered(undefined)
}
const commit = (range: DateRange | undefined) => {
onChange?.(range)
reset()
setOpen(false)
}
const label = value?.from
? value.to && !isSameDay(value.from, value.to)
? formatDateRange(value.from, value.to, { era })
: formatDate(value.from, { era })
: placeholder
const preview = anchor ? ordered(anchor, hovered ?? anchor) : undefined
const previewDays = preview ? differenceInCalendarDays(preview.to, preview.from) + 1 : 0
return (
<Popover
open={open}
onOpenChange={(o) => {
setOpen(o)
if (!o) reset()
}}
>
<TriggerFrame
className={className}
onClear={clearable && value?.from && !trigger.disabled ? () => onChange?.(undefined) : undefined}
>
<PopoverTrigger asChild>
<DateTrigger {...trigger} empty={!value?.from}>
{active && <span className="text-muted-foreground">{active.label} ·</span>}
{label}
</DateTrigger>
</PopoverTrigger>
</TriggerFrame>
<PopoverContent className="flex w-auto flex-col p-0 sm:flex-row" align={align}>
{visiblePresets.length > 0 && (
<div
role="group"
aria-label="ช่วงเวลาที่ใช้บ่อย"
className="flex gap-1 overflow-x-auto border-b p-2 [scrollbar-width:none] sm:w-36 sm:flex-col sm:border-r sm:border-b-0"
>
{visiblePresets.map((p) => (
<Button
key={p.key}
variant="ghost"
size="sm"
aria-pressed={active?.key === p.key}
className="shrink-0 justify-start aria-pressed:bg-accent aria-pressed:text-accent-foreground"
onClick={() => commit(p.range())}
>
{p.label}
</Button>
))}
</div>
)}
<div className="grid">
<Calendar
mode="range"
era={era}
numberOfMonths={isMobile ? 1 : 2}
// with two months side by side, outside days would show the same date twice
showOutsideDays={isMobile}
// Two months end on the selected (or current) month — range filters mostly look back
defaultMonth={isMobile ? value?.from : subMonths(value?.to ?? value?.from ?? new Date(), 1)}
selected={preview ?? value}
disabled={disabled}
captionLayout={captionLayout}
startMonth={startMonth}
endMonth={endMonth}
// the in-progress range is lighter than a committed one
className={cn(anchor && "**:data-range-middle:bg-accent/60!")}
onDayMouseEnter={(day) => anchor && setHovered(day)}
onDayFocus={(day) => anchor && setHovered(day)}
onSelect={(_, day) => {
if (!anchor) return setAnchor(day)
const r = ordered(anchor, day)
commit({ from: startOfDay(r.from), to: endOfDay(r.to) })
}}
autoFocus
/>
<p className="px-3 pb-3 text-xs text-muted-foreground tabular-nums" aria-live="polite">
{preview
? `${formatDateRange(preview.from, preview.to, { era })} · ${previewDays} วัน — เลือกวันสิ้นสุด`
: maxDays
? `เลือกวันเริ่มต้น · สูงสุด ${maxDays} วัน`
: "เลือกวันเริ่มต้น"}
</p>
</div>
</PopoverContent>
</Popover>
)
}
/* ─── shared trigger ───────────────────────────────────────────────────── */
/** Positions the optional × next to (not inside) the trigger button — buttons can't nest. */
function TriggerFrame({
className,
onClear,
children,
}: {
className?: string
onClear?: () => void
children: React.ReactNode
}) {
return (
<div data-slot="date-picker" className={cn("group/date relative inline-flex", className)}>
{children}
{onClear && (
<Button
data-slot="date-clear"
variant="ghost"
size="icon-xs"
aria-label="ล้างค่า"
className="absolute top-1/2 right-1.5 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={onClear}
>
<CloseCircleIcon />
</Button>
)}
</div>
)
}
function DateTrigger({
className,
empty,
children,
...props
}: React.ComponentProps<typeof Button> & { empty?: boolean }) {
return (
<Button
variant="outline"
data-empty={empty || undefined}
className={cn(
"w-full justify-start gap-2 font-normal tabular-nums data-empty:text-muted-foreground",
"group-has-[>[data-slot=date-clear]]/date:pr-9",
"aria-expanded:border-ring aria-expanded:ring-3 aria-expanded:ring-ring/50",
className
)}
{...props}
>
<CalendarIcon className="text-muted-foreground" />
<span className="flex min-w-0 items-center gap-1 truncate">{children}</span>
</Button>
)
}
export { DateInput, DatePicker, DateRangePicker }
export type { DateRange }
lib/format.ts
/**
* Ecsight formatters — the one place dates, money and relative times are
* turned into Thai copy. `th-TH` uses the Buddhist calendar (พ.ศ.) and Arabic
* digits by default; pass `era: "ce"` for ค.ศ.
*/
type Era = "be" | "ce"
type DateInput = Date | string | number
const localeFor = (era: Era) => (era === "ce" ? "th-TH-u-ca-gregory" : "th-TH")
const toDate = (d: DateInput) => (d instanceof Date ? d : new Date(d))
const dateStyles = {
/** 1 ต.ค. 69 */
short: { day: "numeric", month: "short", year: "2-digit" },
/** 1 ต.ค. 2569 */
medium: { day: "numeric", month: "short", year: "numeric" },
/** 1 ตุลาคม 2569 */
long: { day: "numeric", month: "long", year: "numeric" },
} satisfies Record<string, Intl.DateTimeFormatOptions>
export type DateStyle = keyof typeof dateStyles
export function formatDate(d: DateInput, { style = "medium", era = "be" }: { style?: DateStyle; era?: Era } = {}) {
return toDate(d).toLocaleDateString(localeFor(era), dateStyles[style])
}
/** "1–7 ต.ค. 2569", "28 ก.ย. – 4 ต.ค. 2569" — collapses shared month/year. */
export function formatDateRange(from: DateInput, to: DateInput, { era = "be" }: { era?: Era } = {}) {
return new Intl.DateTimeFormat(localeFor(era), dateStyles.medium).formatRange(toDate(from), toDate(to))
}
/** 14:05 */
export function formatTime(d: DateInput) {
return toDate(d).toLocaleTimeString("th-TH", { hour: "2-digit", minute: "2-digit" })
}
const thb = new Intl.NumberFormat("th-TH", { style: "currency", currency: "THB", maximumFractionDigits: 0 })
const thbSatang = new Intl.NumberFormat("th-TH", { style: "currency", currency: "THB", minimumFractionDigits: 2 })
/** ฿1,284,500 — pass `satang: true` for ฿1,284,500.00 */
export function formatTHB(n: number, { satang = false }: { satang?: boolean } = {}) {
return (satang ? thbSatang : thb).format(n)
}
const relative = new Intl.RelativeTimeFormat("th", { numeric: "auto" })
const units: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31_536_000],
["month", 2_592_000],
["week", 604_800],
["day", 86_400],
["hour", 3_600],
["minute", 60],
]
/** "3 นาทีที่ผ่านมา", "เมื่อวาน", "ในอีก 2 วัน"; under a minute → "เมื่อสักครู่". */
export function formatRelative(d: DateInput, now: DateInput = Date.now()) {
const seconds = (toDate(d).getTime() - toDate(now).getTime()) / 1000
for (const [unit, size] of units) {
if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit)
}
return "เมื่อสักครู่"
}
/* ─── Date ↔ string for APIs and inputs ──────────────────────────────── */
const pad = (n: number) => String(n).padStart(2, "0")
/**
* "2026-10-01" from the **local** calendar day. Use this for API payloads and
* URLs — `date.toISOString()` goes through UTC, so local midnight in Thailand
* (UTC+7) would come out as the previous day.
*/
export function toISODate(d: Date) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** "2026-10-01" → local midnight (not UTC, unlike `new Date("2026-10-01")`). Invalid → undefined. */
export function parseISODate(s: string) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
return m ? safeDate(Number(m[1]), Number(m[2]), Number(m[3])) : undefined
}
function safeDate(y: number, m: number, d: number) {
const date = new Date(y, m - 1, d)
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d ? date : undefined
}
// Thai month names as Intl prints them — short ("ต.ค.") and long ("ตุลาคม"), dots optional
const thaiMonths = Array.from({ length: 12 }, (_, m) => {
const d = new Date(2026, m, 1)
return [d.toLocaleDateString("th-TH", { month: "short" }), d.toLocaleDateString("th-TH", { month: "long" })].map(
(s) => s.replace(/\./g, "")
)
})
/** Resolve a typed year: ≥ 2400 is พ.ศ.; 2 digits follow `era` (69 → 2569 → 2026, or 26 → 2026). */
function toCE(year: string, era: Era) {
const y = Number(year)
if (year.length <= 2) return era === "be" ? 2500 + y - 543 : 2000 + y
return y >= 2400 ? y - 543 : y
}
/**
* Parse what people actually type into a date field. Accepts Thai or Arabic
* digits, พ.ศ. or ค.ศ. (a 4-digit year ≥ 2400 is พ.ศ.), and:
* `1/10/2569` · `01-10-69` · `1.10.2026` · `01102569` · `1 ต.ค. 2569` · `1 ตุลาคม 69` · `2026-10-01`.
* Returns local midnight, or undefined when the text isn't a real date.
*/
export function parseThaiDate(input: string, { era = "be" }: { era?: Era } = {}): Date | undefined {
const s = input
.trim()
.replace(/[๐-๙]/g, (c) => String(c.charCodeAt(0) - 0x0e50))
.replace(/\s+/g, " ")
if (!s) return undefined
const iso = parseISODate(s)
if (iso) return iso
let m = /^(\d{1,2})[/\-. ](\d{1,2})[/\-. ](\d{2}|\d{4})$/.exec(s)
if (m) return safeDate(toCE(m[3], era), Number(m[2]), Number(m[1]))
m = /^(\d{2})(\d{2})(\d{2}|\d{4})$/.exec(s)
if (m) return safeDate(toCE(m[3], era), Number(m[2]), Number(m[1]))
m = /^(\d{1,2}) ?([ก-๛.]+) ?(\d{2}|\d{4})$/.exec(s)
if (m) {
const name = m[2].replace(/\./g, "")
const month = thaiMonths.findIndex((names) => names.includes(name))
if (month >= 0) return safeDate(toCE(m[3], era), month + 1, Number(m[1]))
}
return undefined
}