// ui-datetime.jsx — поля даты и времени со своим пикером. // // Нативные рисуются средствами ОС: на тёмной теме // сайта они выпадают светлым системным календарём, шрифтом и радиусами мимо // всего остального. Здесь и поле, и всплывашка — обычная разметка, поэтому // подчиняются тем же переменным, что Select и .input. // // Клавиатура остаётся первичным способом ввода: маска ДД.ММ.ГГГГ / чч:мм // работает без открытия пикера. Для даты рождения это единственный быстрый // путь — календарём до 1991 года никто листать не станет, поэтому у него есть // режимы «месяцы» и «годы» (клик по заголовку). const DT_MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; const DT_MONTHS_SHORT = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']; const DT_WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; const dtPad2 = (n) => String(n).padStart(2, '0'); const dtDaysInMonth = (y, m0) => new Date(y, m0 + 1, 0).getDate(); // Неделя начинается с понедельника: JS отдаёт 0 для воскресенья. const dtFirstWeekday = (y, m0) => (new Date(y, m0, 1).getDay() + 6) % 7; const dtValidYMD = (y, m, d) => y >= 1 && m >= 1 && m <= 12 && d >= 1 && d <= dtDaysInMonth(y, m - 1); // "ДД.ММ.ГГГГ" → "ГГГГ-ММ-ДД"; несуществующая дата (31.02) → "". const dtDotToISO = (s) => { const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec((s || '').trim()); if (!m) return ''; return dtValidYMD(+m[3], +m[2], +m[1]) ? `${m[3]}-${m[2]}-${m[1]}` : ''; }; // "ГГГГ-ММ-ДД[что угодно]" → "ДД.ММ.ГГГГ". const dtISOToDot = (s) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec((s || '').trim()); if (!m) return ''; return dtValidYMD(+m[1], +m[2], +m[3]) ? `${m[3]}.${m[2]}.${m[1]}` : ''; }; // Маска даты: из любых символов оставляем цифры и расставляем точки по мере // ввода. Бэкспейс работает естественно — точки уходят вместе с цифрами. const dtMaskDate = (s) => { const d = (s || '').replace(/\D/g, '').slice(0, 8); const parts = [d.slice(0, 2)]; if (d.length >= 3) parts.push(d.slice(2, 4)); if (d.length >= 5) parts.push(d.slice(4, 8)); return parts.filter(Boolean).join('.'); }; // Маска времени: цифры → чч:мм. const dtMaskTime = (s) => { const d = (s || '').replace(/\D/g, '').slice(0, 4); return d.length <= 2 ? d : d.slice(0, 2) + ':' + d.slice(2, 4); }; // Время в каноническом виде "HH:MM" или "" — терпимо к "04:30:00.000000", // которое может прийти из базы. const dtNormTime = (s) => { const m = /^(\d{1,2}):(\d{2})/.exec((s || '').trim()); if (!m) return ''; const h = +m[1], mi = +m[2]; if (h > 23 || mi > 59) return ''; return `${dtPad2(h)}:${dtPad2(mi)}`; }; // Закрытие по клику мимо и по Escape — общее поведение обеих всплывашек. function useDtPopover(open, close) { const rootRef = React.useRef(null); React.useEffect(() => { if (!open) return undefined; const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) close(); }; const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey, true); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey, true); }; }, [open, close]); return rootRef; } // Всплывашка не должна уезжать за нижний край: если места мало — раскрываем вверх. function useDtDirection(open, rootRef, height) { const [up, setUp] = React.useState(false); React.useEffect(() => { if (!open || !rootRef.current) return; const r = rootRef.current.getBoundingClientRect(); setUp(r.bottom + height > window.innerHeight && r.top > height); }, [open]); return up; } // ——— DateField ——— // value/onChange — "ГГГГ-ММ-ДД" (format="iso", по умолчанию) либо "ДД.ММ.ГГГГ" // (format="dot"). Неполный ввод отдаётся наверх как "": незаконченная дата // не должна выглядеть как введённая. function DateField({ value, onChange, format = 'iso', placeholder = 'ДД.ММ.ГГГГ', minYear = 1900, maxYear = new Date().getFullYear() + 1, disabled = false, id, className = '', }) { const iso = format === 'dot' ? dtDotToISO(value) : (dtISOToDot(value) ? String(value).slice(0, 10) : ''); const emit = (nextISO) => onChange(!nextISO ? '' : (format === 'dot' ? dtISOToDot(nextISO) : nextISO)); const [text, setText] = React.useState(dtISOToDot(iso)); const [open, setOpen] = React.useState(false); const [view, setView] = React.useState('days'); // days | months | years const today = React.useMemo(() => new Date(), []); const [cursor, setCursor] = React.useState(() => { const d = iso ? new Date(iso + 'T00:00:00') : today; return { y: d.getFullYear(), m: d.getMonth() }; }); // Внешнее изменение value перерисовывает поле, но не затирает набранное // вручную: сравниваем по смыслу, а не по строке. React.useEffect(() => { if (iso !== dtDotToISO(text)) setText(iso ? dtISOToDot(iso) : ''); }, [iso]); const close = React.useCallback(() => { setOpen(false); setView('days'); }, []); const rootRef = useDtPopover(open, close); const up = useDtDirection(open, rootRef, 360); React.useEffect(() => { if (!open) return; const d = iso ? new Date(iso + 'T00:00:00') : today; setCursor({ y: d.getFullYear(), m: d.getMonth() }); }, [open]); const onText = (raw) => { const masked = dtMaskDate(raw); setText(masked); emit(dtDotToISO(masked)); }; const pickDay = (d) => { const next = `${cursor.y}-${dtPad2(cursor.m + 1)}-${dtPad2(d)}`; setText(dtISOToDot(next)); emit(next); close(); }; const shiftMonth = (delta) => setCursor(c => { const m = c.m + delta; return { y: c.y + Math.floor(m / 12), m: ((m % 12) + 12) % 12 }; }); const yearPageStart = Math.floor((cursor.y - minYear) / 24) * 24 + minYear; const cells = React.useMemo(() => { const lead = dtFirstWeekday(cursor.y, cursor.m); const total = dtDaysInMonth(cursor.y, cursor.m); const out = []; for (let i = 0; i < lead; i++) out.push(null); for (let d = 1; d <= total; d++) out.push(d); while (out.length % 7 !== 0) out.push(null); return out; }, [cursor.y, cursor.m]); const selISO = iso; const todayISO = `${today.getFullYear()}-${dtPad2(today.getMonth() + 1)}-${dtPad2(today.getDate())}`; return (
onText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && open) { e.preventDefault(); close(); } }} />
{open && (
{view === 'days' && (
{DT_WEEKDAYS.map(w => {w})}
{cells.map((d, i) => { if (!d) return ; const cellISO = `${cursor.y}-${dtPad2(cursor.m + 1)}-${dtPad2(d)}`; return ( ); })}
)} {view === 'months' && (
{DT_MONTHS_SHORT.map((mo, i) => ( ))}
)} {view === 'years' && (
{Array.from({ length: 24 }, (_, i) => yearPageStart + i) .filter(y => y >= minYear && y <= maxYear) .map(y => ( ))}
)}
)}
); } // ——— TimeField ——— // value/onChange — "ЧЧ:ММ" либо "". Часы и минуты выбираются двумя колонками: // это единственная раскладка, которая на телефоне не превращается в рулетку // из системного пикера. function TimeField({ value, onChange, placeholder = 'чч:мм', disabled = false, minuteStep = 1, id, className = '', }) { const norm = dtNormTime(value); const [text, setText] = React.useState(norm); const [open, setOpen] = React.useState(false); const hoursRef = React.useRef(null); const minsRef = React.useRef(null); React.useEffect(() => { if (norm !== dtNormTime(text)) setText(norm); }, [norm]); const close = React.useCallback(() => setOpen(false), []); const rootRef = useDtPopover(open, close); const up = useDtDirection(open, rootRef, 260); // При открытии подкручиваем колонки к выбранному значению. React.useEffect(() => { if (!open) return; [hoursRef, minsRef].forEach(ref => { const el = ref.current && ref.current.querySelector('.selected'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center' }); }); }, [open]); const hh = norm ? norm.slice(0, 2) : ''; const mm = norm ? norm.slice(3, 5) : ''; const onText = (raw) => { const masked = dtMaskTime(raw); setText(masked); onChange(dtNormTime(masked)); }; const set = (nextH, nextM) => { const v = `${nextH}:${nextM}`; setText(v); onChange(v); }; const minutes = React.useMemo( () => Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => dtPad2(i * minuteStep)), [minuteStep] ); return (
onText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && open) { e.preventDefault(); close(); } }} />
{open && (
час
{Array.from({ length: 24 }, (_, i) => dtPad2(i)).map(h => ( ))}
мин
{minutes.map(m => ( ))}
)}
); } Object.assign(window, { DateField, TimeField, dtMaskDate, dtMaskTime, dtDotToISO, dtISOToDot, dtNormTime, });