mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 01:57:37 +00:00
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { DayPicker } from 'react-day-picker';
|
|
import { ChevronLeft, ChevronRight, Calendar, X } from 'lucide-react';
|
|
import { format, parse, isValid } from 'date-fns';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface DatePickerProps {
|
|
value: string; // YYYY-MM-DD
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
minDate?: Date;
|
|
className?: string;
|
|
}
|
|
|
|
export default function DatePicker({
|
|
value,
|
|
onChange,
|
|
placeholder = 'Pick a date',
|
|
disabled = false,
|
|
minDate,
|
|
className,
|
|
}: DatePickerProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const [popoverPos, setPopoverPos] = useState({ top: 0, left: 0 });
|
|
|
|
useEffect(() => { setMounted(true); }, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [open]);
|
|
|
|
const selected = value
|
|
? parse(value, 'yyyy-MM-dd', new Date())
|
|
: undefined;
|
|
const validSelected = selected && isValid(selected) ? selected : undefined;
|
|
|
|
// Clear the value if it's now before the minDate
|
|
useEffect(() => {
|
|
if (minDate && validSelected && validSelected < minDate) {
|
|
onChange('');
|
|
}
|
|
}, [minDate, validSelected, onChange]);
|
|
|
|
function handleOpen() {
|
|
if (disabled) return;
|
|
if (triggerRef.current) {
|
|
const rect = triggerRef.current.getBoundingClientRect();
|
|
setPopoverPos({
|
|
top: rect.bottom + window.scrollY + 6,
|
|
left: rect.left + window.scrollX,
|
|
});
|
|
}
|
|
setOpen(true);
|
|
}
|
|
|
|
function handleSelect(date: Date | undefined) {
|
|
if (date && isValid(date)) {
|
|
onChange(format(date, 'yyyy-MM-dd'));
|
|
}
|
|
setOpen(false);
|
|
}
|
|
|
|
function handleClear(e: React.MouseEvent) {
|
|
e.stopPropagation();
|
|
onChange('');
|
|
}
|
|
|
|
const displayText = validSelected
|
|
? format(validSelected, 'MMM d, yyyy')
|
|
: null;
|
|
|
|
const popover = open && mounted ? createPortal(
|
|
<>
|
|
<div
|
|
className="fixed inset-0"
|
|
style={{ zIndex: 9999 }}
|
|
onClick={() => setOpen(false)}
|
|
/>
|
|
<div
|
|
className="absolute bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl p-3"
|
|
style={{ zIndex: 10000, top: popoverPos.top, left: popoverPos.left }}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<DayPicker
|
|
mode="single"
|
|
selected={validSelected}
|
|
onSelect={handleSelect}
|
|
disabled={minDate ? { before: minDate } : undefined}
|
|
showOutsideDays
|
|
classNames={{
|
|
root: 'w-full',
|
|
months: 'w-full',
|
|
month: 'w-full',
|
|
month_caption: 'flex items-center justify-between mb-3',
|
|
caption_label: 'text-sm font-semibold text-gray-900 dark:text-white',
|
|
nav: 'flex items-center gap-1',
|
|
button_previous: 'h-7 w-7 rounded-lg flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors',
|
|
button_next: 'h-7 w-7 rounded-lg flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors',
|
|
month_grid: 'w-full border-collapse',
|
|
weekdays: 'flex w-full mb-1',
|
|
weekday: 'flex-1 text-center text-xs font-medium text-gray-400 dark:text-gray-500 py-1',
|
|
weeks: '',
|
|
week: 'flex w-full mt-0.5',
|
|
day: 'flex-1 flex items-center justify-center p-0',
|
|
day_button: 'h-8 w-8 text-xs rounded-lg flex items-center justify-center transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer text-gray-700 dark:text-gray-300',
|
|
selected: '',
|
|
today: '',
|
|
outside: 'opacity-30',
|
|
disabled: 'opacity-20 cursor-not-allowed',
|
|
hidden: 'invisible',
|
|
range_start: '',
|
|
range_end: '',
|
|
range_middle: '',
|
|
focused: 'ring-1 ring-blue-500/50',
|
|
chevron: '',
|
|
dropdowns: '',
|
|
dropdown: '',
|
|
dropdown_root: '',
|
|
footer: '',
|
|
months_dropdown: '',
|
|
week_number: '',
|
|
week_number_header: '',
|
|
years_dropdown: '',
|
|
weeks_after_enter: '',
|
|
weeks_after_exit: '',
|
|
weeks_before_enter: '',
|
|
weeks_before_exit: '',
|
|
}}
|
|
components={{
|
|
Chevron: ({ orientation }) =>
|
|
orientation === 'left'
|
|
? <ChevronLeft className="h-4 w-4" />
|
|
: <ChevronRight className="h-4 w-4" />,
|
|
DayButton: ({ day, modifiers, className: cls, ...props }) => (
|
|
<button
|
|
{...props}
|
|
className={cn(
|
|
cls,
|
|
modifiers.selected && 'bg-blue-600 text-white font-semibold hover:bg-blue-700',
|
|
modifiers.today && !modifiers.selected && 'text-blue-600 dark:text-blue-400 font-bold',
|
|
)}
|
|
/>
|
|
),
|
|
}}
|
|
/>
|
|
</div>
|
|
</>,
|
|
document.body,
|
|
) : null;
|
|
|
|
return (
|
|
<div className={cn('relative', className)}>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
onClick={handleOpen}
|
|
disabled={disabled}
|
|
className={cn(
|
|
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors',
|
|
'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800',
|
|
'text-gray-900 dark:text-white',
|
|
'focus:outline-none focus:ring-2 focus:ring-blue-500',
|
|
'disabled:opacity-40 disabled:cursor-not-allowed',
|
|
!displayText && 'text-gray-400 dark:text-gray-500',
|
|
)}
|
|
>
|
|
<Calendar className="w-4 h-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
|
<span className="min-w-[90px] text-left">{displayText ?? placeholder}</span>
|
|
{displayText && !disabled && (
|
|
<X
|
|
className="w-3.5 h-3.5 shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 ml-1"
|
|
onClick={handleClear}
|
|
/>
|
|
)}
|
|
</button>
|
|
{popover}
|
|
</div>
|
|
);
|
|
}
|