485 lines
16 KiB
JavaScript
485 lines
16 KiB
JavaScript
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
const MONTH_NAMES = [
|
|
'January', 'February', 'March', 'April', 'May', 'June',
|
|
'July', 'August', 'September', 'October', 'November', 'December',
|
|
]
|
|
|
|
const DAY_ABBR = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
|
|
|
function pad(value) {
|
|
return String(value).padStart(2, '0')
|
|
}
|
|
|
|
function daysInMonth(year, month) {
|
|
return new Date(year, month + 1, 0).getDate()
|
|
}
|
|
|
|
function firstWeekday(year, month) {
|
|
const day = new Date(year, month, 1).getDay()
|
|
return (day + 6) % 7
|
|
}
|
|
|
|
function toISODate(date) {
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
}
|
|
|
|
function parseDatePart(value) {
|
|
if (!value) return null
|
|
|
|
const [year, month, day] = value.split('-').map(Number)
|
|
|
|
if (!year || !month || !day) return null
|
|
|
|
return new Date(year, month - 1, day)
|
|
}
|
|
|
|
function splitDateTime(value) {
|
|
if (!value) {
|
|
return { date: '', time: '' }
|
|
}
|
|
|
|
const [date = '', time = ''] = String(value).split('T')
|
|
|
|
return {
|
|
date,
|
|
time: time.slice(0, 5),
|
|
}
|
|
}
|
|
|
|
function mergeDateTime(date, time) {
|
|
if (!date) return ''
|
|
return `${date}T${time || '00:00'}`
|
|
}
|
|
|
|
function maxDateValue(a, b) {
|
|
if (!a) return b || ''
|
|
if (!b) return a || ''
|
|
return a > b ? a : b
|
|
}
|
|
|
|
function minDateValue(a, b) {
|
|
if (!a) return b || ''
|
|
if (!b) return a || ''
|
|
return a < b ? a : b
|
|
}
|
|
|
|
function clampTimeToBounds(date, time, minDateTime, maxDateTime) {
|
|
const nextTime = time || '00:00'
|
|
const minParts = splitDateTime(minDateTime)
|
|
const maxParts = splitDateTime(maxDateTime)
|
|
|
|
if (date && minParts.date === date && minParts.time && nextTime < minParts.time) {
|
|
return minParts.time
|
|
}
|
|
|
|
if (date && maxParts.date === date && maxParts.time && nextTime > maxParts.time) {
|
|
return maxParts.time
|
|
}
|
|
|
|
return nextTime
|
|
}
|
|
|
|
function formatDisplay(value) {
|
|
if (!value) return ''
|
|
|
|
const { date, time } = splitDateTime(value)
|
|
const parsed = parseDatePart(date)
|
|
|
|
if (!parsed) return ''
|
|
|
|
return `${MONTH_NAMES[parsed.getMonth()].slice(0, 3)} ${parsed.getDate()}, ${parsed.getFullYear()}${time ? ` at ${time}` : ''}`
|
|
}
|
|
|
|
function isSameDay(a, b) {
|
|
return a?.getFullYear() === b?.getFullYear()
|
|
&& a?.getMonth() === b?.getMonth()
|
|
&& a?.getDate() === b?.getDate()
|
|
}
|
|
|
|
function CalendarGrid({ year, month, selectedDate, onSelect, minDate, maxDate }) {
|
|
const count = daysInMonth(year, month)
|
|
const start = firstWeekday(year, month)
|
|
const prevMonth = month - 1 < 0 ? 11 : month - 1
|
|
const prevYear = month - 1 < 0 ? year - 1 : year
|
|
const prevCount = daysInMonth(prevYear, prevMonth)
|
|
const cells = []
|
|
|
|
for (let index = start - 1; index >= 0; index -= 1) {
|
|
cells.push({
|
|
day: prevCount - index,
|
|
current: false,
|
|
date: new Date(prevYear, prevMonth, prevCount - index),
|
|
})
|
|
}
|
|
|
|
for (let day = 1; day <= count; day += 1) {
|
|
cells.push({ day, current: true, date: new Date(year, month, day) })
|
|
}
|
|
|
|
let nextDay = 1
|
|
while (cells.length % 7 !== 0) {
|
|
cells.push({ day: nextDay, current: false, date: new Date(year, month + 1, nextDay) })
|
|
nextDay += 1
|
|
}
|
|
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
|
|
return (
|
|
<div className="p-3">
|
|
<div className="mb-1 grid grid-cols-7">
|
|
{DAY_ABBR.map((day) => (
|
|
<div key={day} className="py-1 text-center text-[10px] font-semibold text-slate-500">{day}</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-y-0.5">
|
|
{cells.map((cell, index) => {
|
|
const iso = toISODate(cell.date)
|
|
const selected = isSameDay(cell.date, selectedDate)
|
|
const todayCell = isSameDay(cell.date, today)
|
|
const disabled = (minDate && iso < minDate) || (maxDate && iso > maxDate)
|
|
|
|
return (
|
|
<button
|
|
key={`${iso}-${index}`}
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => onSelect(iso)}
|
|
className={[
|
|
'relative mx-auto flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-all',
|
|
!cell.current ? 'text-slate-600' : '',
|
|
cell.current && !selected && !disabled ? 'text-white hover:bg-white/10' : '',
|
|
selected ? 'bg-accent font-semibold text-white shadow shadow-accent/30' : '',
|
|
todayCell && !selected ? 'text-accent ring-1 ring-accent/50' : '',
|
|
disabled ? 'cursor-not-allowed opacity-30' : 'cursor-pointer',
|
|
].join(' ')}
|
|
>
|
|
{cell.day}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function DateTimePicker({
|
|
value = '',
|
|
onChange,
|
|
label,
|
|
placeholder,
|
|
error,
|
|
hint,
|
|
required = false,
|
|
clearable = false,
|
|
id,
|
|
disabled = false,
|
|
mode = 'datetime',
|
|
minDate,
|
|
maxDate,
|
|
minDateTime,
|
|
maxDateTime,
|
|
className = '',
|
|
}) {
|
|
const today = new Date()
|
|
const initial = splitDateTime(value)
|
|
const initialDate = parseDatePart(initial.date) || today
|
|
|
|
const [open, setOpen] = useState(false)
|
|
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 320 })
|
|
const [viewYear, setViewYear] = useState(initialDate.getFullYear())
|
|
const [viewMonth, setViewMonth] = useState(initialDate.getMonth())
|
|
const [draftDate, setDraftDate] = useState(initial.date)
|
|
const [draftTime, setDraftTime] = useState(initial.time || '12:00')
|
|
const effectivePlaceholder = placeholder || (mode === 'date' ? 'Pick a date' : 'Pick a date and time')
|
|
|
|
const triggerRef = useRef(null)
|
|
const inputId = id ?? (label ? `dtp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-time-picker')
|
|
const panelId = `dtp-panel-${inputId}`
|
|
|
|
useEffect(() => {
|
|
const next = splitDateTime(value)
|
|
setDraftDate(next.date)
|
|
setDraftTime(next.time || '12:00')
|
|
|
|
const nextDate = parseDatePart(next.date)
|
|
if (nextDate) {
|
|
setViewYear(nextDate.getFullYear())
|
|
setViewMonth(nextDate.getMonth())
|
|
}
|
|
}, [value])
|
|
|
|
const measure = useCallback(() => {
|
|
if (!triggerRef.current) return
|
|
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
const panelWidth = Math.max(rect.width, 320)
|
|
const panelHeight = 420
|
|
const openUp = window.innerHeight - rect.bottom < panelHeight + 8 && rect.top > panelHeight + 8
|
|
|
|
setDropPos({
|
|
top: openUp ? rect.top - panelHeight - 4 : rect.bottom + 4,
|
|
left: Math.min(rect.left, window.innerWidth - panelWidth - 8),
|
|
width: panelWidth,
|
|
})
|
|
}, [])
|
|
|
|
const openPicker = useCallback(() => {
|
|
if (disabled) return
|
|
measure()
|
|
setOpen(true)
|
|
}, [disabled, measure])
|
|
|
|
useEffect(() => {
|
|
if (!open) return undefined
|
|
|
|
const handleMouseDown = (event) => {
|
|
if (!triggerRef.current?.contains(event.target) && !document.getElementById(panelId)?.contains(event.target)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleMouseDown)
|
|
return () => document.removeEventListener('mousedown', handleMouseDown)
|
|
}, [open, panelId])
|
|
|
|
useEffect(() => {
|
|
if (!open) return undefined
|
|
|
|
const handleScroll = (event) => {
|
|
if (document.getElementById(panelId)?.contains(event.target)) return
|
|
setOpen(false)
|
|
}
|
|
|
|
const handleResize = () => setOpen(false)
|
|
|
|
window.addEventListener('scroll', handleScroll, true)
|
|
window.addEventListener('resize', handleResize)
|
|
|
|
return () => {
|
|
window.removeEventListener('scroll', handleScroll, true)
|
|
window.removeEventListener('resize', handleResize)
|
|
}
|
|
}, [open, panelId])
|
|
|
|
const applyValue = useCallback((date, time) => {
|
|
if (!date) {
|
|
onChange?.('')
|
|
return
|
|
}
|
|
|
|
onChange?.(mode === 'date' ? date : mergeDateTime(date, time))
|
|
}, [mode, onChange])
|
|
|
|
const handleDateSelect = (nextDate) => {
|
|
const nextTime = clampTimeToBounds(nextDate, draftTime, minDateTime, maxDateTime)
|
|
setDraftDate(nextDate)
|
|
setDraftTime(nextTime)
|
|
applyValue(nextDate, nextTime)
|
|
}
|
|
|
|
const handleTimeChange = (event) => {
|
|
const nextTime = clampTimeToBounds(draftDate, event.target.value, minDateTime, maxDateTime)
|
|
setDraftTime(nextTime)
|
|
applyValue(draftDate, nextTime)
|
|
}
|
|
|
|
const clearValue = (event) => {
|
|
event.stopPropagation()
|
|
setDraftDate('')
|
|
setDraftTime('12:00')
|
|
onChange?.('')
|
|
}
|
|
|
|
const prevMonth = () => {
|
|
if (viewMonth === 0) {
|
|
setViewMonth(11)
|
|
setViewYear((current) => current - 1)
|
|
return
|
|
}
|
|
|
|
setViewMonth((current) => current - 1)
|
|
}
|
|
|
|
const nextMonth = () => {
|
|
if (viewMonth === 11) {
|
|
setViewMonth(0)
|
|
setViewYear((current) => current + 1)
|
|
return
|
|
}
|
|
|
|
setViewMonth((current) => current + 1)
|
|
}
|
|
|
|
const triggerClass = [
|
|
'relative flex h-[42px] w-full cursor-pointer items-center gap-2 rounded-xl border px-3.5 text-sm transition-all duration-150',
|
|
'bg-white/[0.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
|
|
error
|
|
? 'border-red-500/60 focus-visible:ring-red-500/40'
|
|
: open
|
|
? 'border-accent/50 ring-2 ring-accent/40'
|
|
: 'border-white/12 hover:border-white/22',
|
|
disabled ? 'pointer-events-none cursor-not-allowed opacity-50' : '',
|
|
className,
|
|
].join(' ')
|
|
|
|
const selectedDate = parseDatePart(draftDate)
|
|
const minDateTimeParts = splitDateTime(minDateTime)
|
|
const maxDateTimeParts = splitDateTime(maxDateTime)
|
|
const effectiveMinDate = maxDateValue(minDate, minDateTimeParts.date)
|
|
const effectiveMaxDate = minDateValue(maxDate, maxDateTimeParts.date)
|
|
const minTime = draftDate && draftDate === minDateTimeParts.date ? minDateTimeParts.time || undefined : undefined
|
|
const maxTime = draftDate && draftDate === maxDateTimeParts.date ? maxDateTimeParts.time || undefined : undefined
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
{label && (
|
|
<label htmlFor={inputId} className="text-sm font-medium text-white/85 select-none">
|
|
{label}
|
|
{required && <span className="ml-1 text-red-400">*</span>}
|
|
</label>
|
|
)}
|
|
|
|
<div
|
|
ref={triggerRef}
|
|
id={inputId}
|
|
role="button"
|
|
tabIndex={disabled ? -1 : 0}
|
|
aria-label={label ?? effectivePlaceholder}
|
|
className={triggerClass}
|
|
onClick={openPicker}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
openPicker()
|
|
}
|
|
}}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0 text-slate-500" aria-hidden="true">
|
|
<rect x="1" y="2.5" width="12" height="10.5" rx="1.5" stroke="currentColor" strokeWidth="1.3" />
|
|
<path d="M1 6h12" stroke="currentColor" strokeWidth="1.3" />
|
|
<path d="M4 1v3M10 1v3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
|
<circle cx="4.5" cy="9" r="0.75" fill="currentColor" />
|
|
<circle cx="7" cy="9" r="0.75" fill="currentColor" />
|
|
<circle cx="9.5" cy="9" r="0.75" fill="currentColor" />
|
|
</svg>
|
|
|
|
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
|
|
{value ? formatDisplay(value) : effectivePlaceholder}
|
|
</span>
|
|
|
|
{clearable && value && (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
onClick={clearValue}
|
|
className="flex h-5 w-5 items-center justify-center rounded text-slate-500 transition-colors hover:text-white"
|
|
aria-label="Clear date and time"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
|
|
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
|
|
|
|
{open && createPortal(
|
|
<div
|
|
id={panelId}
|
|
className="fixed z-[500] overflow-hidden rounded-2xl border border-white/12 bg-nova-900 shadow-2xl shadow-black/50"
|
|
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
|
|
>
|
|
<div className="flex items-center justify-between px-3 pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={prevMonth}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white"
|
|
aria-label="Previous month"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
<path d="M7 1L3 5l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
|
|
<span className="text-sm font-semibold text-white">{MONTH_NAMES[viewMonth]} {viewYear}</span>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={nextMonth}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white"
|
|
aria-label="Next month"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
<path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<CalendarGrid
|
|
year={viewYear}
|
|
month={viewMonth}
|
|
selectedDate={selectedDate}
|
|
onSelect={handleDateSelect}
|
|
minDate={effectiveMinDate}
|
|
maxDate={effectiveMaxDate}
|
|
/>
|
|
|
|
<div className="border-t border-white/8 px-4 py-3">
|
|
<div className={`grid gap-3 ${mode === 'date' ? '' : 'sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end'}`}>
|
|
<div>
|
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected date</div>
|
|
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white">
|
|
{draftDate ? formatDisplay(draftDate) : 'Pick a day'}
|
|
</div>
|
|
</div>
|
|
|
|
{mode !== 'date' ? (
|
|
<label className="grid gap-1.5 text-sm text-slate-300">
|
|
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
|
|
<input
|
|
type="time"
|
|
value={draftTime}
|
|
onChange={handleTimeChange}
|
|
min={minTime}
|
|
max={maxTime}
|
|
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDateSelect(toISODate(new Date()))}
|
|
className="text-xs font-medium text-accent transition-colors hover:text-accent/80"
|
|
>
|
|
Today
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-white/[0.08]"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)
|
|
} |