198 lines
5.8 KiB
TypeScript
198 lines
5.8 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { buildMonthCells, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
|
||
|
||
const MONTH_NAMES_RU = [
|
||
"Январь",
|
||
"Февраль",
|
||
"Март",
|
||
"Апрель",
|
||
"Май",
|
||
"Июнь",
|
||
"Июль",
|
||
"Август",
|
||
"Сентябрь",
|
||
"Октябрь",
|
||
"Ноябрь",
|
||
"Декабрь",
|
||
];
|
||
|
||
interface DatePickerFieldProps {
|
||
value: string;
|
||
name: string;
|
||
required?: boolean;
|
||
onChange: (value: string) => void;
|
||
}
|
||
|
||
function parseYmd(value: string): { year: number; monthIndex: number; day: number } | null {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||
return null;
|
||
}
|
||
|
||
const year = Number(value.slice(0, 4));
|
||
const monthIndex = Number(value.slice(5, 7)) - 1;
|
||
const day = Number(value.slice(8, 10));
|
||
|
||
if (!Number.isInteger(year) || !Number.isInteger(monthIndex) || !Number.isInteger(day)) {
|
||
return null;
|
||
}
|
||
if (monthIndex < 0 || monthIndex > 11) {
|
||
return null;
|
||
}
|
||
|
||
return { year, monthIndex, day };
|
||
}
|
||
|
||
function getInitialVisibleMonth(value: string): { year: number; monthIndex: number } {
|
||
const parsed = parseYmd(value);
|
||
if (parsed) {
|
||
return { year: parsed.year, monthIndex: parsed.monthIndex };
|
||
}
|
||
|
||
const now = new Date();
|
||
return { year: now.getFullYear(), monthIndex: now.getMonth() };
|
||
}
|
||
|
||
export function DatePickerField(props: DatePickerFieldProps): JSX.Element {
|
||
const { value, name, required, onChange } = props;
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [visibleMonth, setVisibleMonth] = useState(() => getInitialVisibleMonth(value));
|
||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
const parsed = parseYmd(value);
|
||
if (!parsed) {
|
||
return;
|
||
}
|
||
setVisibleMonth({ year: parsed.year, monthIndex: parsed.monthIndex });
|
||
}, [value]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
return;
|
||
}
|
||
|
||
function handlePointerDown(event: MouseEvent): void {
|
||
if (rootRef.current?.contains(event.target as Node)) {
|
||
return;
|
||
}
|
||
setIsOpen(false);
|
||
}
|
||
|
||
function handleKeyDown(event: KeyboardEvent): void {
|
||
if (event.key === "Escape") {
|
||
setIsOpen(false);
|
||
}
|
||
}
|
||
|
||
document.addEventListener("mousedown", handlePointerDown);
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handlePointerDown);
|
||
document.removeEventListener("keydown", handleKeyDown);
|
||
};
|
||
}, [isOpen]);
|
||
|
||
const selected = parseYmd(value);
|
||
const todayYmd = toYmd(new Date().getFullYear(), new Date().getMonth(), new Date().getDate());
|
||
const cells = useMemo(
|
||
() => buildMonthCells(visibleMonth.year, visibleMonth.monthIndex),
|
||
[visibleMonth],
|
||
);
|
||
const monthTitle = `${MONTH_NAMES_RU[visibleMonth.monthIndex]} ${visibleMonth.year}`;
|
||
|
||
function shiftMonth(delta: number): void {
|
||
setVisibleMonth((prev) => {
|
||
const next = new Date(Date.UTC(prev.year, prev.monthIndex + delta, 1));
|
||
return { year: next.getUTCFullYear(), monthIndex: next.getUTCMonth() };
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="date-picker" ref={rootRef}>
|
||
<div className="date-picker__control">
|
||
<input
|
||
className="race-form__input date-picker__input"
|
||
type="text"
|
||
inputMode="numeric"
|
||
name={name}
|
||
value={value}
|
||
onChange={(event) => {
|
||
onChange(event.target.value);
|
||
}}
|
||
onFocus={() => setIsOpen(true)}
|
||
placeholder="2026-05-03"
|
||
autoComplete="off"
|
||
required={required}
|
||
/>
|
||
<button
|
||
className="date-picker__toggle"
|
||
type="button"
|
||
aria-label="Открыть календарь"
|
||
aria-expanded={isOpen}
|
||
onClick={() => setIsOpen((prev) => !prev)}
|
||
>
|
||
▾
|
||
</button>
|
||
</div>
|
||
|
||
{isOpen ? (
|
||
<div className="date-picker__popover" role="dialog" aria-label="Выбор даты">
|
||
<div className="date-picker__header">
|
||
<button
|
||
className="date-picker__nav"
|
||
type="button"
|
||
aria-label="Предыдущий месяц"
|
||
onClick={() => shiftMonth(-1)}
|
||
>
|
||
‹
|
||
</button>
|
||
<p className="date-picker__title">{monthTitle}</p>
|
||
<button
|
||
className="date-picker__nav"
|
||
type="button"
|
||
aria-label="Следующий месяц"
|
||
onClick={() => shiftMonth(1)}
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
<div className="date-picker__weekdays" aria-hidden>
|
||
{WEEKDAY_LABELS_SHORT_RU.map((weekday) => (
|
||
<span key={weekday} className="date-picker__weekday">
|
||
{weekday}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="date-picker__cells">
|
||
{cells.map((day, idx) => {
|
||
if (day === null) {
|
||
return <span key={`empty-${idx}`} className="date-picker__cell date-picker__cell--empty" />;
|
||
}
|
||
|
||
const ymd = toYmd(visibleMonth.year, visibleMonth.monthIndex, day);
|
||
const isSelected =
|
||
selected?.year === visibleMonth.year &&
|
||
selected.monthIndex === visibleMonth.monthIndex &&
|
||
selected.day === day;
|
||
|
||
return (
|
||
<button
|
||
key={ymd}
|
||
className={`date-picker__day${isSelected ? " date-picker__day--selected" : ""}${ymd === todayYmd ? " date-picker__day--today" : ""}`}
|
||
type="button"
|
||
onClick={() => {
|
||
onChange(ymd);
|
||
setIsOpen(false);
|
||
}}
|
||
>
|
||
{day}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|