317 lines
8.9 KiB
TypeScript
317 lines
8.9 KiB
TypeScript
/**
|
||
* Material Design 3 Date Picker — Dialog variant.
|
||
* Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs).
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import React, { useState, useMemo } from 'react';
|
||
import {
|
||
format,
|
||
startOfMonth,
|
||
endOfMonth,
|
||
eachDayOfInterval,
|
||
isSameDay,
|
||
isSameMonth,
|
||
addMonths,
|
||
subMonths,
|
||
startOfWeek,
|
||
endOfWeek,
|
||
} from 'date-fns';
|
||
import { ru } from 'date-fns/locale';
|
||
import { Dialog, DialogContent, Box, Button } from '@mui/material';
|
||
|
||
interface DatePickerProps {
|
||
value: string; // YYYY-MM-DD format
|
||
onChange: (value: string) => void;
|
||
disabled?: boolean;
|
||
required?: boolean;
|
||
label?: string;
|
||
}
|
||
|
||
export const DatePicker: React.FC<DatePickerProps> = ({
|
||
value,
|
||
onChange,
|
||
disabled = false,
|
||
required = false,
|
||
label,
|
||
}) => {
|
||
const [open, setOpen] = useState(false);
|
||
const [displayMonth, setDisplayMonth] = useState(
|
||
value ? new Date(value + 'T00:00:00') : new Date(),
|
||
);
|
||
|
||
const selectedDate = useMemo(
|
||
() => (value ? new Date(value + 'T00:00:00') : null),
|
||
[value],
|
||
);
|
||
|
||
const openPicker = () => {
|
||
if (disabled) return;
|
||
setDisplayMonth(selectedDate ?? new Date());
|
||
setOpen(true);
|
||
};
|
||
|
||
const closePicker = () => setOpen(false);
|
||
|
||
const handleDateSelect = (date: Date) => {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||
const d = String(date.getDate()).padStart(2, '0');
|
||
onChange(`${y}-${m}-${d}`);
|
||
closePicker();
|
||
};
|
||
|
||
const days = useMemo(() => {
|
||
const start = startOfMonth(displayMonth);
|
||
const end = endOfMonth(displayMonth);
|
||
return eachDayOfInterval({
|
||
start: startOfWeek(start, { locale: ru }),
|
||
end: endOfWeek(end, { locale: ru }),
|
||
});
|
||
}, [displayMonth]);
|
||
|
||
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||
|
||
const displayValue = selectedDate
|
||
? format(selectedDate, 'd MMMM yyyy', { locale: ru })
|
||
: label || 'Выберите дату';
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={openPicker}
|
||
disabled={disabled}
|
||
aria-required={required}
|
||
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 var(--md-sys-color-outline)',
|
||
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>{displayValue}</span>
|
||
<span
|
||
className="material-symbols-outlined"
|
||
style={{ fontSize: 20, opacity: 0.7 }}
|
||
>
|
||
calendar_today
|
||
</span>
|
||
</button>
|
||
|
||
<Dialog
|
||
open={open}
|
||
onClose={closePicker}
|
||
fullWidth
|
||
maxWidth="xs"
|
||
slotProps={{
|
||
paper: {
|
||
sx: {
|
||
borderRadius: '24px',
|
||
overflow: 'visible',
|
||
bgcolor: 'var(--md-sys-color-surface)',
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
<DialogContent sx={{ p: 2 }}>
|
||
{/* Month/year header */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
mb: 1.5,
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
padding: 0,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderRadius: '50%',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||
chevron_left
|
||
</span>
|
||
</button>
|
||
|
||
<span
|
||
style={{
|
||
fontSize: 16,
|
||
fontWeight: 500,
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
textTransform: 'capitalize',
|
||
}}
|
||
>
|
||
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
|
||
</span>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
padding: 0,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderRadius: '50%',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||
chevron_right
|
||
</span>
|
||
</button>
|
||
</Box>
|
||
|
||
{/* Weekday headers */}
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||
gap: 2,
|
||
marginBottom: 4,
|
||
}}
|
||
>
|
||
{weekDays.map((day) => (
|
||
<div
|
||
key={day}
|
||
style={{
|
||
textAlign: 'center',
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
padding: '6px 0',
|
||
}}
|
||
>
|
||
{day}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Calendar days */}
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||
gap: 2,
|
||
}}
|
||
>
|
||
{days.map((day, idx) => {
|
||
const isSelected = selectedDate && isSameDay(day, selectedDate);
|
||
const isCurrent = isSameMonth(day, displayMonth);
|
||
const isToday = isSameDay(day, new Date());
|
||
|
||
return (
|
||
<button
|
||
key={idx}
|
||
type="button"
|
||
onClick={() => handleDateSelect(day)}
|
||
style={{
|
||
width: '100%',
|
||
aspectRatio: '1',
|
||
maxWidth: 40,
|
||
margin: '0 auto',
|
||
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: 14,
|
||
fontWeight: isSelected ? 600 : 400,
|
||
color: isSelected
|
||
? 'var(--md-sys-color-on-primary)'
|
||
: isCurrent
|
||
? 'var(--md-sys-color-on-surface)'
|
||
: 'var(--md-sys-color-on-surface-variant)',
|
||
opacity: isCurrent ? 1 : 0.35,
|
||
transition: 'background 0.15s',
|
||
}}
|
||
>
|
||
{format(day, 'd')}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
mt: 2,
|
||
pt: 1.5,
|
||
borderTop: '1px solid var(--md-sys-color-outline-variant)',
|
||
}}
|
||
>
|
||
<Button
|
||
onClick={() => handleDateSelect(new Date())}
|
||
variant="text"
|
||
sx={{
|
||
color: 'var(--md-sys-color-primary)',
|
||
textTransform: 'none',
|
||
fontWeight: 500,
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
Сегодня
|
||
</Button>
|
||
<Button
|
||
onClick={closePicker}
|
||
variant="text"
|
||
sx={{
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
textTransform: 'none',
|
||
fontWeight: 500,
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
</Box>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
};
|