155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { DayPicker } from '@daypicker/persian';
|
|
import '@daypicker/react/style.css';
|
|
import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
|
|
|
interface PersianDatePickerProps {
|
|
/** Gregorian YYYY-MM-DD, or '' when empty. */
|
|
value: string;
|
|
/** Called with the selected Gregorian YYYY-MM-DD, or '' when cleared. */
|
|
onChange: (value: string) => void;
|
|
label?: string;
|
|
placeholder?: string;
|
|
id?: string;
|
|
}
|
|
|
|
// Parses a Gregorian "YYYY-MM-DD" into a local Date (pinned at noon to avoid
|
|
// any date shift from timezone offsets).
|
|
const parseGregorian = (value: string): Date | null => {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
if (!match) return null;
|
|
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12, 0, 0);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
};
|
|
|
|
const toGregorianValue = (date: Date): string => {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
// Renders the given Gregorian date as a Persian (Jalali) string with Persian
|
|
// digits, e.g. "۱۴۰۴/۰۵/۱۱".
|
|
const jalaliFormat = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
});
|
|
|
|
const formatJalali = (date: Date): string => jalaliFormat.format(date);
|
|
|
|
/**
|
|
* A Persian (Jalali) date input built on @daypicker/persian.
|
|
*
|
|
* The calendar operates in the Jalali calendar, but the public value is a
|
|
* Gregorian "YYYY-MM-DD" string — the same format the native <input
|
|
* type="date"> produced — so the accounting filters keep working unchanged.
|
|
*
|
|
* Note: with the default dateLib (no `timeZone` prop), @daypicker/persian
|
|
* works with plain JS `Date` objects whose native getters already return the
|
|
* Gregorian date; the Jalali conversion happens inside the calendar library.
|
|
* That's why we can read `getFullYear()/getMonth()/getDate()` straight off the
|
|
* picked date.
|
|
*/
|
|
export default function PersianDatePicker({
|
|
value,
|
|
onChange,
|
|
label,
|
|
placeholder,
|
|
id,
|
|
}: PersianDatePickerProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
const selectedDate = value ? parseGregorian(value) : null;
|
|
|
|
// Controlled month so the popover opens on the selected month (or now).
|
|
const [viewMonth, setViewMonth] = useState<Date>(() => selectedDate ?? new Date());
|
|
|
|
// Close on outside click or Escape.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handlePointerDown = (event: MouseEvent) => {
|
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handlePointerDown);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handlePointerDown);
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [open]);
|
|
|
|
const handleSelect = (date: Date | undefined) => {
|
|
if (date) onChange(toGregorianValue(date));
|
|
setOpen(false);
|
|
};
|
|
|
|
const handleClear = () => {
|
|
onChange('');
|
|
setOpen(false);
|
|
};
|
|
|
|
const toggle = () => {
|
|
setViewMonth(selectedDate ?? new Date());
|
|
setOpen((prev) => !prev);
|
|
};
|
|
|
|
return (
|
|
<div ref={rootRef} className="relative">
|
|
{label && (
|
|
<label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
|
{label}
|
|
</label>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
id={id}
|
|
onClick={toggle}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={open}
|
|
className={`flex w-full items-center justify-between gap-2 px-3 py-2.5 text-right text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent sm:text-sm dark:bg-gray-800/80 transition-colors ${
|
|
selectedDate
|
|
? 'text-gray-900 dark:text-gray-100'
|
|
: 'text-gray-400 dark:text-gray-500'
|
|
}`}
|
|
>
|
|
<span className="truncate">{selectedDate ? formatJalali(selectedDate) : placeholder || 'انتخاب تاریخ'}</span>
|
|
{selectedDate ? (
|
|
<XMarkIcon
|
|
className="h-4 w-4 shrink-0 text-gray-400 hover:text-red-500 transition-colors"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
handleClear();
|
|
}}
|
|
aria-label="پاک کردن تاریخ"
|
|
/>
|
|
) : (
|
|
<CalendarDaysIcon className="h-4 w-4 shrink-0 text-gray-400" />
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute right-0 z-50 mt-2 bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 rounded-2xl shadow-xl p-2 max-w-[calc(100vw-3rem)] overflow-x-auto">
|
|
<DayPicker
|
|
mode="single"
|
|
selected={selectedDate ?? undefined}
|
|
onSelect={handleSelect}
|
|
month={viewMonth}
|
|
onMonthChange={setViewMonth}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|