import { customElement, type TemplateResult, property, state, html, css, cssManager, DeesElement, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { zIndexRegistry } from '../../00zindex.js'; import { themeDefaultStyles } from '../../00theme.js'; import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js'; import '../../00group-utility/dees-icon/dees-icon.js'; import type { IDateEvent } from './types.js'; declare global { interface HTMLElementTagNameMap { 'dees-input-datepicker-popup': DeesInputDatepickerPopup; } } @customElement('dees-input-datepicker-popup') export class DeesInputDatepickerPopup extends DeesElement { // Properties set by the parent @property({ attribute: false }) accessor triggerRect: DOMRect | null = null; @property({ attribute: false }) accessor ownerComponent: HTMLElement | null = null; @property({ type: Boolean }) accessor enableTime: boolean = false; @property({ type: String }) accessor timeFormat: '24h' | '12h' = '24h'; @property({ type: Number }) accessor minuteIncrement: number = 1; @property({ type: Number }) accessor weekStartsOn: 0 | 1 = 1; @property({ type: String }) accessor minDate: string = ''; @property({ type: String }) accessor maxDate: string = ''; @property({ type: Array }) accessor disabledDates: string[] = []; @property({ type: Boolean }) accessor enableTimezone: boolean = false; @property({ type: String }) accessor timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone; @property({ type: Array }) accessor events: IDateEvent[] = []; @property({ type: Boolean }) accessor opensToTop: boolean = false; // Internal state @state() accessor selectedDate: Date | null = null; @state() accessor viewDate: Date = new Date(); @state() accessor selectedHour: number = 0; @state() accessor selectedMinute: number = 0; @state() accessor menuZIndex: number = 1000; @state() accessor visible: boolean = false; private windowLayer: DeesWindowLayer | null = null; private isDestroying: boolean = false; public static styles = [ themeDefaultStyles, cssManager.defaultStyles, css` :host { position: fixed; top: 0; left: 0; width: 0; height: 0; pointer-events: none; } * { box-sizing: border-box; } .calendar-popup { position: fixed; pointer-events: auto; will-change: transform, opacity; transition: all 0.15s ease; opacity: 0; transform: translateY(-4px); background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; box-shadow: ${cssManager.bdTheme( '0 10px 15px -3px hsl(0 0% 0% / 0.1), 0 4px 6px -4px hsl(0 0% 0% / 0.1)', '0 10px 15px -3px hsl(0 0% 0% / 0.2), 0 4px 6px -4px hsl(0 0% 0% / 0.2)' )}; border-radius: 6px; padding: 12px; user-select: none; min-width: 280px; } .calendar-popup.top { transform: translateY(4px); } .calendar-popup.show { transform: translateY(0); opacity: 1; } /* Calendar Header */ .calendar-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; gap: 8px; } .month-year-display { font-weight: 500; font-size: 14px; color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; flex: 1; text-align: center; } .nav-button { width: 28px; height: 28px; border: none; background: transparent; cursor: pointer; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; transition: all 0.2s ease; } .nav-button:hover { background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; } /* Weekday headers */ .weekdays { display: grid; grid-template-columns: repeat(7, 1fr); margin-bottom: 4px; } .weekday { text-align: center; font-size: 12px; font-weight: 400; color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; padding: 0 0 8px 0; } /* Days grid */ .days-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; } .day { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 6px; font-size: 14px; transition: all 0.2s ease; color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; border: none; width: 36px; height: 36px; background: transparent; position: relative; } .day:hover:not(.disabled) { background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; } .day.other-month { color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; opacity: 0.5; } .day.today { background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; font-weight: 500; } .day.selected { background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')}; font-weight: 500; } .day.disabled { color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; cursor: not-allowed; opacity: 0.3; } /* Event indicators */ .event-indicator { position: absolute; bottom: 4px; left: 50%; transform: translateX(-50%); display: flex; gap: 2px; } .event-dot { width: 4px; height: 4px; border-radius: 50%; background: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; } .event-dot.info { background: ${cssManager.bdTheme('hsl(211 70% 52%)', 'hsl(211 70% 62%)')}; } .event-dot.warning { background: ${cssManager.bdTheme('hsl(45 90% 45%)', 'hsl(45 90% 55%)')}; } .event-dot.success { background: ${cssManager.bdTheme('hsl(142 69% 45%)', 'hsl(142 69% 55%)')}; } .event-dot.error { background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')}; } .event-count { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; padding: 0 4px; background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')}; color: white; border-radius: 8px; font-size: 10px; font-weight: 600; display: flex; align-items: center; justify-content: center; } .event-tooltip { position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); background: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')}; color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 0%)')}; padding: 8px 12px; border-radius: 6px; font-size: 12px; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity 0.2s ease; z-index: 10; } .day.has-event:hover .event-tooltip { opacity: 1; } /* Time selector */ .time-selector { margin-top: 12px; padding-top: 12px; border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; } .time-selector-title { font-size: 12px; font-weight: 500; margin-bottom: 8px; color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; } .time-inputs { display: flex; gap: 8px; align-items: center; } .time-input { width: 65px; height: 36px; border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; border-radius: 6px; padding: 0 12px; font-size: 14px; text-align: center; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; transition: all 0.2s ease; } .time-input:focus { outline: none; border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')}; } .time-separator { font-size: 14px; font-weight: 500; color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; } .am-pm-selector { display: flex; gap: 4px; margin-left: 8px; } .am-pm-button { padding: 6px 12px; border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; } .am-pm-button.selected { background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')}; border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; } .am-pm-button:hover:not(.selected) { background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; } /* Timezone selector */ .timezone-selector { margin-top: 12px; padding-top: 12px; border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; } .timezone-selector-title { font-size: 12px; font-weight: 500; margin-bottom: 8px; color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; } .timezone-select { width: 100%; height: 36px; border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; border-radius: 6px; padding: 0 12px; font-size: 14px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; cursor: pointer; } /* Action buttons */ .calendar-actions { display: flex; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; } .action-button { flex: 1; height: 36px; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; } .today-button { background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; } .today-button:hover { background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; } .clear-action-button { background: transparent; border: 1px solid transparent; color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; } .clear-action-button:hover { background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')}; color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; } `, ]; private static readonly MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; private static readonly TIMEZONES = [ { value: 'UTC', label: 'UTC (Coordinated Universal Time)' }, { value: 'America/New_York', label: 'Eastern Time (US & Canada)' }, { value: 'America/Chicago', label: 'Central Time (US & Canada)' }, { value: 'America/Denver', label: 'Mountain Time (US & Canada)' }, { value: 'America/Los_Angeles', label: 'Pacific Time (US & Canada)' }, { value: 'Europe/London', label: 'London' }, { value: 'Europe/Paris', label: 'Paris' }, { value: 'Europe/Berlin', label: 'Berlin' }, { value: 'Europe/Moscow', label: 'Moscow' }, { value: 'Asia/Dubai', label: 'Dubai' }, { value: 'Asia/Kolkata', label: 'India Standard Time' }, { value: 'Asia/Shanghai', label: 'China Standard Time' }, { value: 'Asia/Tokyo', label: 'Tokyo' }, { value: 'Australia/Sydney', label: 'Sydney' }, { value: 'Pacific/Auckland', label: 'Auckland' }, ]; public render(): TemplateResult { if (!this.triggerRect) return html``; const posStyle = this.computePositionStyle(); const weekDays = this.weekStartsOn === 1 ? ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] : ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; const days = this.getDaysInMonth(); const isAM = this.selectedHour < 12; return html`
${DeesInputDatepickerPopup.MONTH_NAMES[this.viewDate.getMonth()]} ${this.viewDate.getFullYear()}
${weekDays.map(day => html`
${day}
`)}
${days.map(day => { const isToday = this.isToday(day); const isSelected = this.isSelected(day); const isOtherMonth = day.getMonth() !== this.viewDate.getMonth(); const isDayDisabled = this.isDayDisabled(day); const dayEvents = this.getEventsForDate(day); const hasEvents = dayEvents.length > 0; const totalEventCount = dayEvents.reduce((sum, event) => sum + (event.count || 1), 0); return html`
!isDayDisabled && this.handleSelectDate(day)} > ${day.getDate()} ${hasEvents ? html` ${totalEventCount > 3 ? html`
${totalEventCount}
` : html`
${dayEvents.slice(0, 3).map(event => html`
`)}
`} ${dayEvents[0].title ? html`
${dayEvents[0].title} ${totalEventCount > 1 ? html` (+${totalEventCount - 1} more)` : ''}
` : ''} ` : ''}
`; })}
${this.enableTime ? html`
Time
12 ? this.selectedHour - 12 : this.selectedHour).toString().padStart(2, '0') : this.selectedHour.toString().padStart(2, '0')} @input=${this.handleHourInput} min="${this.timeFormat === '12h' ? 1 : 0}" max="${this.timeFormat === '12h' ? 12 : 23}" /> : ${this.timeFormat === '12h' ? html`
` : ''}
` : ''} ${this.enableTimezone ? html`
Timezone
` : ''}
`; } private computePositionStyle(): string { const rect = this.triggerRect!; const left = rect.left; if (this.opensToTop) { const bottom = window.innerHeight - rect.top + 4; return `left: ${left}px; bottom: ${bottom}px; top: auto`; } else { const top = rect.bottom + 4; return `left: ${left}px; top: ${top}px`; } } // Calendar logic private getDaysInMonth(): Date[] { const year = this.viewDate.getFullYear(); const month = this.viewDate.getMonth(); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const days: Date[] = []; const startOffset = this.weekStartsOn === 1 ? (firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1) : firstDay.getDay(); for (let i = startOffset; i > 0; i--) days.push(new Date(year, month, 1 - i)); for (let i = 1; i <= lastDay.getDate(); i++) days.push(new Date(year, month, i)); const remaining = 42 - days.length; for (let i = 1; i <= remaining; i++) days.push(new Date(year, month + 1, i)); return days; } private isToday(date: Date): boolean { const today = new Date(); return date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear(); } private isSelected(date: Date): boolean { if (!this.selectedDate) return false; return date.getDate() === this.selectedDate.getDate() && date.getMonth() === this.selectedDate.getMonth() && date.getFullYear() === this.selectedDate.getFullYear(); } private isDayDisabled(date: Date): boolean { if (this.minDate) { const min = new Date(this.minDate); if (date < min) return true; } if (this.maxDate) { const max = new Date(this.maxDate); if (date > max) return true; } if (this.disabledDates?.length) { return this.disabledDates.some(ds => { try { const d = new Date(ds); return date.getDate() === d.getDate() && date.getMonth() === d.getMonth() && date.getFullYear() === d.getFullYear(); } catch { return false; } }); } return false; } private getEventsForDate(date: Date): IDateEvent[] { if (!this.events?.length) return []; const dateStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`; return this.events.filter(e => e.date === dateStr); } private previousMonth(): void { this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() - 1, 1); } private nextMonth(): void { this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() + 1, 1); } // Event dispatching private handleSelectDate(day: Date): void { this.selectedDate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), this.selectedHour, this.selectedMinute); this.dispatchEvent(new CustomEvent('date-selected', { detail: this.selectedDate })); if (!this.enableTime) { this.dispatchEvent(new CustomEvent('close-request')); } } private handleSelectToday(): void { const today = new Date(); this.selectedDate = today; this.viewDate = new Date(today); this.selectedHour = today.getHours(); this.selectedMinute = today.getMinutes(); this.dispatchEvent(new CustomEvent('date-selected', { detail: this.selectedDate })); if (!this.enableTime) { this.dispatchEvent(new CustomEvent('close-request')); } } private handleClear(): void { this.dispatchEvent(new CustomEvent('date-cleared')); this.dispatchEvent(new CustomEvent('close-request')); } private handleHourInput = (e: InputEvent): void => { const input = e.target as HTMLInputElement; let value = parseInt(input.value) || 0; if (this.timeFormat === '12h') { value = Math.max(1, Math.min(12, value)); if (this.selectedHour >= 12 && value !== 12) this.selectedHour = value + 12; else if (this.selectedHour < 12 && value === 12) this.selectedHour = 0; else this.selectedHour = value; } else { this.selectedHour = Math.max(0, Math.min(23, value)); } this.emitTimeUpdate(); }; private handleMinuteInput = (e: InputEvent): void => { const input = e.target as HTMLInputElement; let value = parseInt(input.value) || 0; value = Math.max(0, Math.min(59, value)); if (this.minuteIncrement > 1) value = Math.round(value / this.minuteIncrement) * this.minuteIncrement; this.selectedMinute = value; this.emitTimeUpdate(); }; private setAMPM(period: 'am' | 'pm'): void { if (period === 'am' && this.selectedHour >= 12) this.selectedHour -= 12; else if (period === 'pm' && this.selectedHour < 12) this.selectedHour += 12; this.emitTimeUpdate(); } private handleTimezoneChange = (e: Event): void => { this.timezone = (e.target as HTMLSelectElement).value; this.emitTimeUpdate(); }; private emitTimeUpdate(): void { if (this.selectedDate) { this.selectedDate = new Date(this.selectedDate.getFullYear(), this.selectedDate.getMonth(), this.selectedDate.getDate(), this.selectedHour, this.selectedMinute); this.dispatchEvent(new CustomEvent('date-selected', { detail: this.selectedDate })); } } // Show/hide lifecycle public async show(): Promise { this.windowLayer = await DeesWindowLayer.createAndShow(); this.windowLayer.addEventListener('click', () => { this.dispatchEvent(new CustomEvent('close-request')); }); this.menuZIndex = zIndexRegistry.getNextZIndex(); zIndexRegistry.register(this, this.menuZIndex); this.style.zIndex = this.menuZIndex.toString(); document.body.appendChild(this); await domtools.plugins.smartdelay.delayFor(0); this.visible = true; window.addEventListener('scroll', this.handleScrollOrResize, { capture: true, passive: true }); window.addEventListener('resize', this.handleScrollOrResize, { passive: true }); } public async hide(): Promise { if (this.isDestroying) return; this.isDestroying = true; window.removeEventListener('scroll', this.handleScrollOrResize, { capture: true } as EventListenerOptions); window.removeEventListener('resize', this.handleScrollOrResize); zIndexRegistry.unregister(this); if (this.windowLayer) { this.windowLayer.destroy(); this.windowLayer = null; } this.visible = false; await domtools.plugins.smartdelay.delayFor(150); if (this.parentElement) this.parentElement.removeChild(this); this.isDestroying = false; } private handleScrollOrResize = (): void => { this.dispatchEvent(new CustomEvent('reposition-request')); }; async disconnectedCallback() { await super.disconnectedCallback(); window.removeEventListener('scroll', this.handleScrollOrResize, { capture: true } as EventListenerOptions); window.removeEventListener('resize', this.handleScrollOrResize); zIndexRegistry.unregister(this); } }