feat(input): extract datepicker popup into a window-layer overlay and enhance the code editor modal status UI

This commit is contained in:
2026-04-05 00:24:12 +00:00
parent 976039798a
commit 9bfb6446af
8 changed files with 1005 additions and 918 deletions

View File

@@ -8,7 +8,7 @@ import { DeesInputBase } from '../dees-input-base/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 { DeesInputDatepickerPopup } from './datepicker-popup.js';
import '../../00group-utility/dees-icon/dees-icon.js';
import '../../00group-layout/dees-label/dees-label.js';
@@ -49,7 +49,7 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
accessor disabledDates: string[] = [];
@property({ type: Number })
accessor weekStartsOn: 0 | 1 = 1; // Default to Monday
accessor weekStartsOn: 0 | 1 = 1;
@property({ type: String })
accessor placeholder: string = 'YYYY-MM-DD';
@@ -61,14 +61,11 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
accessor timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
@property({ type: Array })
accessor events: IDateEvent[] = [];
accessor events: import('./types.js').IDateEvent[] = [];
@state()
accessor isOpened: boolean = false;
@state()
accessor opensToTop: boolean = false;
@state()
accessor selectedDate: Date | null = null;
@@ -81,57 +78,19 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
@state()
accessor selectedMinute: number = 0;
private popupInstance: DeesInputDatepickerPopup | null = null;
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);
@@ -160,19 +119,15 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
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;
@@ -186,17 +141,11 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
// Timezone formatting if enabled
if (this.enableTimezone) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZoneName: 'short',
timeZone: this.timezone
});
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}`;
}
if (tzPart) formatted += ` ${tzPart.value}`;
}
return formatted;
@@ -205,274 +154,101 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
private handleClickOutside = (event: MouseEvent) => {
const path = event.composedPath();
if (!path.includes(this)) {
this.isOpened = false;
document.removeEventListener('click', this.handleClickOutside);
}
};
public async toggleCalendar(): Promise<void> {
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;
this.closePopup();
return;
}
// Add click outside listener
setTimeout(() => {
document.addEventListener('click', this.handleClickOutside);
}, 0);
} else {
document.removeEventListener('click', this.handleClickOutside);
this.isOpened = true;
const inputContainer = this.shadowRoot!.querySelector('.input-container') as HTMLElement;
const rect = inputContainer.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const opensToTop = spaceBelow < 400 && spaceAbove > spaceBelow;
if (!this.popupInstance) {
this.popupInstance = new DeesInputDatepickerPopup();
}
// Configure popup
this.popupInstance.triggerRect = rect;
this.popupInstance.ownerComponent = this;
this.popupInstance.opensToTop = opensToTop;
this.popupInstance.enableTime = this.enableTime;
this.popupInstance.timeFormat = this.timeFormat;
this.popupInstance.minuteIncrement = this.minuteIncrement;
this.popupInstance.weekStartsOn = this.weekStartsOn;
this.popupInstance.minDate = this.minDate;
this.popupInstance.maxDate = this.maxDate;
this.popupInstance.disabledDates = this.disabledDates;
this.popupInstance.enableTimezone = this.enableTimezone;
this.popupInstance.timezone = this.timezone;
this.popupInstance.events = this.events;
this.popupInstance.selectedDate = this.selectedDate;
this.popupInstance.viewDate = new Date(this.viewDate);
this.popupInstance.selectedHour = this.selectedHour;
this.popupInstance.selectedMinute = this.selectedMinute;
// Listen for popup events
this.popupInstance.addEventListener('date-selected', this.handleDateSelected);
this.popupInstance.addEventListener('date-cleared', this.handleDateCleared);
this.popupInstance.addEventListener('close-request', this.handleCloseRequest);
this.popupInstance.addEventListener('reposition-request', this.handleRepositionRequest);
await this.popupInstance.show();
}
private closePopup(): void {
this.isOpened = false;
if (this.popupInstance) {
this.popupInstance.removeEventListener('date-selected', this.handleDateSelected);
this.popupInstance.removeEventListener('date-cleared', this.handleDateCleared);
this.popupInstance.removeEventListener('close-request', this.handleCloseRequest);
this.popupInstance.removeEventListener('reposition-request', this.handleRepositionRequest);
this.popupInstance.hide();
}
}
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);
private handleDateSelected = (event: Event): void => {
const date = (event as CustomEvent).detail as Date;
this.selectedDate = date;
this.selectedHour = date.getHours();
this.selectedMinute = date.getMinutes();
this.viewDate = new Date(date);
this.value = this.formatValueWithTimezone(date);
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 {
private handleDateCleared = (): 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);
}
private handleCloseRequest = (): void => {
this.closePopup();
};
public nextMonth(): void {
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() + 1, 1);
}
private handleRepositionRequest = (): void => {
if (!this.popupInstance || !this.isOpened) return;
const inputContainer = this.shadowRoot!.querySelector('.input-container') as HTMLElement;
if (!inputContainer) return;
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));
const rect = inputContainer.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) {
this.closePopup();
return;
}
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')}`;
}
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
this.popupInstance.opensToTop = spaceBelow < 400 && spaceAbove > spaceBelow;
this.popupInstance.triggerRect = rect;
};
public handleKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter' || e.key === ' ') {
@@ -480,7 +256,7 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
this.toggleCalendar();
} else if (e.key === 'Escape' && this.isOpened) {
e.preventDefault();
this.isOpened = false;
this.closePopup();
}
}
@@ -494,9 +270,8 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
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;
@@ -504,7 +279,6 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
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);
@@ -517,7 +291,7 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
public handleInputBlur(e: FocusEvent): void {
const input = e.target as HTMLInputElement;
const inputValue = input.value.trim();
if (!inputValue) {
this.value = '';
this.selectedDate = null;
@@ -533,10 +307,8 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
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);
}
}
@@ -544,22 +316,17 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
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) {
@@ -568,7 +335,6 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
// Format 3: MM/DD/YYYY (US)
if (!parsedDate) {
const usMatch = datePart.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (usMatch) {
@@ -577,12 +343,8 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
// If no date was parsed, return null
if (!parsedDate || isNaN(parsedDate.getTime())) {
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) {
@@ -591,7 +353,6 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
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());
@@ -602,6 +363,34 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
return parsedDate;
}
private formatValueWithTimezone(date: Date): string {
if (!this.enableTimezone) return date.toISOString();
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; });
const isoString = `${dateParts.year}-${dateParts.month}-${dateParts.day}T${dateParts.hour}:${dateParts.minute}:${dateParts.second}`;
const tzOffset = this.getTimezoneOffset(date, this.timezone);
return `${isoString}${tzOffset}`;
}
private getTimezoneOffset(date: Date, timezone: string): string {
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 getValue(): string {
return this.value;
}
@@ -622,4 +411,16 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
}
}
async disconnectedCallback() {
await super.disconnectedCallback();
if (this.popupInstance) {
this.popupInstance.removeEventListener('date-selected', this.handleDateSelected);
this.popupInstance.removeEventListener('date-cleared', this.handleDateCleared);
this.popupInstance.removeEventListener('close-request', this.handleCloseRequest);
this.popupInstance.removeEventListener('reposition-request', this.handleRepositionRequest);
this.popupInstance.hide();
this.popupInstance = null;
}
}
}