@duskmoon-dev/el-datepicker 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,701 @@
1
+ import { BaseElement, css as cssTag } from '@duskmoon-dev/el-core';
2
+ import { css } from '@duskmoon-dev/core/components/datepicker';
3
+
4
+ // Strip @layer components wrapper for Shadow DOM
5
+ const strippedCss = css
6
+ .replace(/@layer\s+components\s*\{/, '')
7
+ .replace(/\}[\s]*$/, '');
8
+
9
+ const styles = cssTag`
10
+ ${strippedCss}
11
+
12
+ :host {
13
+ display: block;
14
+ }
15
+
16
+ :host([hidden]) {
17
+ display: none;
18
+ }
19
+
20
+ .datepicker {
21
+ width: 100%;
22
+ }
23
+ `;
24
+
25
+ export type DatepickerSize = 'sm' | 'md' | 'lg';
26
+ type ViewMode = 'days' | 'months' | 'years';
27
+
28
+ const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
29
+ const MONTHS = [
30
+ 'January',
31
+ 'February',
32
+ 'March',
33
+ 'April',
34
+ 'May',
35
+ 'June',
36
+ 'July',
37
+ 'August',
38
+ 'September',
39
+ 'October',
40
+ 'November',
41
+ 'December',
42
+ ];
43
+
44
+ export class ElDmDatepicker extends BaseElement {
45
+ static properties = {
46
+ value: { type: String, reflect: true, default: '' },
47
+ disabled: { type: Boolean, reflect: true, default: false },
48
+ placeholder: { type: String, reflect: true, default: 'Select date' },
49
+ format: { type: String, reflect: true, default: 'YYYY-MM-DD' },
50
+ minDate: { type: String, reflect: true, default: '' },
51
+ maxDate: { type: String, reflect: true, default: '' },
52
+ range: { type: Boolean, reflect: true, default: false },
53
+ showTime: { type: Boolean, reflect: true, default: false },
54
+ size: { type: String, reflect: true, default: 'md' },
55
+ };
56
+
57
+ value!: string;
58
+ disabled!: boolean;
59
+ placeholder!: string;
60
+ format!: string;
61
+ minDate!: string;
62
+ maxDate!: string;
63
+ range!: boolean;
64
+ showTime!: boolean;
65
+ size!: DatepickerSize;
66
+
67
+ private _isOpen = false;
68
+ private _viewMode: ViewMode = 'days';
69
+ private _viewDate = new Date();
70
+ private _selectedDate: Date | null = null;
71
+ private _rangeStart: Date | null = null;
72
+ private _rangeEnd: Date | null = null;
73
+ private _hours = 12;
74
+ private _minutes = 0;
75
+ private _period: 'AM' | 'PM' = 'AM';
76
+
77
+ constructor() {
78
+ super();
79
+ this.attachStyles(styles);
80
+ }
81
+
82
+ connectedCallback() {
83
+ super.connectedCallback();
84
+ this._parseValue();
85
+ document.addEventListener('click', this._handleOutsideClick);
86
+ }
87
+
88
+ disconnectedCallback() {
89
+ super.disconnectedCallback();
90
+ document.removeEventListener('click', this._handleOutsideClick);
91
+ }
92
+
93
+ private _handleOutsideClick = (e: MouseEvent) => {
94
+ if (!this.contains(e.target as Node)) {
95
+ this._close();
96
+ }
97
+ };
98
+
99
+ private _parseValue() {
100
+ if (!this.value) return;
101
+
102
+ if (this.range) {
103
+ const [start, end] = this.value.split(' - ');
104
+ if (start) this._rangeStart = new Date(start);
105
+ if (end) this._rangeEnd = new Date(end);
106
+ if (this._rangeStart) {
107
+ this._viewDate = new Date(this._rangeStart);
108
+ }
109
+ } else {
110
+ const date = new Date(this.value);
111
+ if (!isNaN(date.getTime())) {
112
+ this._selectedDate = date;
113
+ this._viewDate = new Date(date);
114
+
115
+ if (this.showTime) {
116
+ this._hours = date.getHours() % 12 || 12;
117
+ this._minutes = date.getMinutes();
118
+ this._period = date.getHours() >= 12 ? 'PM' : 'AM';
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ private _open() {
125
+ if (this.disabled) return;
126
+ this._isOpen = true;
127
+ this._viewMode = 'days';
128
+ this.emit('open');
129
+ this.update();
130
+ }
131
+
132
+ private _close() {
133
+ if (!this._isOpen) return;
134
+ this._isOpen = false;
135
+ this.emit('close');
136
+ this.update();
137
+ }
138
+
139
+ private _toggle() {
140
+ if (this._isOpen) {
141
+ this._close();
142
+ } else {
143
+ this._open();
144
+ }
145
+ }
146
+
147
+ private _formatDate(date: Date | null): string {
148
+ if (!date) return '';
149
+
150
+ const year = date.getFullYear();
151
+ const month = String(date.getMonth() + 1).padStart(2, '0');
152
+ const day = String(date.getDate()).padStart(2, '0');
153
+
154
+ let formatted = this.format
155
+ .replace('YYYY', String(year))
156
+ .replace('MM', month)
157
+ .replace('DD', day);
158
+
159
+ if (this.showTime) {
160
+ const hours = String(this._hours).padStart(2, '0');
161
+ const minutes = String(this._minutes).padStart(2, '0');
162
+ formatted += ` ${hours}:${minutes} ${this._period}`;
163
+ }
164
+
165
+ return formatted;
166
+ }
167
+
168
+ private _getDisplayValue(): string {
169
+ if (this.range) {
170
+ if (this._rangeStart && this._rangeEnd) {
171
+ return `${this._formatDate(this._rangeStart)} - ${this._formatDate(this._rangeEnd)}`;
172
+ } else if (this._rangeStart) {
173
+ return this._formatDate(this._rangeStart);
174
+ }
175
+ return '';
176
+ }
177
+
178
+ return this._formatDate(this._selectedDate);
179
+ }
180
+
181
+ private _prevMonth() {
182
+ this._viewDate.setMonth(this._viewDate.getMonth() - 1);
183
+ this.update();
184
+ }
185
+
186
+ private _nextMonth() {
187
+ this._viewDate.setMonth(this._viewDate.getMonth() + 1);
188
+ this.update();
189
+ }
190
+
191
+ private _prevYear() {
192
+ this._viewDate.setFullYear(this._viewDate.getFullYear() - 1);
193
+ this.update();
194
+ }
195
+
196
+ private _nextYear() {
197
+ this._viewDate.setFullYear(this._viewDate.getFullYear() + 1);
198
+ this.update();
199
+ }
200
+
201
+ private _setViewMode(mode: ViewMode) {
202
+ this._viewMode = mode;
203
+ this.update();
204
+ }
205
+
206
+ private _selectMonth(month: number) {
207
+ this._viewDate.setMonth(month);
208
+ this._viewMode = 'days';
209
+ this.update();
210
+ }
211
+
212
+ private _selectYear(year: number) {
213
+ this._viewDate.setFullYear(year);
214
+ this._viewMode = 'months';
215
+ this.update();
216
+ }
217
+
218
+ private _isDateDisabled(date: Date): boolean {
219
+ if (this.minDate) {
220
+ const min = new Date(this.minDate);
221
+ if (date < min) return true;
222
+ }
223
+ if (this.maxDate) {
224
+ const max = new Date(this.maxDate);
225
+ if (date > max) return true;
226
+ }
227
+ return false;
228
+ }
229
+
230
+ private _isToday(date: Date): boolean {
231
+ const today = new Date();
232
+ return (
233
+ date.getDate() === today.getDate() &&
234
+ date.getMonth() === today.getMonth() &&
235
+ date.getFullYear() === today.getFullYear()
236
+ );
237
+ }
238
+
239
+ private _isSelected(date: Date): boolean {
240
+ if (this.range) {
241
+ if (this._rangeStart) {
242
+ if (this._isSameDay(date, this._rangeStart)) return true;
243
+ }
244
+ if (this._rangeEnd) {
245
+ if (this._isSameDay(date, this._rangeEnd)) return true;
246
+ }
247
+ return false;
248
+ }
249
+
250
+ return this._selectedDate ? this._isSameDay(date, this._selectedDate) : false;
251
+ }
252
+
253
+ private _isInRange(date: Date): boolean {
254
+ if (!this.range || !this._rangeStart || !this._rangeEnd) return false;
255
+ return date > this._rangeStart && date < this._rangeEnd;
256
+ }
257
+
258
+ private _isRangeStart(date: Date): boolean {
259
+ return this._rangeStart ? this._isSameDay(date, this._rangeStart) : false;
260
+ }
261
+
262
+ private _isRangeEnd(date: Date): boolean {
263
+ return this._rangeEnd ? this._isSameDay(date, this._rangeEnd) : false;
264
+ }
265
+
266
+ private _isSameDay(a: Date, b: Date): boolean {
267
+ return (
268
+ a.getDate() === b.getDate() &&
269
+ a.getMonth() === b.getMonth() &&
270
+ a.getFullYear() === b.getFullYear()
271
+ );
272
+ }
273
+
274
+ private _selectDate(date: Date) {
275
+ if (this._isDateDisabled(date)) return;
276
+
277
+ if (this.range) {
278
+ if (!this._rangeStart || (this._rangeStart && this._rangeEnd)) {
279
+ this._rangeStart = date;
280
+ this._rangeEnd = null;
281
+ } else {
282
+ if (date < this._rangeStart) {
283
+ this._rangeEnd = this._rangeStart;
284
+ this._rangeStart = date;
285
+ } else {
286
+ this._rangeEnd = date;
287
+ }
288
+
289
+ this._updateValue();
290
+ if (!this.showTime) {
291
+ this._close();
292
+ }
293
+ }
294
+ } else {
295
+ this._selectedDate = date;
296
+ this._updateValue();
297
+ if (!this.showTime) {
298
+ this._close();
299
+ }
300
+ }
301
+
302
+ this.update();
303
+ }
304
+
305
+ private _updateValue() {
306
+ if (this.range) {
307
+ if (this._rangeStart && this._rangeEnd) {
308
+ this.value = `${this._formatDate(this._rangeStart)} - ${this._formatDate(this._rangeEnd)}`;
309
+ } else if (this._rangeStart) {
310
+ this.value = this._formatDate(this._rangeStart);
311
+ }
312
+ } else if (this._selectedDate) {
313
+ if (this.showTime) {
314
+ const hours24 =
315
+ this._period === 'PM'
316
+ ? this._hours === 12
317
+ ? 12
318
+ : this._hours + 12
319
+ : this._hours === 12
320
+ ? 0
321
+ : this._hours;
322
+ this._selectedDate.setHours(hours24, this._minutes);
323
+ }
324
+ this.value = this._selectedDate.toISOString();
325
+ }
326
+
327
+ this.emit('change', {
328
+ value: this.value,
329
+ date: this._selectedDate,
330
+ rangeStart: this._rangeStart,
331
+ rangeEnd: this._rangeEnd,
332
+ });
333
+ }
334
+
335
+ private _getCalendarDays(): Array<{ date: Date; isOtherMonth: boolean }> {
336
+ const year = this._viewDate.getFullYear();
337
+ const month = this._viewDate.getMonth();
338
+
339
+ const firstDay = new Date(year, month, 1);
340
+ const lastDay = new Date(year, month + 1, 0);
341
+
342
+ const days: Array<{ date: Date; isOtherMonth: boolean }> = [];
343
+
344
+ // Previous month days
345
+ const startPadding = firstDay.getDay();
346
+ for (let i = startPadding - 1; i >= 0; i--) {
347
+ const date = new Date(year, month, -i);
348
+ days.push({ date, isOtherMonth: true });
349
+ }
350
+
351
+ // Current month days
352
+ for (let i = 1; i <= lastDay.getDate(); i++) {
353
+ const date = new Date(year, month, i);
354
+ days.push({ date, isOtherMonth: false });
355
+ }
356
+
357
+ // Next month days (fill to complete 6 rows)
358
+ const remaining = 42 - days.length;
359
+ for (let i = 1; i <= remaining; i++) {
360
+ const date = new Date(year, month + 1, i);
361
+ days.push({ date, isOtherMonth: true });
362
+ }
363
+
364
+ return days;
365
+ }
366
+
367
+ private _renderDays(): string {
368
+ const days = this._getCalendarDays();
369
+
370
+ return `
371
+ <div class="datepicker-weekdays">
372
+ ${WEEKDAYS.map((d) => `<div class="datepicker-weekday">${d}</div>`).join('')}
373
+ </div>
374
+ <div class="datepicker-days">
375
+ ${days
376
+ .map(({ date, isOtherMonth }) => {
377
+ const classes = [
378
+ 'datepicker-day',
379
+ isOtherMonth ? 'datepicker-day-other-month' : '',
380
+ this._isToday(date) ? 'datepicker-day-today' : '',
381
+ this._isSelected(date) ? 'datepicker-day-selected' : '',
382
+ this._isInRange(date) ? 'datepicker-day-in-range' : '',
383
+ this._isRangeStart(date) ? 'datepicker-day-range-start' : '',
384
+ this._isRangeEnd(date) ? 'datepicker-day-range-end' : '',
385
+ ]
386
+ .filter(Boolean)
387
+ .join(' ');
388
+
389
+ return `
390
+ <button
391
+ type="button"
392
+ class="${classes}"
393
+ data-date="${date.toISOString()}"
394
+ ${this._isDateDisabled(date) ? 'disabled' : ''}
395
+ >
396
+ ${date.getDate()}
397
+ </button>
398
+ `;
399
+ })
400
+ .join('')}
401
+ </div>
402
+ `;
403
+ }
404
+
405
+ private _renderMonths(): string {
406
+ const currentMonth = this._viewDate.getMonth();
407
+
408
+ return `
409
+ <div class="datepicker-months">
410
+ ${MONTHS.map(
411
+ (name, i) => `
412
+ <button
413
+ type="button"
414
+ class="datepicker-month ${i === currentMonth ? 'selected' : ''}"
415
+ data-month="${i}"
416
+ >
417
+ ${name.slice(0, 3)}
418
+ </button>
419
+ `
420
+ ).join('')}
421
+ </div>
422
+ `;
423
+ }
424
+
425
+ private _renderYears(): string {
426
+ const currentYear = this._viewDate.getFullYear();
427
+ const startYear = currentYear - 5;
428
+
429
+ return `
430
+ <div class="datepicker-years">
431
+ ${Array.from(
432
+ { length: 12 },
433
+ (_, i) => `
434
+ <button
435
+ type="button"
436
+ class="datepicker-year ${i + startYear === currentYear ? 'selected' : ''}"
437
+ data-year="${i + startYear}"
438
+ >
439
+ ${i + startYear}
440
+ </button>
441
+ `
442
+ ).join('')}
443
+ </div>
444
+ `;
445
+ }
446
+
447
+ private _renderTime(): string {
448
+ if (!this.showTime) return '';
449
+
450
+ return `
451
+ <div class="datepicker-time">
452
+ <input
453
+ type="text"
454
+ class="datepicker-time-input"
455
+ value="${String(this._hours).padStart(2, '0')}"
456
+ data-time="hours"
457
+ maxlength="2"
458
+ />
459
+ <span class="datepicker-time-separator">:</span>
460
+ <input
461
+ type="text"
462
+ class="datepicker-time-input"
463
+ value="${String(this._minutes).padStart(2, '0')}"
464
+ data-time="minutes"
465
+ maxlength="2"
466
+ />
467
+ <div class="datepicker-time-period">
468
+ <button
469
+ type="button"
470
+ class="datepicker-time-period-btn ${this._period === 'AM' ? 'active' : ''}"
471
+ data-period="AM"
472
+ >AM</button>
473
+ <button
474
+ type="button"
475
+ class="datepicker-time-period-btn ${this._period === 'PM' ? 'active' : ''}"
476
+ data-period="PM"
477
+ >PM</button>
478
+ </div>
479
+ </div>
480
+ `;
481
+ }
482
+
483
+ private _renderHeader(): string {
484
+ const title =
485
+ this._viewMode === 'days'
486
+ ? `${MONTHS[this._viewDate.getMonth()]} ${this._viewDate.getFullYear()}`
487
+ : this._viewMode === 'months'
488
+ ? String(this._viewDate.getFullYear())
489
+ : `${this._viewDate.getFullYear() - 5} - ${this._viewDate.getFullYear() + 6}`;
490
+
491
+ return `
492
+ <div class="datepicker-header">
493
+ <div class="datepicker-nav">
494
+ <button type="button" class="datepicker-nav-btn" data-nav="prev">&lt;</button>
495
+ </div>
496
+ <button type="button" class="datepicker-title" data-action="toggle-view">
497
+ ${title}
498
+ </button>
499
+ <div class="datepicker-nav">
500
+ <button type="button" class="datepicker-nav-btn" data-nav="next">&gt;</button>
501
+ </div>
502
+ </div>
503
+ `;
504
+ }
505
+
506
+ render() {
507
+ const sizeClass = this.size !== 'md' ? `datepicker-${this.size}` : '';
508
+ const openClass = this._isOpen ? 'datepicker-open' : '';
509
+
510
+ let calendarContent = '';
511
+ switch (this._viewMode) {
512
+ case 'days':
513
+ calendarContent = this._renderDays();
514
+ break;
515
+ case 'months':
516
+ calendarContent = this._renderMonths();
517
+ break;
518
+ case 'years':
519
+ calendarContent = this._renderYears();
520
+ break;
521
+ }
522
+
523
+ return `
524
+ <div class="datepicker ${sizeClass} ${openClass}">
525
+ <input
526
+ type="text"
527
+ class="datepicker-input"
528
+ placeholder="${this.placeholder}"
529
+ value="${this._getDisplayValue()}"
530
+ ${this.disabled ? 'disabled' : ''}
531
+ readonly
532
+ />
533
+ <div class="datepicker-icon">
534
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
535
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
536
+ <line x1="16" y1="2" x2="16" y2="6"></line>
537
+ <line x1="8" y1="2" x2="8" y2="6"></line>
538
+ <line x1="3" y1="10" x2="21" y2="10"></line>
539
+ </svg>
540
+ </div>
541
+ <div class="datepicker-dropdown">
542
+ ${this._renderHeader()}
543
+ <div class="datepicker-calendar">
544
+ ${calendarContent}
545
+ </div>
546
+ ${this._renderTime()}
547
+ </div>
548
+ </div>
549
+ `;
550
+ }
551
+
552
+ update() {
553
+ super.update();
554
+ this._attachEventListeners();
555
+ }
556
+
557
+ private _attachEventListeners() {
558
+ const input = this.shadowRoot?.querySelector('.datepicker-input');
559
+ if (input) {
560
+ input.removeEventListener('click', this._toggle.bind(this));
561
+ input.addEventListener('click', this._toggle.bind(this));
562
+ }
563
+
564
+ // Navigation
565
+ const prevBtn = this.shadowRoot?.querySelector('[data-nav="prev"]');
566
+ const nextBtn = this.shadowRoot?.querySelector('[data-nav="next"]');
567
+
568
+ if (prevBtn) {
569
+ prevBtn.removeEventListener('click', this._handlePrev);
570
+ prevBtn.addEventListener('click', this._handlePrev);
571
+ }
572
+
573
+ if (nextBtn) {
574
+ nextBtn.removeEventListener('click', this._handleNext);
575
+ nextBtn.addEventListener('click', this._handleNext);
576
+ }
577
+
578
+ // Title toggle view
579
+ const titleBtn = this.shadowRoot?.querySelector('[data-action="toggle-view"]');
580
+ if (titleBtn) {
581
+ titleBtn.removeEventListener('click', this._handleToggleView);
582
+ titleBtn.addEventListener('click', this._handleToggleView);
583
+ }
584
+
585
+ // Day selection
586
+ const dayBtns = this.shadowRoot?.querySelectorAll('.datepicker-day');
587
+ dayBtns?.forEach((btn) => {
588
+ btn.removeEventListener('click', this._handleDayClick);
589
+ btn.addEventListener('click', this._handleDayClick);
590
+ });
591
+
592
+ // Month selection
593
+ const monthBtns = this.shadowRoot?.querySelectorAll('.datepicker-month');
594
+ monthBtns?.forEach((btn) => {
595
+ btn.removeEventListener('click', this._handleMonthClick);
596
+ btn.addEventListener('click', this._handleMonthClick);
597
+ });
598
+
599
+ // Year selection
600
+ const yearBtns = this.shadowRoot?.querySelectorAll('.datepicker-year');
601
+ yearBtns?.forEach((btn) => {
602
+ btn.removeEventListener('click', this._handleYearClick);
603
+ btn.addEventListener('click', this._handleYearClick);
604
+ });
605
+
606
+ // Time inputs
607
+ const timeInputs = this.shadowRoot?.querySelectorAll('.datepicker-time-input');
608
+ timeInputs?.forEach((input) => {
609
+ input.removeEventListener('change', this._handleTimeChange);
610
+ input.addEventListener('change', this._handleTimeChange);
611
+ });
612
+
613
+ // Period buttons
614
+ const periodBtns = this.shadowRoot?.querySelectorAll('.datepicker-time-period-btn');
615
+ periodBtns?.forEach((btn) => {
616
+ btn.removeEventListener('click', this._handlePeriodClick);
617
+ btn.addEventListener('click', this._handlePeriodClick);
618
+ });
619
+ }
620
+
621
+ private _handlePrev = () => {
622
+ if (this._viewMode === 'days') {
623
+ this._prevMonth();
624
+ } else if (this._viewMode === 'months') {
625
+ this._prevYear();
626
+ } else {
627
+ this._viewDate.setFullYear(this._viewDate.getFullYear() - 12);
628
+ this.update();
629
+ }
630
+ };
631
+
632
+ private _handleNext = () => {
633
+ if (this._viewMode === 'days') {
634
+ this._nextMonth();
635
+ } else if (this._viewMode === 'months') {
636
+ this._nextYear();
637
+ } else {
638
+ this._viewDate.setFullYear(this._viewDate.getFullYear() + 12);
639
+ this.update();
640
+ }
641
+ };
642
+
643
+ private _handleToggleView = () => {
644
+ if (this._viewMode === 'days') {
645
+ this._setViewMode('months');
646
+ } else if (this._viewMode === 'months') {
647
+ this._setViewMode('years');
648
+ } else {
649
+ this._setViewMode('days');
650
+ }
651
+ };
652
+
653
+ private _handleDayClick = (e: Event) => {
654
+ const btn = e.currentTarget as HTMLElement;
655
+ const dateStr = btn.dataset.date;
656
+ if (dateStr) {
657
+ this._selectDate(new Date(dateStr));
658
+ }
659
+ };
660
+
661
+ private _handleMonthClick = (e: Event) => {
662
+ const btn = e.currentTarget as HTMLElement;
663
+ const month = btn.dataset.month;
664
+ if (month !== undefined) {
665
+ this._selectMonth(parseInt(month, 10));
666
+ }
667
+ };
668
+
669
+ private _handleYearClick = (e: Event) => {
670
+ const btn = e.currentTarget as HTMLElement;
671
+ const year = btn.dataset.year;
672
+ if (year !== undefined) {
673
+ this._selectYear(parseInt(year, 10));
674
+ }
675
+ };
676
+
677
+ private _handleTimeChange = (e: Event) => {
678
+ const input = e.target as HTMLInputElement;
679
+ const type = input.dataset.time;
680
+ const value = parseInt(input.value, 10);
681
+
682
+ if (type === 'hours') {
683
+ this._hours = Math.max(1, Math.min(12, value || 12));
684
+ } else if (type === 'minutes') {
685
+ this._minutes = Math.max(0, Math.min(59, value || 0));
686
+ }
687
+
688
+ this._updateValue();
689
+ this.update();
690
+ };
691
+
692
+ private _handlePeriodClick = (e: Event) => {
693
+ const btn = e.currentTarget as HTMLElement;
694
+ const period = btn.dataset.period as 'AM' | 'PM';
695
+ if (period) {
696
+ this._period = period;
697
+ this._updateValue();
698
+ this.update();
699
+ }
700
+ };
701
+ }