import { customElement, type TemplateResult, property, state, } from '@design.estate/dees-element'; import { DeesInputBase } from '../dees-input-base.js'; import { demoFunc } from './demo.js'; import { datepickerStyles } from './styles.js'; import { renderDatepicker } from './template.js'; import type { IDateEvent } from './types.js'; import '../dees-icon.js'; import '../dees-label.js'; declare global { interface HTMLElementTagNameMap { 'dees-input-datepicker': DeesInputDatepicker; } } @customElement('dees-input-datepicker') export class DeesInputDatepicker extends DeesInputBase { public static demo = demoFunc; @property({ type: String }) public value: string = ''; @property({ type: Boolean }) public enableTime: boolean = false; @property({ type: String }) public timeFormat: '24h' | '12h' = '24h'; @property({ type: Number }) public minuteIncrement: number = 1; @property({ type: String }) public dateFormat: string = 'YYYY-MM-DD'; @property({ type: String }) public minDate: string = ''; @property({ type: String }) public maxDate: string = ''; @property({ type: Array }) public disabledDates: string[] = []; @property({ type: Number }) public weekStartsOn: 0 | 1 = 1; // Default to Monday @property({ type: String }) public placeholder: string = 'YYYY-MM-DD'; @property({ type: Boolean }) public enableTimezone: boolean = false; @property({ type: String }) public timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone; @property({ type: Array }) public events: IDateEvent[] = []; @state() public isOpened: boolean = false; @state() public opensToTop: boolean = false; @state() public selectedDate: Date | null = null; @state() public viewDate: Date = new Date(); @state() public selectedHour: number = 0; @state() public selectedMinute: number = 0; public static styles = datepickerStyles; public getTimezones(): { value: string; label: string }[] { // Common timezones with their display names return [ { 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: 'America/Phoenix', label: 'Arizona' }, { value: 'America/Anchorage', label: 'Alaska' }, { value: 'Pacific/Honolulu', label: 'Hawaii' }, { 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 { return renderDatepicker(this); } async connectedCallback() { super.connectedCallback(); this.handleClickOutside = this.handleClickOutside.bind(this); } async disconnectedCallback() { await super.disconnectedCallback(); document.removeEventListener('click', this.handleClickOutside); } async firstUpdated() { // Initialize with empty value if not set if (!this.value) { this.value = ''; } // Initialize view date and selected time if (this.value) { try { const date = new Date(this.value); if (!isNaN(date.getTime())) { this.selectedDate = date; this.viewDate = new Date(date); this.selectedHour = date.getHours(); this.selectedMinute = date.getMinutes(); } } catch { // Invalid date } } else { const now = new Date(); this.viewDate = new Date(now); this.selectedHour = now.getHours(); this.selectedMinute = 0; } } public formatDate(isoString: string): string { if (!isoString) return ''; try { const date = new Date(isoString); if (isNaN(date.getTime())) return ''; let formatted = this.dateFormat; // Basic date formatting const day = date.getDate().toString().padStart(2, '0'); const month = (date.getMonth() + 1).toString().padStart(2, '0'); const year = date.getFullYear().toString(); // Replace in correct order to avoid conflicts formatted = formatted.replace('YYYY', year); formatted = formatted.replace('YY', year.slice(-2)); formatted = formatted.replace('MM', month); formatted = formatted.replace('DD', day); // Time formatting if enabled if (this.enableTime) { const hours24 = date.getHours(); const hours12 = hours24 === 0 ? 12 : hours24 > 12 ? hours24 - 12 : hours24; const minutes = date.getMinutes().toString().padStart(2, '0'); const ampm = hours24 >= 12 ? 'PM' : 'AM'; if (this.timeFormat === '12h') { formatted += ` ${hours12}:${minutes} ${ampm}`; } else { formatted += ` ${hours24.toString().padStart(2, '0')}:${minutes}`; } } // Timezone formatting if enabled if (this.enableTimezone) { const formatter = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short', timeZone: this.timezone }); const parts = formatter.formatToParts(date); const tzPart = parts.find(part => part.type === 'timeZoneName'); if (tzPart) { formatted += ` ${tzPart.value}`; } } return formatted; } catch { return ''; } } private handleClickOutside = (event: MouseEvent) => { const path = event.composedPath(); if (!path.includes(this)) { this.isOpened = false; document.removeEventListener('click', this.handleClickOutside); } }; public async toggleCalendar(): Promise { if (this.disabled) return; this.isOpened = !this.isOpened; if (this.isOpened) { // Check available space and set position const inputContainer = this.shadowRoot!.querySelector('.input-container') as HTMLElement; const rect = inputContainer.getBoundingClientRect(); const spaceBelow = window.innerHeight - rect.bottom; const spaceAbove = rect.top; // Determine if we should open upwards (approximate height of 400px) this.opensToTop = spaceBelow < 400 && spaceAbove > spaceBelow; // Add click outside listener setTimeout(() => { document.addEventListener('click', this.handleClickOutside); }, 0); } else { document.removeEventListener('click', this.handleClickOutside); } } public 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[] = []; // Adjust for week start const startOffset = this.weekStartsOn === 1 ? (firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1) : firstDay.getDay(); // Add days from previous month for (let i = startOffset; i > 0; i--) { days.push(new Date(year, month, 1 - i)); } // Add days of current month for (let i = 1; i <= lastDay.getDate(); i++) { days.push(new Date(year, month, i)); } // Add days from next month to complete the grid (6 rows) const remainingDays = 42 - days.length; for (let i = 1; i <= remainingDays; i++) { days.push(new Date(year, month + 1, i)); } return days; } public isToday(date: Date): boolean { const today = new Date(); return date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear(); } public 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(); } public isDisabled(date: Date): boolean { // Check min date if (this.minDate) { const min = new Date(this.minDate); if (date < min) return true; } // Check max date if (this.maxDate) { const max = new Date(this.maxDate); if (date > max) return true; } // Check disabled dates if (this.disabledDates && this.disabledDates.length > 0) { return this.disabledDates.some(disabledStr => { try { const disabled = new Date(disabledStr); return date.getDate() === disabled.getDate() && date.getMonth() === disabled.getMonth() && date.getFullYear() === disabled.getFullYear(); } catch { return false; } }); } return false; } public getEventsForDate(date: Date): IDateEvent[] { if (!this.events || this.events.length === 0) return []; const dateStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`; return this.events.filter(event => event.date === dateStr); } public selectDate(date: Date): void { this.selectedDate = new Date( date.getFullYear(), date.getMonth(), date.getDate(), this.selectedHour, this.selectedMinute ); this.value = this.formatValueWithTimezone(this.selectedDate); this.changeSubject.next(this); if (!this.enableTime) { this.isOpened = false; } } public selectToday(): void { const today = new Date(); this.selectedDate = today; this.viewDate = new Date(today); this.selectedHour = today.getHours(); this.selectedMinute = today.getMinutes(); this.value = this.formatValueWithTimezone(this.selectedDate); this.changeSubject.next(this); if (!this.enableTime) { this.isOpened = false; } } public clear(): void { this.value = ''; this.selectedDate = null; this.changeSubject.next(this); this.isOpened = false; } public previousMonth(): void { this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() - 1, 1); } public nextMonth(): void { this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() + 1, 1); } public 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)); // Convert to 24h format 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.updateSelectedDateTime(); } public 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 && this.minuteIncrement > 1) { value = Math.round(value / this.minuteIncrement) * this.minuteIncrement; } this.selectedMinute = value; this.updateSelectedDateTime(); } public 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.updateSelectedDateTime(); } private updateSelectedDateTime(): void { if (this.selectedDate) { this.selectedDate = new Date( this.selectedDate.getFullYear(), this.selectedDate.getMonth(), this.selectedDate.getDate(), this.selectedHour, this.selectedMinute ); this.value = this.formatValueWithTimezone(this.selectedDate); this.changeSubject.next(this); } } public handleTimezoneChange(e: Event): void { const select = e.target as HTMLSelectElement; this.timezone = select.value; this.updateSelectedDateTime(); } private formatValueWithTimezone(date: Date): string { if (!this.enableTimezone) { return date.toISOString(); } // Format the date with timezone offset const formatter = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: this.timezone, timeZoneName: 'short' }); const parts = formatter.formatToParts(date); const dateParts: any = {}; parts.forEach(part => { dateParts[part.type] = part.value; }); // Create ISO-like format with timezone const isoString = `${dateParts.year}-${dateParts.month}-${dateParts.day}T${dateParts.hour}:${dateParts.minute}:${dateParts.second}`; // Get timezone offset const tzOffset = this.getTimezoneOffset(date, this.timezone); return `${isoString}${tzOffset}`; } private getTimezoneOffset(date: Date, timezone: string): string { // Create a date in the target timezone const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' })); const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60); const hours = Math.floor(Math.abs(offsetMinutes) / 60); const minutes = Math.abs(offsetMinutes) % 60; const sign = offsetMinutes >= 0 ? '+' : '-'; return `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; } public handleKeydown(e: KeyboardEvent): void { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.toggleCalendar(); } else if (e.key === 'Escape' && this.isOpened) { e.preventDefault(); this.isOpened = false; } } public clearValue(e: Event): void { e.stopPropagation(); this.value = ''; this.selectedDate = null; this.changeSubject.next(this); } public handleManualInput(e: InputEvent): void { const input = e.target as HTMLInputElement; const inputValue = input.value.trim(); if (!inputValue) { // Clear the value if input is empty this.value = ''; this.selectedDate = null; return; } const parsedDate = this.parseManualDate(inputValue); if (parsedDate && !isNaN(parsedDate.getTime())) { // Update internal state without triggering re-render of input this.value = parsedDate.toISOString(); this.selectedDate = parsedDate; this.viewDate = new Date(parsedDate); this.selectedHour = parsedDate.getHours(); this.selectedMinute = parsedDate.getMinutes(); this.changeSubject.next(this); } } public handleInputBlur(e: FocusEvent): void { const input = e.target as HTMLInputElement; const inputValue = input.value.trim(); if (!inputValue) { this.value = ''; this.selectedDate = null; this.changeSubject.next(this); return; } const parsedDate = this.parseManualDate(inputValue); if (parsedDate && !isNaN(parsedDate.getTime())) { this.value = parsedDate.toISOString(); this.selectedDate = parsedDate; this.viewDate = new Date(parsedDate); this.selectedHour = parsedDate.getHours(); this.selectedMinute = parsedDate.getMinutes(); this.changeSubject.next(this); // Update the input with formatted date input.value = this.formatDate(this.value); } else { // Revert to previous valid value on blur if parsing failed input.value = this.formatDate(this.value); } } private parseManualDate(input: string): Date | null { if (!input) return null; // Split date and time parts if present const parts = input.split(' '); let datePart = parts[0]; let timePart = parts[1] || ''; let parsedDate: Date | null = null; // Try different date formats // Format 1: YYYY-MM-DD (ISO-like) const isoMatch = datePart.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); if (isoMatch) { const [_, year, month, day] = isoMatch; parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); } // Format 2: DD.MM.YYYY (European) if (!parsedDate) { const euMatch = datePart.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); if (euMatch) { const [_, day, month, year] = euMatch; parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); } } // Format 3: MM/DD/YYYY (US) if (!parsedDate) { const usMatch = datePart.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (usMatch) { const [_, month, day, year] = usMatch; parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); } } // If no date was parsed, return null if (!parsedDate || isNaN(parsedDate.getTime())) { return null; } // Parse time if present (HH:MM format) if (timePart) { const timeMatch = timePart.match(/^(\d{1,2}):(\d{2})$/); if (timeMatch) { const [_, hours, minutes] = timeMatch; parsedDate.setHours(parseInt(hours)); parsedDate.setMinutes(parseInt(minutes)); } } else if (!this.enableTime) { // If time is not enabled and not provided, use current time const now = new Date(); parsedDate.setHours(now.getHours()); parsedDate.setMinutes(now.getMinutes()); parsedDate.setSeconds(0); parsedDate.setMilliseconds(0); } return parsedDate; } public getValue(): string { return this.value; } public setValue(value: string): void { this.value = value; if (value) { try { const date = new Date(value); if (!isNaN(date.getTime())) { this.selectedDate = date; this.viewDate = new Date(date); this.selectedHour = date.getHours(); this.selectedMinute = date.getMinutes(); } } catch { // Invalid date } } } }