@liwe3/webcomponents 1.0.2 → 1.0.14

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,550 @@
1
+ /**
2
+ * DateSelector Web Component
3
+ * A customizable date picker with single date and range selection modes
4
+ */
5
+
6
+ export type DateRange = {
7
+ start: string | null;
8
+ end: string | null;
9
+ };
10
+
11
+ export class DateSelectorElement extends HTMLElement {
12
+ declare shadowRoot: ShadowRoot;
13
+ private currentDate: Date = new Date();
14
+ private selectedDate: string | null = null;
15
+ private selectedRange: DateRange = { start: null, end: null };
16
+
17
+ // Month and day names for localization
18
+ private readonly MONTH_NAMES: string[] = [
19
+ 'January', 'February', 'March', 'April', 'May', 'June',
20
+ 'July', 'August', 'September', 'October', 'November', 'December'
21
+ ];
22
+
23
+ private readonly DAY_NAMES: string[] = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
24
+
25
+ constructor() {
26
+ super();
27
+ this.attachShadow({ mode: 'open' });
28
+ this.render();
29
+ this.attachEventListeners();
30
+ }
31
+
32
+ static get observedAttributes(): string[] {
33
+ return ['range-mode', 'selected-date', 'selected-range'];
34
+ }
35
+
36
+ attributeChangedCallback(name: string): void {
37
+ if (name === 'range-mode') {
38
+ this.selectedDate = null;
39
+ this.selectedRange = { start: null, end: null };
40
+ this.render();
41
+ }
42
+ }
43
+
44
+ get rangeMode(): boolean {
45
+ return this.hasAttribute('range-mode');
46
+ }
47
+
48
+ set rangeMode(value: boolean) {
49
+ if (value) {
50
+ this.setAttribute('range-mode', '');
51
+ } else {
52
+ this.removeAttribute('range-mode');
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Renders the date selector component
58
+ */
59
+ private render(): void {
60
+ const year = this.currentDate.getFullYear();
61
+ const month = this.currentDate.getMonth();
62
+
63
+ this.shadowRoot.innerHTML = `
64
+ <style>
65
+ :host {
66
+ display: block;
67
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
68
+ background: white;
69
+ border: 1px solid #e0e0e0;
70
+ border-radius: 8px;
71
+ padding: 16px;
72
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
73
+ width: 320px;
74
+ }
75
+
76
+ .header {
77
+ display: flex;
78
+ justify-content: space-between;
79
+ align-items: center;
80
+ margin-bottom: 16px;
81
+ }
82
+
83
+ .month-year {
84
+ font-size: 18px;
85
+ font-weight: 600;
86
+ color: #333;
87
+ padding-left: 4px;
88
+ }
89
+
90
+ .nav-buttons {
91
+ display: flex;
92
+ gap: 8px;
93
+ }
94
+
95
+ .nav-btn {
96
+ background: #f5f5f5;
97
+ border: none;
98
+ width: 32px;
99
+ height: 32px;
100
+ border-radius: 4px;
101
+ cursor: pointer;
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ font-size: 14px;
106
+ transition: background-color 0.2s;
107
+ }
108
+
109
+ .nav-btn:hover {
110
+ background: #e0e0e0;
111
+ }
112
+
113
+ .year-selector {
114
+ background: transparent;
115
+ border: none;
116
+ font-size: 18px;
117
+ font-weight: 600;
118
+ color: #333;
119
+ cursor: pointer;
120
+ padding: 4px;
121
+ border-radius: 4px;
122
+ }
123
+
124
+ .year-selector:hover {
125
+ background: #f5f5f5;
126
+ }
127
+
128
+ .calendar-grid {
129
+ display: grid;
130
+ grid-template-columns: repeat(7, 1fr);
131
+ gap: 2px;
132
+ }
133
+
134
+ .day-header {
135
+ text-align: center;
136
+ font-size: 12px;
137
+ font-weight: 600;
138
+ color: #666;
139
+ padding: 8px 4px;
140
+ }
141
+
142
+ .day-cell {
143
+ aspect-ratio: 1;
144
+ display: flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ font-size: 14px;
148
+ cursor: pointer;
149
+ border-radius: 4px;
150
+ transition: all 0.2s;
151
+ position: relative;
152
+ }
153
+
154
+ .day-cell:hover {
155
+ background: #f0f0f0;
156
+ }
157
+
158
+ .day-cell.other-month {
159
+ color: #ccc;
160
+ }
161
+
162
+ .day-cell.today {
163
+ background: #e3f2fd;
164
+ font-weight: 600;
165
+ }
166
+
167
+ .day-cell.selected {
168
+ background: #2196f3;
169
+ color: white;
170
+ }
171
+
172
+ .day-cell.range-start {
173
+ background: #2196f3;
174
+ color: white;
175
+ }
176
+
177
+ .day-cell.range-end {
178
+ background: #2196f3;
179
+ color: white;
180
+ }
181
+
182
+ .day-cell.in-range {
183
+ background: #bbdefb;
184
+ color: #1976d2;
185
+ }
186
+
187
+ .day-cell.range-hover {
188
+ background: #e3f2fd;
189
+ }
190
+
191
+ .mode-indicator {
192
+ font-size: 12px;
193
+ color: #666;
194
+ margin-bottom: 8px;
195
+ text-align: center;
196
+ }
197
+ </style>
198
+
199
+ <div class="mode-indicator">
200
+ ${this.rangeMode ? 'Select date range' : 'Select a date'}
201
+ </div>
202
+
203
+ <div class="header">
204
+ <div class="month-year">
205
+ ${this.MONTH_NAMES[month]}
206
+ <select class="year-selector" id="yearSelector">
207
+ ${this.generateYearOptions(year)}
208
+ </select>
209
+ </div>
210
+ <div class="nav-buttons">
211
+ <button class="nav-btn" id="prevMonth">‹</button>
212
+ <button class="nav-btn" id="nextMonth">›</button>
213
+ </div>
214
+ </div>
215
+
216
+ <div class="calendar-grid">
217
+ ${this.generateCalendarGrid(year, month)}
218
+ </div>
219
+ `;
220
+
221
+ // Reattach event listeners after rendering
222
+ this.attachEventListenersToShadowRoot();
223
+ }
224
+
225
+ /**
226
+ * Generates year options for the dropdown (current year ±10 years)
227
+ */
228
+ private generateYearOptions(currentYear: number): string {
229
+ const START_YEAR = currentYear - 10;
230
+ const END_YEAR = currentYear + 10;
231
+ let options = '';
232
+
233
+ for (let year = START_YEAR; year <= END_YEAR; year++) {
234
+ const selected = year === currentYear ? 'selected' : '';
235
+ options += `<option value="${year}" ${selected}>${year}</option>`;
236
+ }
237
+
238
+ return options;
239
+ }
240
+
241
+ /**
242
+ * Generates the calendar grid with day headers and day cells
243
+ */
244
+ private generateCalendarGrid(year: number, month: number): string {
245
+ let grid = '';
246
+
247
+ // Add day headers
248
+ this.DAY_NAMES.forEach(day => {
249
+ grid += `<div class="day-header">${day}</div>`;
250
+ });
251
+
252
+ // Get first day of month and number of days
253
+ const firstDay = new Date(year, month, 1);
254
+ const lastDay = new Date(year, month + 1, 0);
255
+ const firstDayOfWeek = firstDay.getDay();
256
+ const daysInMonth = lastDay.getDate();
257
+
258
+ // Add previous month's trailing days
259
+ const prevMonth = month === 0 ? 11 : month - 1;
260
+ const prevYear = month === 0 ? year - 1 : year;
261
+ const daysInPrevMonth = new Date(prevYear, prevMonth + 1, 0).getDate();
262
+
263
+ for (let i = firstDayOfWeek - 1; i >= 0; i--) {
264
+ const day = daysInPrevMonth - i;
265
+ grid += `<div class="day-cell other-month" data-date="${prevYear}-${(prevMonth + 1).toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}">${day}</div>`;
266
+ }
267
+
268
+ // Add current month days
269
+ const today = new Date();
270
+ const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month;
271
+
272
+ for (let day = 1; day <= daysInMonth; day++) {
273
+ const dateStr = `${year}-${(month + 1).toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
274
+ const isToday = isCurrentMonth && today.getDate() === day;
275
+
276
+ let classes = 'day-cell';
277
+ if (isToday) classes += ' today';
278
+
279
+ // Add selection classes
280
+ if (this.rangeMode) {
281
+ if (this.selectedRange.start && this.dateMatches(dateStr, this.selectedRange.start)) {
282
+ classes += ' range-start';
283
+ } else if (this.selectedRange.end && this.dateMatches(dateStr, this.selectedRange.end)) {
284
+ classes += ' range-end';
285
+ } else if (this.isDateInRange(dateStr)) {
286
+ classes += ' in-range';
287
+ }
288
+ } else {
289
+ if (this.selectedDate && this.dateMatches(dateStr, this.selectedDate)) {
290
+ classes += ' selected';
291
+ }
292
+ }
293
+
294
+ grid += `<div class="${classes}" data-date="${dateStr}">${day}</div>`;
295
+ }
296
+
297
+ // Add next month's leading days
298
+ const totalCells = Math.ceil((firstDayOfWeek + daysInMonth) / 7) * 7;
299
+ const remainingCells = totalCells - (firstDayOfWeek + daysInMonth);
300
+ const nextMonth = month === 11 ? 0 : month + 1;
301
+ const nextYear = month === 11 ? year + 1 : year;
302
+
303
+ for (let day = 1; day <= remainingCells; day++) {
304
+ grid += `<div class="day-cell other-month" data-date="${nextYear}-${(nextMonth + 1).toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}">${day}</div>`;
305
+ }
306
+
307
+ return grid;
308
+ }
309
+
310
+ /**
311
+ * Checks if two date strings match
312
+ */
313
+ private dateMatches(dateStr1: string, dateStr2: string): boolean {
314
+ return dateStr1 === dateStr2;
315
+ }
316
+
317
+ /**
318
+ * Checks if a date is within the selected range
319
+ */
320
+ private isDateInRange(dateStr: string): boolean {
321
+ if (!this.selectedRange.start || !this.selectedRange.end) return false;
322
+
323
+ const date = new Date(dateStr);
324
+ const start = new Date(this.selectedRange.start);
325
+ const end = new Date(this.selectedRange.end);
326
+
327
+ return date > start && date < end;
328
+ }
329
+
330
+ /**
331
+ * Attaches main event listeners (called once during construction)
332
+ */
333
+ private attachEventListeners(): void {
334
+ // Main event delegation - only attach once
335
+ this.shadowRoot.addEventListener('click', (e) => {
336
+ const target = e.target as HTMLElement;
337
+
338
+ if (target.id === 'prevMonth') {
339
+ this.navigateMonth(-1);
340
+ } else if (target.id === 'nextMonth') {
341
+ this.navigateMonth(1);
342
+ } else if (target.classList.contains('day-cell')) {
343
+ this.handleDayClick(target);
344
+ }
345
+ });
346
+
347
+ this.shadowRoot.addEventListener('change', (e) => {
348
+ const target = e.target as HTMLSelectElement;
349
+ if (target.id === 'yearSelector') {
350
+ this.navigateToYear(parseInt(target.value));
351
+ }
352
+ });
353
+
354
+ // Add hover effects for range selection
355
+ if (this.rangeMode) {
356
+ this.shadowRoot.addEventListener('mouseover', (e) => {
357
+ const target = e.target as HTMLElement;
358
+ if (target.classList.contains('day-cell') && this.selectedRange.start && !this.selectedRange.end) {
359
+ this.updateRangeHover(target.dataset.date!);
360
+ }
361
+ });
362
+
363
+ this.shadowRoot.addEventListener('mouseleave', () => {
364
+ this.clearRangeHover();
365
+ });
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Called after each render to setup any post-render event listeners
371
+ */
372
+ private attachEventListenersToShadowRoot(): void {
373
+ // This method is for any post-render setup if needed
374
+ // Currently empty as we use event delegation
375
+ }
376
+
377
+ /**
378
+ * Navigates to the previous or next month
379
+ */
380
+ private navigateMonth(direction: number): void {
381
+ const currentYear = this.currentDate.getFullYear();
382
+ const currentMonth = this.currentDate.getMonth();
383
+ const newMonth = currentMonth + direction;
384
+
385
+ // Handle year transitions properly
386
+ if (newMonth < 0) {
387
+ this.currentDate = new Date(currentYear - 1, 11, 1);
388
+ } else if (newMonth > 11) {
389
+ this.currentDate = new Date(currentYear + 1, 0, 1);
390
+ } else {
391
+ this.currentDate = new Date(currentYear, newMonth, 1);
392
+ }
393
+
394
+ this.render();
395
+ }
396
+
397
+ /**
398
+ * Navigates to a specific year
399
+ */
400
+ private navigateToYear(year: number): void {
401
+ this.currentDate = new Date(year, this.currentDate.getMonth(), 1);
402
+ this.render();
403
+ }
404
+
405
+ /**
406
+ * Handles click on a day cell
407
+ */
408
+ private handleDayClick(dayElement: HTMLElement): void {
409
+ const dateStr = dayElement.dataset.date!;
410
+
411
+ if (this.rangeMode) {
412
+ this.handleRangeSelection(dateStr);
413
+ } else {
414
+ this.handleSingleSelection(dateStr);
415
+ }
416
+
417
+ this.render();
418
+ }
419
+
420
+ /**
421
+ * Handles single date selection
422
+ */
423
+ private handleSingleSelection(dateStr: string): void {
424
+ this.selectedDate = dateStr;
425
+
426
+ // Dispatch custom event
427
+ this.dispatchEvent(new CustomEvent('dateSelected', {
428
+ detail: { date: dateStr },
429
+ bubbles: true
430
+ }));
431
+ }
432
+
433
+ /**
434
+ * Handles range selection
435
+ */
436
+ private handleRangeSelection(dateStr: string): void {
437
+ if (!this.selectedRange.start || (this.selectedRange.start && this.selectedRange.end)) {
438
+ // Start new selection
439
+ this.selectedRange = { start: dateStr, end: null };
440
+ } else {
441
+ // Complete the range
442
+ const startDate = new Date(this.selectedRange.start);
443
+ const endDate = new Date(dateStr);
444
+
445
+ if (endDate < startDate) {
446
+ // Swap if end is before start
447
+ this.selectedRange = { start: dateStr, end: this.selectedRange.start };
448
+ } else {
449
+ this.selectedRange.end = dateStr;
450
+ }
451
+
452
+ // Dispatch custom event
453
+ this.dispatchEvent(new CustomEvent('rangeSelected', {
454
+ detail: {
455
+ start: this.selectedRange.start,
456
+ end: this.selectedRange.end
457
+ },
458
+ bubbles: true
459
+ }));
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Updates hover effect when selecting a range
465
+ */
466
+ private updateRangeHover(hoverDate: string): void {
467
+ const dayCells = this.shadowRoot.querySelectorAll('.day-cell:not(.other-month)');
468
+ const startDate = new Date(this.selectedRange.start!);
469
+ const hoverDateObj = new Date(hoverDate);
470
+
471
+ dayCells.forEach(cell => {
472
+ const cellElement = cell as HTMLElement;
473
+ const cellDate = new Date(cellElement.dataset.date!);
474
+ cellElement.classList.remove('range-hover');
475
+
476
+ if (cellDate > startDate && cellDate <= hoverDateObj) {
477
+ cellElement.classList.add('range-hover');
478
+ }
479
+ });
480
+ }
481
+
482
+ /**
483
+ * Clears the hover effect for range selection
484
+ */
485
+ private clearRangeHover(): void {
486
+ const dayCells = this.shadowRoot.querySelectorAll('.day-cell');
487
+ dayCells.forEach(cell => {
488
+ cell.classList.remove('range-hover');
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Sets the selected date programmatically (single date mode only)
494
+ */
495
+ public setDate(dateStr: string): void {
496
+ if (this.rangeMode) return;
497
+ this.selectedDate = dateStr;
498
+ const date = new Date(dateStr);
499
+ this.currentDate = new Date(date.getFullYear(), date.getMonth(), 1);
500
+ this.render();
501
+ }
502
+
503
+ /**
504
+ * Sets the selected range programmatically (range mode only)
505
+ */
506
+ public setRange(startDate: string, endDate: string): void {
507
+ if (!this.rangeMode) return;
508
+ this.selectedRange = { start: startDate, end: endDate };
509
+ const date = new Date(startDate);
510
+ this.currentDate = new Date(date.getFullYear(), date.getMonth(), 1);
511
+ this.render();
512
+ }
513
+
514
+ /**
515
+ * Gets the currently selected date
516
+ */
517
+ public getSelectedDate(): string | null {
518
+ return this.selectedDate;
519
+ }
520
+
521
+ /**
522
+ * Gets the currently selected range
523
+ */
524
+ public getSelectedRange(): DateRange {
525
+ return this.selectedRange;
526
+ }
527
+
528
+ /**
529
+ * Clears the current selection
530
+ */
531
+ public clear(): void {
532
+ this.selectedDate = null;
533
+ this.selectedRange = { start: null, end: null };
534
+ this.render();
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Conditionally defines the custom element if in a browser environment.
540
+ */
541
+ const defineDateSelector = (tagName: string = 'liwe3-date-selector'): void => {
542
+ if (typeof window !== 'undefined' && !window.customElements.get(tagName)) {
543
+ customElements.define(tagName, DateSelectorElement);
544
+ }
545
+ };
546
+
547
+ // Auto-register with default tag name
548
+ defineDateSelector();
549
+
550
+ export { defineDateSelector };