feat(frontend): redesign race dashboard
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
This commit is contained in:
197
frontend/src/components/DatePickerField.tsx
Normal file
197
frontend/src/components/DatePickerField.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user