import { CssProps, RefProps, bindGlobalStyle, getGlobalStylesId, VNode } from 'lupine.web'; import { HtmlVar } from '../../components/html-var'; import { PickerHelper } from '../picker-helper'; import { SvgSvg, SvgRect, SvgLine, SvgPolyline, CalendarIcon, ClearIcon, CheckIcon } from '../svg-props'; export type DatePickerProps = { value?: string; placeholder?: string; className?: string; onChange?: (value: string) => void; style?: CssProps; yearRange?: number; // +/- years to show in menu clearable?: boolean; }; const datePickerCss: CssProps = { display: 'inline-flex', position: 'relative', width: '100%', flexDirection: 'column', '.&-input': { width: '100%', cursor: 'pointer', paddingRight: '30px', }, '.&-icon': { position: 'absolute', right: '8px', top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: 'var(--secondary-color, #999)', }, '.&-icon-clear': { position: 'absolute', right: '28px', top: '50%', transform: 'translateY(-50%)', cursor: 'pointer', color: 'var(--error-color, #f5222d)', opacity: 0.6, transition: 'opacity 0.2s', '&:hover': { opacity: 1 }, display: 'none', }, '&:hover .&-icon-clear.visible': { display: 'block', }, '.&-panel': { width: '220px', backgroundColor: 'var(--primary-bg-color, #fff)', userSelect: 'none', display: 'flex', flexDirection: 'column', }, '.&-header': { display: 'flex', alignItems: 'center', padding: '6px 12px 4px 12px', borderBottom: '1px solid var(--primary-border, #f0f0f0)', gap: '4px', }, '.&-nav-btn': { padding: '4px', border: '1px solid transparent', backgroundColor: 'transparent', cursor: 'pointer', color: 'var(--primary-color, #666)', '&:hover': { color: 'var(--primary-accent-color, #1890ff)' }, fontSize: '16px', fontWeight: 'bold', }, '.&-year-input-box': { display: 'flex', alignItems: 'center', flex: 1, position: 'relative', '.year-input': { width: '60px', border: '1px solid var(--primary-border, #d9d9d9)', borderRadius: '4px', padding: '4px', fontSize: '14px', textAlign: 'center', cursor: 'pointer', backgroundColor: 'transparent', color: 'inherit', '&:focus': { borderColor: 'var(--primary-accent-color, #1890ff)', outline: 'none' }, }, }, '.&-month-select': { border: '1px solid var(--primary-border, #d9d9d9)', borderRadius: '4px', backgroundColor: 'transparent', color: 'inherit', padding: '2px 4px', cursor: 'pointer', }, '.&-body': { padding: '6px', }, '.&-grid': { display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '1px', }, '.&-weekday': { textAlign: 'center', fontSize: '12px', fontWeight: '600', color: 'var(--secondary-color, #8c8c8c)', padding: '4px 0', }, '.&-day': { textAlign: 'center', padding: '4px 0', cursor: 'pointer', borderRadius: '4px', fontSize: '14px', transition: 'all 0.2s', '&:hover': { backgroundColor: 'var(--secondary-bg-color, #e6f7ff)', color: 'var(--primary-accent-color, #1890ff)', }, '&.selected': { backgroundColor: 'var(--primary-accent-color, #1890ff)', color: '#fff', fontWeight: 'bold', }, '&.today': { color: 'var(--primary-accent-color, #1890ff)', fontWeight: 'bold', '&::after': { content: '""', display: 'block', margin: '-4px auto 0', width: '4px', height: '4px', borderRadius: '50%', backgroundColor: 'var(--primary-accent-color, #1890ff)', }, }, '&.not-this-month': { color: 'var(--secondary-color, #d9d9d9)', }, }, '.&-ok-btn': { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '24px', height: '24px', padding: '2px', borderRadius: '4px', cursor: 'pointer', backgroundColor: 'var(--primary-accent-color, #1890ff)', color: '#fff', border: 'none', transition: 'opacity 0.2s', '&:hover': { opacity: 0.85 }, }, '.&-year-popup': { position: 'absolute', top: '100%', left: 0, right: 0, backgroundColor: 'var(--primary-bg-color, #fff)', boxShadow: 'var(--cover-box-shadow, 0 4px 12px rgba(0,0,0,0.1))', borderRadius: '4px', border: '1px solid var(--primary-border, #f0f0f0)', maxHeight: '200px', overflowY: 'auto', scrollbarWidth: 'none', '-ms-overflow-style': 'none', '&::-webkit-scrollbar': { display: 'none', }, zIndex: 10, display: 'none', '&.open': { display: 'block' }, '.year-item': { padding: '8px', textAlign: 'center', cursor: 'pointer', fontSize: '13px', '&:hover': { backgroundColor: 'var(--secondary-bg-color, #f5f5f5)' }, '&.active': { color: 'var(--primary-accent-color, #1890ff)', fontWeight: 'bold' }, }, }, }; export const DatePicker = (props: DatePickerProps) => { const globalCssId = getGlobalStylesId(datePickerCss); bindGlobalStyle(globalCssId, datePickerCss); const ref: RefProps = { referToCssId: globalCssId }; let currentValue = props.value || ''; const parseDate = (val: string) => { if (!val) return new Date(); const d = new Date(val); if (isNaN(d.getTime())) return new Date(); return d; }; const formatDate = (date: Date) => { const y = date.getFullYear(); const m = (date.getMonth() + 1).toString().padStart(2, '0'); const d = date.getDate().toString().padStart(2, '0'); return `${y}-${m}-${d}`; }; const updateInput = (val: string) => { currentValue = val; const input = ref.$('.&-input') as HTMLInputElement; if (input) input.value = val; }; const showPicker = () => { const input = ref.$('.&-input') as HTMLInputElement; let viewDate = parseDate(input.value); let selectedDate = parseDate(input.value); let isYearMenuOpen = false; const renderPanel = () => { const panelRef: RefProps = { referToCssId: globalCssId }; const gridVar = new HtmlVar(''); const refreshGrid = () => { gridVar.value = renderGridDays(); }; const changeYear = (delta: number) => { viewDate = new Date(viewDate.setFullYear(viewDate.getFullYear() + delta)); updateHeader(); refreshGrid(); }; const changeMonth = (m: number) => { viewDate = new Date(viewDate.setMonth(m)); updateHeader(); refreshGrid(); }; const setYear = (y: number) => { viewDate = new Date(viewDate.setFullYear(y)); isYearMenuOpen = false; updateHeader(); refreshGrid(); }; const updateHeader = () => { const yrInput = panelRef.$('.year-input') as HTMLInputElement; if (yrInput) yrInput.value = viewDate.getFullYear().toString(); const mSelect = panelRef.$('.&-month-select') as HTMLSelectElement; if (mSelect) mSelect.value = viewDate.getMonth().toString(); const yearPopup = panelRef.$('.&-year-popup'); if (yearPopup) yearPopup.classList.toggle('open', isYearMenuOpen); }; const selectDay = (day: Date, confirm = false) => { selectedDate = new Date(day); viewDate = new Date(day); if (confirm) { const val = formatDate(selectedDate); updateInput(val); props.onChange?.(val); PickerHelper.hide(); } else { refreshGrid(); } }; const renderGridDays = () => { const year = viewDate.getFullYear(); const month = viewDate.getMonth(); const firstDayOfMonth = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); // Previous month days to fill the gap const prevMonthLastDay = new Date(year, month, 0).getDate(); const daysNodes: any[] = []; // weekdays ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((w) => { daysNodes.push(
{w}
); }); const todayStr = formatDate(new Date()); const selectedStr = formatDate(selectedDate); // fill previous month days for (let i = firstDayOfMonth - 1; i >= 0; i--) { daysNodes.push(
{prevMonthLastDay - i}
); } // current month days for (let i = 1; i <= daysInMonth; i++) { const d = new Date(year, month, i); const dStr = formatDate(d); const isToday = dStr === todayStr; const isSelected = dStr === selectedStr; daysNodes.push(
selectDay(d)} onDblClick={() => selectDay(d, true)} > {i}
); } const ref2: RefProps = { referToCssId: globalCssId }; // used to replace the & in css return (
{daysNodes}
); }; const yearRange = props.yearRange || 10; const years: number[] = []; const currentYear = new Date().getFullYear(); for (let i = currentYear - 50; i <= currentYear + 50; i++) { years.push(i); } gridVar.value = renderGridDays(); return (
{ isYearMenuOpen = !isYearMenuOpen; updateHeader(); }} />
{years.map((y) => (
setYear(y)} > {y}
))}
{gridVar.node}
); }; PickerHelper.show(input, renderPanel()); }; return (
{ e.stopPropagation(); updateInput(''); props.onChange?.(''); }} >
); };