300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
/**
|
||
* Material Design 3 Date Picker
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import React, { useState, useRef, useEffect } from 'react';
|
||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, startOfWeek, endOfWeek } from 'date-fns';
|
||
import { ru } from 'date-fns/locale';
|
||
|
||
interface DatePickerProps {
|
||
value: string; // YYYY-MM-DD format
|
||
onChange: (value: string) => void;
|
||
disabled?: boolean;
|
||
required?: boolean;
|
||
}
|
||
|
||
export const DatePicker: React.FC<DatePickerProps> = ({
|
||
value,
|
||
onChange,
|
||
disabled = false,
|
||
required = false,
|
||
}) => {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [displayMonth, setDisplayMonth] = useState(value ? new Date(value) : new Date());
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
const selectedDate = value ? new Date(value) : null;
|
||
|
||
// Закрываем picker при клике вне компонента
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||
setIsOpen(false);
|
||
}
|
||
};
|
||
|
||
if (isOpen) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}, [isOpen]);
|
||
|
||
const handleDateSelect = (date: Date) => {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
onChange(`${year}-${month}-${day}`);
|
||
setIsOpen(false);
|
||
};
|
||
|
||
// Получаем дни для отображения в календаре
|
||
const getDaysInMonth = () => {
|
||
const start = startOfMonth(displayMonth);
|
||
const end = endOfMonth(displayMonth);
|
||
const startDate = startOfWeek(start, { locale: ru });
|
||
const endDate = endOfWeek(end, { locale: ru });
|
||
|
||
return eachDayOfInterval({ start: startDate, end: endDate });
|
||
};
|
||
|
||
const days = getDaysInMonth();
|
||
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||
|
||
return (
|
||
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
|
||
{/* Input field */}
|
||
<button
|
||
type="button"
|
||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||
disabled={disabled}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
fontSize: '16px',
|
||
color: value ? 'var(--md-sys-color-on-surface)' : 'var(--md-sys-color-on-surface-variant)',
|
||
background: 'var(--md-sys-color-surface)',
|
||
border: `1px solid ${isOpen ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'}`,
|
||
borderWidth: isOpen ? '2px' : '1px',
|
||
borderRadius: '4px',
|
||
fontFamily: 'inherit',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
outline: 'none',
|
||
transition: 'all 0.2s ease',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '12px',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<span>
|
||
{selectedDate ? format(selectedDate, 'd MMMM yyyy', { locale: ru }) : 'Выберите дату'}
|
||
</span>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Calendar dropdown */}
|
||
{isOpen && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 'calc(100% + 4px)',
|
||
left: 0,
|
||
background: 'var(--md-sys-color-surface)',
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
borderRadius: '16px',
|
||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)',
|
||
zIndex: 1000,
|
||
padding: '16px',
|
||
minWidth: '320px',
|
||
}}>
|
||
{/* Header with month/year navigation */}
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: '16px',
|
||
}}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
|
||
style={{
|
||
width: '32px',
|
||
height: '32px',
|
||
padding: 0,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderRadius: '50%',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
transition: 'background 0.2s ease',
|
||
}}
|
||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)'}
|
||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<polyline points="15 18 9 12 15 6"></polyline>
|
||
</svg>
|
||
</button>
|
||
|
||
<div style={{
|
||
fontSize: '16px',
|
||
fontWeight: '500',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
}}>
|
||
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
|
||
style={{
|
||
width: '32px',
|
||
height: '32px',
|
||
padding: 0,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderRadius: '50%',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
transition: 'background 0.2s ease',
|
||
}}
|
||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)'}
|
||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<polyline points="9 18 15 12 9 6"></polyline>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Week days header */}
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||
gap: '4px',
|
||
marginBottom: '8px',
|
||
}}>
|
||
{weekDays.map(day => (
|
||
<div
|
||
key={day}
|
||
style={{
|
||
textAlign: 'center',
|
||
fontSize: '12px',
|
||
fontWeight: '500',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
padding: '8px 0',
|
||
}}
|
||
>
|
||
{day}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Calendar days grid */}
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||
gap: '4px',
|
||
}}>
|
||
{days.map((day, index) => {
|
||
const isSelected = selectedDate && isSameDay(day, selectedDate);
|
||
const isCurrentMonth = isSameMonth(day, displayMonth);
|
||
const isToday = isSameDay(day, new Date());
|
||
|
||
return (
|
||
<button
|
||
key={index}
|
||
type="button"
|
||
onClick={() => handleDateSelect(day)}
|
||
style={{
|
||
width: '40px',
|
||
height: '40px',
|
||
padding: 0,
|
||
background: isSelected
|
||
? 'var(--md-sys-color-primary)'
|
||
: 'transparent',
|
||
border: isToday && !isSelected
|
||
? '1px solid var(--md-sys-color-primary)'
|
||
: 'none',
|
||
borderRadius: '50%',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: '14px',
|
||
fontWeight: isSelected ? '500' : '400',
|
||
color: isSelected
|
||
? 'var(--md-sys-color-on-primary)'
|
||
: isCurrentMonth
|
||
? 'var(--md-sys-color-on-surface)'
|
||
: 'var(--md-sys-color-on-surface-variant)',
|
||
opacity: isCurrentMonth ? 1 : 0.4,
|
||
transition: 'all 0.2s ease',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isSelected) {
|
||
e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isSelected) {
|
||
e.currentTarget.style.background = 'transparent';
|
||
}
|
||
}}
|
||
>
|
||
{format(day, 'd')}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Today button */}
|
||
<div style={{
|
||
marginTop: '16px',
|
||
paddingTop: '16px',
|
||
borderTop: '1px solid var(--md-sys-color-outline-variant)',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
}}>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDateSelect(new Date())}
|
||
style={{
|
||
padding: '8px 16px',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderRadius: '20px',
|
||
cursor: 'pointer',
|
||
fontSize: '14px',
|
||
fontWeight: '500',
|
||
color: 'var(--md-sys-color-primary)',
|
||
transition: 'background 0.2s ease',
|
||
}}
|
||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-primary-container)'}
|
||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||
>
|
||
Сегодня
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|