mtrl 0.3.0 → 0.3.1

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.
Files changed (60) hide show
  1. package/CLAUDE.md +33 -0
  2. package/index.ts +0 -2
  3. package/package.json +3 -1
  4. package/src/components/navigation/index.ts +4 -1
  5. package/src/components/navigation/types.ts +33 -0
  6. package/src/components/snackbar/index.ts +7 -1
  7. package/src/components/snackbar/types.ts +25 -0
  8. package/src/components/switch/index.ts +5 -1
  9. package/src/components/switch/types.ts +13 -0
  10. package/src/components/textfield/index.ts +7 -1
  11. package/src/components/textfield/types.ts +36 -0
  12. package/test/components/badge.test.ts +545 -0
  13. package/test/components/bottom-app-bar.test.ts +303 -0
  14. package/test/components/button.test.ts +233 -0
  15. package/test/components/card.test.ts +560 -0
  16. package/test/components/carousel.test.ts +951 -0
  17. package/test/components/checkbox.test.ts +462 -0
  18. package/test/components/chip.test.ts +692 -0
  19. package/test/components/datepicker.test.ts +1124 -0
  20. package/test/components/dialog.test.ts +990 -0
  21. package/test/components/divider.test.ts +412 -0
  22. package/test/components/extended-fab.test.ts +672 -0
  23. package/test/components/fab.test.ts +561 -0
  24. package/test/components/list.test.ts +365 -0
  25. package/test/components/menu.test.ts +718 -0
  26. package/test/components/navigation.test.ts +186 -0
  27. package/test/components/progress.test.ts +567 -0
  28. package/test/components/radios.test.ts +699 -0
  29. package/test/components/search.test.ts +1135 -0
  30. package/test/components/segmented-button.test.ts +732 -0
  31. package/test/components/sheet.test.ts +641 -0
  32. package/test/components/slider.test.ts +1220 -0
  33. package/test/components/snackbar.test.ts +461 -0
  34. package/test/components/switch.test.ts +452 -0
  35. package/test/components/tabs.test.ts +1369 -0
  36. package/test/components/textfield.test.ts +400 -0
  37. package/test/components/timepicker.test.ts +592 -0
  38. package/test/components/tooltip.test.ts +630 -0
  39. package/test/components/top-app-bar.test.ts +566 -0
  40. package/test/core/dom.attributes.test.ts +148 -0
  41. package/test/core/dom.classes.test.ts +152 -0
  42. package/test/core/dom.events.test.ts +243 -0
  43. package/test/core/emitter.test.ts +141 -0
  44. package/test/core/ripple.test.ts +99 -0
  45. package/test/core/state.store.test.ts +189 -0
  46. package/test/core/utils.normalize.test.ts +61 -0
  47. package/test/core/utils.object.test.ts +120 -0
  48. package/test/setup.ts +451 -0
  49. package/tsconfig.json +2 -2
  50. package/src/components/snackbar/constants.ts +0 -26
  51. package/test/components/button.test.js +0 -170
  52. package/test/components/checkbox.test.js +0 -238
  53. package/test/components/list.test.js +0 -105
  54. package/test/components/menu.test.js +0 -385
  55. package/test/components/navigation.test.js +0 -227
  56. package/test/components/snackbar.test.js +0 -234
  57. package/test/components/switch.test.js +0 -186
  58. package/test/components/textfield.test.js +0 -314
  59. package/test/core/emitter.test.js +0 -141
  60. package/test/core/ripple.test.js +0 -66
@@ -0,0 +1,1124 @@
1
+ // test/components/datepicker.test.ts
2
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
3
+ import { JSDOM } from 'jsdom';
4
+ import {
5
+ type DatePickerComponent,
6
+ type DatePickerConfig,
7
+ type DatePickerVariant,
8
+ type DatePickerView,
9
+ type DatePickerSelectionMode,
10
+ DAY_NAMES,
11
+ MONTH_NAMES,
12
+ DEFAULT_DATE_FORMAT
13
+ } from '../../src/components/datepicker/types';
14
+
15
+ // Setup jsdom environment
16
+ let dom: JSDOM;
17
+ let window: Window;
18
+ let document: Document;
19
+ let originalGlobalDocument: any;
20
+ let originalGlobalWindow: any;
21
+
22
+ beforeAll(() => {
23
+ // Create a new JSDOM instance
24
+ dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
25
+ url: 'http://localhost/',
26
+ pretendToBeVisual: true
27
+ });
28
+
29
+ // Get window and document from jsdom
30
+ window = dom.window;
31
+ document = window.document;
32
+
33
+ // Store original globals
34
+ originalGlobalDocument = global.document;
35
+ originalGlobalWindow = global.window;
36
+
37
+ // Set globals to use jsdom
38
+ global.document = document;
39
+ global.window = window;
40
+ global.Element = window.Element;
41
+ global.HTMLElement = window.HTMLElement;
42
+ global.HTMLButtonElement = window.HTMLButtonElement;
43
+ global.Event = window.Event;
44
+ });
45
+
46
+ afterAll(() => {
47
+ // Restore original globals
48
+ global.document = originalGlobalDocument;
49
+ global.window = originalGlobalWindow;
50
+
51
+ // Clean up jsdom
52
+ window.close();
53
+ });
54
+
55
+ // Mock datepicker implementation
56
+ const createMockDatePicker = (config: DatePickerConfig = {}): DatePickerComponent => {
57
+ // Create the main elements
58
+ const element = document.createElement('div');
59
+ element.className = 'mtrl-datepicker';
60
+
61
+ const input = document.createElement('input');
62
+ input.type = 'text';
63
+ input.className = 'mtrl-datepicker__input';
64
+
65
+ // Set default configuration
66
+ const settings = {
67
+ variant: config.variant || 'docked',
68
+ disabled: config.disabled || false,
69
+ initialView: config.initialView || 'day',
70
+ selectionMode: config.selectionMode || 'single',
71
+ dateFormat: config.dateFormat || DEFAULT_DATE_FORMAT,
72
+ componentName: config.componentName || 'datepicker',
73
+ prefix: config.prefix || 'mtrl',
74
+ closeOnSelect: config.closeOnSelect === undefined
75
+ ? (config.variant === 'modal' || config.variant === 'modal-input')
76
+ : config.closeOnSelect
77
+ };
78
+
79
+ // Apply variant class
80
+ if (settings.variant) {
81
+ element.classList.add(`mtrl-datepicker--${settings.variant}`);
82
+ }
83
+
84
+ // Apply disabled state
85
+ if (settings.disabled) {
86
+ element.classList.add('mtrl-datepicker--disabled');
87
+ input.disabled = true;
88
+ }
89
+
90
+ // Apply label if provided
91
+ if (config.label) {
92
+ const label = document.createElement('label');
93
+ label.className = 'mtrl-datepicker__label';
94
+ label.textContent = config.label;
95
+ element.appendChild(label);
96
+ }
97
+
98
+ // Apply placeholder if provided
99
+ if (config.placeholder) {
100
+ input.placeholder = config.placeholder;
101
+ }
102
+
103
+ // Apply additional classes
104
+ if (config.class) {
105
+ const classes = config.class.split(' ');
106
+ classes.forEach(className => element.classList.add(className));
107
+ }
108
+
109
+ element.appendChild(input);
110
+
111
+ // Create calendar container
112
+ const calendarContainer = document.createElement('div');
113
+ calendarContainer.className = 'mtrl-datepicker__calendar';
114
+ calendarContainer.style.display = 'none'; // Hidden by default
115
+
116
+ // Create calendar header
117
+ const calendarHeader = document.createElement('div');
118
+ calendarHeader.className = 'mtrl-datepicker__header';
119
+
120
+ const prevButton = document.createElement('button');
121
+ prevButton.className = 'mtrl-datepicker__prev';
122
+ prevButton.setAttribute('aria-label', 'Previous month');
123
+
124
+ const nextButton = document.createElement('button');
125
+ nextButton.className = 'mtrl-datepicker__next';
126
+ nextButton.setAttribute('aria-label', 'Next month');
127
+
128
+ const titleContainer = document.createElement('div');
129
+ titleContainer.className = 'mtrl-datepicker__title';
130
+
131
+ const monthYearDisplay = document.createElement('button');
132
+ monthYearDisplay.className = 'mtrl-datepicker__month-year';
133
+
134
+ titleContainer.appendChild(monthYearDisplay);
135
+ calendarHeader.appendChild(prevButton);
136
+ calendarHeader.appendChild(titleContainer);
137
+ calendarHeader.appendChild(nextButton);
138
+
139
+ calendarContainer.appendChild(calendarHeader);
140
+
141
+ // Create calendar body
142
+ const calendarBody = document.createElement('div');
143
+ calendarBody.className = 'mtrl-datepicker__body';
144
+
145
+ // Create days-of-week header
146
+ const daysHeader = document.createElement('div');
147
+ daysHeader.className = 'mtrl-datepicker__days-header';
148
+
149
+ DAY_NAMES.forEach(day => {
150
+ const dayEl = document.createElement('span');
151
+ dayEl.className = 'mtrl-datepicker__day-name';
152
+ dayEl.textContent = day;
153
+ daysHeader.appendChild(dayEl);
154
+ });
155
+
156
+ calendarBody.appendChild(daysHeader);
157
+
158
+ // Create days grid
159
+ const daysGrid = document.createElement('div');
160
+ daysGrid.className = 'mtrl-datepicker__days-grid';
161
+ calendarBody.appendChild(daysGrid);
162
+
163
+ // Create months grid (initially hidden)
164
+ const monthsGrid = document.createElement('div');
165
+ monthsGrid.className = 'mtrl-datepicker__months-grid';
166
+ monthsGrid.style.display = 'none';
167
+
168
+ MONTH_NAMES.forEach((month, index) => {
169
+ const monthEl = document.createElement('button');
170
+ monthEl.className = 'mtrl-datepicker__month';
171
+ monthEl.textContent = month;
172
+ monthEl.setAttribute('data-month', index.toString());
173
+ monthsGrid.appendChild(monthEl);
174
+ });
175
+
176
+ calendarBody.appendChild(monthsGrid);
177
+
178
+ // Create years grid (initially hidden)
179
+ const yearsGrid = document.createElement('div');
180
+ yearsGrid.className = 'mtrl-datepicker__years-grid';
181
+ yearsGrid.style.display = 'none';
182
+
183
+ const currentYear = new Date().getFullYear();
184
+ for (let i = currentYear - 50; i <= currentYear + 50; i++) {
185
+ const yearEl = document.createElement('button');
186
+ yearEl.className = 'mtrl-datepicker__year';
187
+ yearEl.textContent = i.toString();
188
+ yearEl.setAttribute('data-year', i.toString());
189
+ yearsGrid.appendChild(yearEl);
190
+ }
191
+
192
+ calendarBody.appendChild(yearsGrid);
193
+ calendarContainer.appendChild(calendarBody);
194
+
195
+ // Create footer with action buttons
196
+ const footer = document.createElement('div');
197
+ footer.className = 'mtrl-datepicker__footer';
198
+
199
+ const cancelButton = document.createElement('button');
200
+ cancelButton.className = 'mtrl-datepicker__cancel';
201
+ cancelButton.textContent = 'Cancel';
202
+
203
+ const okButton = document.createElement('button');
204
+ okButton.className = 'mtrl-datepicker__ok';
205
+ okButton.textContent = 'OK';
206
+
207
+ const clearButton = document.createElement('button');
208
+ clearButton.className = 'mtrl-datepicker__clear';
209
+ clearButton.textContent = 'Clear';
210
+
211
+ footer.appendChild(cancelButton);
212
+ footer.appendChild(clearButton);
213
+ footer.appendChild(okButton);
214
+
215
+ calendarContainer.appendChild(footer);
216
+ element.appendChild(calendarContainer);
217
+
218
+ // Track current state
219
+ let currentView: DatePickerView = settings.initialView as DatePickerView;
220
+ let currentDate = new Date();
221
+ let isOpen = false;
222
+ let selectedDates: Date[] = [];
223
+ let minDate: Date | null = null;
224
+ let maxDate: Date | null = null;
225
+
226
+ // Parse initial dates
227
+ if (config.minDate) {
228
+ minDate = typeof config.minDate === 'string'
229
+ ? new Date(config.minDate)
230
+ : config.minDate;
231
+ }
232
+
233
+ if (config.maxDate) {
234
+ maxDate = typeof config.maxDate === 'string'
235
+ ? new Date(config.maxDate)
236
+ : config.maxDate;
237
+ }
238
+
239
+ // Parse initial value
240
+ if (config.value) {
241
+ if (Array.isArray(config.value)) {
242
+ // Handle range selection
243
+ const startDate = typeof config.value[0] === 'string'
244
+ ? new Date(config.value[0])
245
+ : config.value[0];
246
+
247
+ const endDate = typeof config.value[1] === 'string'
248
+ ? new Date(config.value[1])
249
+ : config.value[1];
250
+
251
+ selectedDates = [startDate, endDate];
252
+ currentDate = new Date(startDate);
253
+ } else {
254
+ // Handle single date selection
255
+ const date = typeof config.value === 'string'
256
+ ? new Date(config.value)
257
+ : config.value;
258
+
259
+ selectedDates = [date];
260
+ currentDate = new Date(date);
261
+ }
262
+
263
+ // Update input value
264
+ updateInputValue();
265
+ }
266
+
267
+ // Update calendar display based on current view and date
268
+ const updateCalendarDisplay = () => {
269
+ // Update month-year title
270
+ const month = currentDate.getMonth();
271
+ const year = currentDate.getFullYear();
272
+ monthYearDisplay.textContent = `${MONTH_NAMES[month]} ${year}`;
273
+
274
+ // Show/hide appropriate grid based on current view
275
+ daysGrid.style.display = currentView === 'day' ? 'grid' : 'none';
276
+ monthsGrid.style.display = currentView === 'month' ? 'grid' : 'none';
277
+ yearsGrid.style.display = currentView === 'year' ? 'grid' : 'none';
278
+
279
+ if (currentView === 'day') {
280
+ renderDaysGrid();
281
+ } else if (currentView === 'month') {
282
+ highlightCurrentMonth();
283
+ } else if (currentView === 'year') {
284
+ highlightCurrentYear();
285
+ }
286
+ };
287
+
288
+ // Render the days grid for the current month
289
+ const renderDaysGrid = () => {
290
+ // Clear existing days
291
+ daysGrid.innerHTML = '';
292
+
293
+ const year = currentDate.getFullYear();
294
+ const month = currentDate.getMonth();
295
+
296
+ // Get first day of month and number of days
297
+ const firstDayOfMonth = new Date(year, month, 1);
298
+ const lastDayOfMonth = new Date(year, month + 1, 0);
299
+ const daysInMonth = lastDayOfMonth.getDate();
300
+
301
+ // Get day of week for first day (0-6, Sunday-Saturday)
302
+ const firstDayWeekday = firstDayOfMonth.getDay();
303
+
304
+ // Get days from previous month to display
305
+ const daysFromPrevMonth = firstDayWeekday;
306
+ const prevMonthLastDay = new Date(year, month, 0).getDate();
307
+
308
+ // Today's date for highlighting
309
+ const today = new Date();
310
+ const isCurrentMonth = today.getMonth() === month && today.getFullYear() === year;
311
+ const todayDate = today.getDate();
312
+
313
+ // Add days from previous month
314
+ for (let i = daysFromPrevMonth - 1; i >= 0; i--) {
315
+ const dayNum = prevMonthLastDay - i;
316
+ const dayEl = document.createElement('button');
317
+ dayEl.className = 'mtrl-datepicker__day mtrl-datepicker__day--outside-month';
318
+ dayEl.textContent = dayNum.toString();
319
+ dayEl.setAttribute('data-date', `${year}-${month === 0 ? 12 : month}-${dayNum}`);
320
+ daysGrid.appendChild(dayEl);
321
+ }
322
+
323
+ // Add days for current month
324
+ for (let i = 1; i <= daysInMonth; i++) {
325
+ const dayEl = document.createElement('button');
326
+ dayEl.className = 'mtrl-datepicker__day';
327
+
328
+ // Check if this day is today
329
+ if (isCurrentMonth && i === todayDate) {
330
+ dayEl.classList.add('mtrl-datepicker__day--today');
331
+ }
332
+
333
+ // Check if this day is selected
334
+ const dayDate = new Date(year, month, i);
335
+ if (selectedDates.length > 0) {
336
+ if (settings.selectionMode === 'single' && isSameDay(selectedDates[0], dayDate)) {
337
+ dayEl.classList.add('mtrl-datepicker__day--selected');
338
+ } else if (settings.selectionMode === 'range' && selectedDates.length === 2) {
339
+ // Check for range selection (start, end, and in-between days)
340
+ if (isSameDay(selectedDates[0], dayDate)) {
341
+ dayEl.classList.add('mtrl-datepicker__day--range-start');
342
+ } else if (isSameDay(selectedDates[1], dayDate)) {
343
+ dayEl.classList.add('mtrl-datepicker__day--range-end');
344
+ } else if (dayDate > selectedDates[0] && dayDate < selectedDates[1]) {
345
+ dayEl.classList.add('mtrl-datepicker__day--range-middle');
346
+ }
347
+ }
348
+ }
349
+
350
+ // Check if this day is within min/max limits
351
+ if (isDateDisabled(dayDate)) {
352
+ dayEl.classList.add('mtrl-datepicker__day--disabled');
353
+ dayEl.disabled = true;
354
+ }
355
+
356
+ dayEl.textContent = i.toString();
357
+ dayEl.setAttribute('data-date', `${year}-${month + 1}-${i}`);
358
+ daysGrid.appendChild(dayEl);
359
+
360
+ // Add click handler
361
+ dayEl.addEventListener('click', () => handleDayClick(dayDate));
362
+ }
363
+
364
+ // Add days from next month to fill the grid
365
+ const totalCells = 42; // 6 rows * 7 days
366
+ const remainingCells = totalCells - (daysFromPrevMonth + daysInMonth);
367
+
368
+ for (let i = 1; i <= remainingCells; i++) {
369
+ const dayEl = document.createElement('button');
370
+ dayEl.className = 'mtrl-datepicker__day mtrl-datepicker__day--outside-month';
371
+ dayEl.textContent = i.toString();
372
+ dayEl.setAttribute('data-date', `${year}-${month + 2}-${i}`);
373
+ daysGrid.appendChild(dayEl);
374
+ }
375
+ };
376
+
377
+ // Highlight the current month in the months grid
378
+ const highlightCurrentMonth = () => {
379
+ const currentMonth = currentDate.getMonth();
380
+
381
+ const monthButtons = monthsGrid.querySelectorAll('.mtrl-datepicker__month');
382
+ monthButtons.forEach((button: Element) => {
383
+ button.classList.remove('mtrl-datepicker__month--selected');
384
+
385
+ const monthIndex = parseInt(button.getAttribute('data-month') || '0');
386
+ if (monthIndex === currentMonth) {
387
+ button.classList.add('mtrl-datepicker__month--selected');
388
+ }
389
+
390
+ // Add click handler
391
+ button.addEventListener('click', () => {
392
+ currentDate.setMonth(monthIndex);
393
+ currentView = 'day';
394
+ updateCalendarDisplay();
395
+ });
396
+ });
397
+ };
398
+
399
+ // Highlight the current year in the years grid
400
+ const highlightCurrentYear = () => {
401
+ const currentYearValue = currentDate.getFullYear();
402
+
403
+ const yearButtons = yearsGrid.querySelectorAll('.mtrl-datepicker__year');
404
+ yearButtons.forEach((button: Element) => {
405
+ button.classList.remove('mtrl-datepicker__year--selected');
406
+
407
+ const yearValue = parseInt(button.getAttribute('data-year') || '0');
408
+ if (yearValue === currentYearValue) {
409
+ button.classList.add('mtrl-datepicker__year--selected');
410
+ // Remove scrollIntoView as it's not implemented in our JSDOM environment
411
+ }
412
+
413
+ // Add click handler
414
+ button.addEventListener('click', () => {
415
+ currentDate.setFullYear(yearValue);
416
+ currentView = 'month';
417
+ updateCalendarDisplay();
418
+ });
419
+ });
420
+ };
421
+
422
+ // Handle day selection
423
+ const handleDayClick = (date: Date) => {
424
+ if (isDateDisabled(date)) return;
425
+
426
+ if (settings.selectionMode === 'single') {
427
+ selectedDates = [new Date(date)];
428
+ updateInputValue();
429
+
430
+ if (settings.closeOnSelect) {
431
+ datepicker.close();
432
+ }
433
+ } else if (settings.selectionMode === 'range') {
434
+ if (selectedDates.length === 0 || selectedDates.length === 2) {
435
+ // Start new range selection
436
+ selectedDates = [new Date(date)];
437
+ } else {
438
+ // Complete range selection
439
+ if (date < selectedDates[0]) {
440
+ // Swap dates if second selection is before first
441
+ selectedDates = [new Date(date), selectedDates[0]];
442
+ } else {
443
+ selectedDates.push(new Date(date));
444
+ }
445
+
446
+ updateInputValue();
447
+
448
+ if (settings.closeOnSelect) {
449
+ datepicker.close();
450
+ }
451
+ }
452
+ }
453
+
454
+ renderDaysGrid();
455
+ emit('change', datepicker.getValue());
456
+ };
457
+
458
+ // Check if two dates are the same day
459
+ const isSameDay = (date1: Date, date2: Date) => {
460
+ return date1.getDate() === date2.getDate() &&
461
+ date1.getMonth() === date2.getMonth() &&
462
+ date1.getFullYear() === date2.getFullYear();
463
+ };
464
+
465
+ // Check if a date is disabled
466
+ const isDateDisabled = (date: Date) => {
467
+ if (minDate && date < minDate) return true;
468
+ if (maxDate && date > maxDate) return true;
469
+
470
+ // Check special dates
471
+ if (config.specialDates) {
472
+ for (const specialDate of config.specialDates) {
473
+ const checkDate = typeof specialDate.date === 'string'
474
+ ? new Date(specialDate.date)
475
+ : specialDate.date;
476
+
477
+ if (isSameDay(checkDate, date) && specialDate.disabled) {
478
+ return true;
479
+ }
480
+ }
481
+ }
482
+
483
+ return false;
484
+ };
485
+
486
+ // Format date to string based on format
487
+ const formatDate = (date: Date) => {
488
+ const month = date.getMonth() + 1;
489
+ const day = date.getDate();
490
+ const year = date.getFullYear();
491
+
492
+ let formatted = settings.dateFormat;
493
+ formatted = formatted.replace('MM', month.toString().padStart(2, '0'));
494
+ formatted = formatted.replace('DD', day.toString().padStart(2, '0'));
495
+ formatted = formatted.replace('YYYY', year.toString());
496
+
497
+ return formatted;
498
+ };
499
+
500
+ // Update input value based on selected dates
501
+ const updateInputValue = () => {
502
+ if (selectedDates.length === 0) {
503
+ input.value = '';
504
+ } else if (selectedDates.length === 1 || settings.selectionMode === 'single') {
505
+ input.value = formatDate(selectedDates[0]);
506
+ } else if (selectedDates.length === 2 && settings.selectionMode === 'range') {
507
+ input.value = `${formatDate(selectedDates[0])} - ${formatDate(selectedDates[1])}`;
508
+ }
509
+ };
510
+
511
+ // Event handlers
512
+ const eventHandlers: Record<string, Function[]> = {};
513
+
514
+ const emit = (event: string, data?: any) => {
515
+ if (eventHandlers[event]) {
516
+ eventHandlers[event].forEach(handler => handler(data));
517
+ }
518
+ };
519
+
520
+ // Add calendar navigation event handlers
521
+ prevButton.addEventListener('click', () => {
522
+ if (currentView === 'day') {
523
+ currentDate.setMonth(currentDate.getMonth() - 1);
524
+ } else if (currentView === 'year') {
525
+ currentDate.setFullYear(currentDate.getFullYear() - 10);
526
+ }
527
+ updateCalendarDisplay();
528
+ });
529
+
530
+ nextButton.addEventListener('click', () => {
531
+ if (currentView === 'day') {
532
+ currentDate.setMonth(currentDate.getMonth() + 1);
533
+ } else if (currentView === 'year') {
534
+ currentDate.setFullYear(currentDate.getFullYear() + 10);
535
+ }
536
+ updateCalendarDisplay();
537
+ });
538
+
539
+ // Month/year display click handler
540
+ monthYearDisplay.addEventListener('click', () => {
541
+ if (currentView === 'day') {
542
+ currentView = 'month';
543
+ } else if (currentView === 'month') {
544
+ currentView = 'year';
545
+ } else {
546
+ currentView = 'day';
547
+ }
548
+ updateCalendarDisplay();
549
+ });
550
+
551
+ // Input click handler to open datepicker
552
+ input.addEventListener('click', () => {
553
+ if (!settings.disabled) {
554
+ datepicker.open();
555
+ }
556
+ });
557
+
558
+ // Footer button handlers
559
+ cancelButton.addEventListener('click', () => {
560
+ datepicker.close();
561
+ });
562
+
563
+ okButton.addEventListener('click', () => {
564
+ datepicker.close();
565
+ });
566
+
567
+ clearButton.addEventListener('click', () => {
568
+ datepicker.clear();
569
+ });
570
+
571
+ // Create the calendar API
572
+ const calendarAPI = {
573
+ goToDate: (date: Date) => {
574
+ currentDate = new Date(date);
575
+ updateCalendarDisplay();
576
+ },
577
+
578
+ nextMonth: () => {
579
+ currentDate.setMonth(currentDate.getMonth() + 1);
580
+ updateCalendarDisplay();
581
+ },
582
+
583
+ prevMonth: () => {
584
+ currentDate.setMonth(currentDate.getMonth() - 1);
585
+ updateCalendarDisplay();
586
+ },
587
+
588
+ nextYear: () => {
589
+ currentDate.setFullYear(currentDate.getFullYear() + 1);
590
+ updateCalendarDisplay();
591
+ },
592
+
593
+ prevYear: () => {
594
+ currentDate.setFullYear(currentDate.getFullYear() - 1);
595
+ updateCalendarDisplay();
596
+ },
597
+
598
+ showDayView: () => {
599
+ currentView = 'day';
600
+ updateCalendarDisplay();
601
+ },
602
+
603
+ showMonthView: () => {
604
+ currentView = 'month';
605
+ updateCalendarDisplay();
606
+ },
607
+
608
+ showYearView: () => {
609
+ currentView = 'year';
610
+ updateCalendarDisplay();
611
+ },
612
+
613
+ getCurrentView: () => currentView
614
+ };
615
+
616
+ // Create the datepicker component
617
+ const datepicker: DatePickerComponent = {
618
+ element,
619
+ input,
620
+ calendar: calendarAPI,
621
+
622
+ disabled: {
623
+ enable: () => {
624
+ element.classList.remove('mtrl-datepicker--disabled');
625
+ input.disabled = false;
626
+ settings.disabled = false;
627
+ },
628
+ disable: () => {
629
+ element.classList.add('mtrl-datepicker--disabled');
630
+ input.disabled = true;
631
+ settings.disabled = true;
632
+ },
633
+ isDisabled: () => settings.disabled
634
+ },
635
+
636
+ lifecycle: {
637
+ destroy: () => {
638
+ datepicker.destroy();
639
+ }
640
+ },
641
+
642
+ getClass: (name: string) => {
643
+ const prefix = settings.prefix;
644
+ return name ? `${prefix}-${name}` : `${prefix}-datepicker`;
645
+ },
646
+
647
+ open: () => {
648
+ if (!settings.disabled && !isOpen) {
649
+ calendarContainer.style.display = 'block';
650
+ isOpen = true;
651
+ updateCalendarDisplay();
652
+ emit('open');
653
+ }
654
+ return datepicker;
655
+ },
656
+
657
+ close: () => {
658
+ if (isOpen) {
659
+ calendarContainer.style.display = 'none';
660
+ isOpen = false;
661
+ emit('close');
662
+ }
663
+ return datepicker;
664
+ },
665
+
666
+ getValue: () => {
667
+ if (selectedDates.length === 0) {
668
+ return null;
669
+ } else if (settings.selectionMode === 'single' || selectedDates.length === 1) {
670
+ return selectedDates[0];
671
+ } else {
672
+ return [selectedDates[0], selectedDates[1]] as [Date, Date];
673
+ }
674
+ },
675
+
676
+ setValue: (value) => {
677
+ if (value === null || value === undefined) {
678
+ selectedDates = [];
679
+ } else if (Array.isArray(value)) {
680
+ // Handle range selection
681
+ selectedDates = value.map(d => typeof d === 'string' ? new Date(d) : new Date(d));
682
+ if (selectedDates.length > 0) {
683
+ currentDate = new Date(selectedDates[0]);
684
+ }
685
+ } else {
686
+ // Handle single date selection
687
+ const date = typeof value === 'string' ? new Date(value) : new Date(value);
688
+ selectedDates = [date];
689
+ currentDate = new Date(date);
690
+ }
691
+
692
+ updateInputValue();
693
+ updateCalendarDisplay();
694
+ emit('change', datepicker.getValue());
695
+
696
+ return datepicker;
697
+ },
698
+
699
+ getFormattedValue: () => {
700
+ if (selectedDates.length === 0) {
701
+ return '';
702
+ } else if (settings.selectionMode === 'single' || selectedDates.length === 1) {
703
+ return formatDate(selectedDates[0]);
704
+ } else {
705
+ return `${formatDate(selectedDates[0])} - ${formatDate(selectedDates[1])}`;
706
+ }
707
+ },
708
+
709
+ clear: () => {
710
+ selectedDates = [];
711
+ updateInputValue();
712
+ renderDaysGrid();
713
+ emit('change', null);
714
+
715
+ return datepicker;
716
+ },
717
+
718
+ enable: () => {
719
+ datepicker.disabled.enable();
720
+ return datepicker;
721
+ },
722
+
723
+ disable: () => {
724
+ datepicker.disabled.disable();
725
+ return datepicker;
726
+ },
727
+
728
+ setMinDate: (date) => {
729
+ minDate = typeof date === 'string' ? new Date(date) : new Date(date);
730
+ updateCalendarDisplay();
731
+ return datepicker;
732
+ },
733
+
734
+ setMaxDate: (date) => {
735
+ maxDate = typeof date === 'string' ? new Date(date) : new Date(date);
736
+ updateCalendarDisplay();
737
+ return datepicker;
738
+ },
739
+
740
+ destroy: () => {
741
+ // Remove event listeners
742
+ input.removeEventListener('click', datepicker.open);
743
+
744
+ // Remove the element from the DOM if it has a parent
745
+ if (element.parentNode) {
746
+ element.parentNode.removeChild(element);
747
+ }
748
+
749
+ // Clear event handlers
750
+ for (const event in eventHandlers) {
751
+ eventHandlers[event] = [];
752
+ }
753
+ },
754
+
755
+ on: (event: string, handler: Function) => {
756
+ if (!eventHandlers[event]) {
757
+ eventHandlers[event] = [];
758
+ }
759
+ eventHandlers[event].push(handler);
760
+ return datepicker;
761
+ },
762
+
763
+ off: (event: string, handler: Function) => {
764
+ if (eventHandlers[event]) {
765
+ eventHandlers[event] = eventHandlers[event].filter(h => h !== handler);
766
+ }
767
+ return datepicker;
768
+ }
769
+ };
770
+
771
+ // Initialize calendar display
772
+ updateCalendarDisplay();
773
+
774
+ return datepicker;
775
+ };
776
+
777
+ describe('DatePicker Component', () => {
778
+ test('should create a datepicker element', () => {
779
+ const datepicker = createMockDatePicker();
780
+ expect(datepicker.element).toBeDefined();
781
+ expect(datepicker.element.tagName).toBe('DIV');
782
+ expect(datepicker.element.className).toContain('mtrl-datepicker');
783
+
784
+ expect(datepicker.input).toBeDefined();
785
+ expect(datepicker.input.tagName).toBe('INPUT');
786
+ expect(datepicker.input.className).toContain('mtrl-datepicker__input');
787
+ });
788
+
789
+ test('should support different variants', () => {
790
+ const variants: DatePickerVariant[] = ['docked', 'modal', 'modal-input'];
791
+
792
+ variants.forEach(variant => {
793
+ const datepicker = createMockDatePicker({ variant });
794
+ expect(datepicker.element.className).toContain(`mtrl-datepicker--${variant}`);
795
+ });
796
+ });
797
+
798
+ test('should set label text', () => {
799
+ const datepicker = createMockDatePicker({ label: 'Select Date' });
800
+
801
+ const label = datepicker.element.querySelector('.mtrl-datepicker__label');
802
+ expect(label).toBeDefined();
803
+ expect(label?.textContent).toBe('Select Date');
804
+ });
805
+
806
+ test('should set placeholder text', () => {
807
+ const datepicker = createMockDatePicker({ placeholder: 'MM/DD/YYYY' });
808
+
809
+ expect(datepicker.input.placeholder).toBe('MM/DD/YYYY');
810
+ });
811
+
812
+ test('should apply disabled state', () => {
813
+ const datepicker = createMockDatePicker({ disabled: true });
814
+
815
+ expect(datepicker.element.className).toContain('mtrl-datepicker--disabled');
816
+ expect(datepicker.input.disabled).toBe(true);
817
+ expect(datepicker.disabled.isDisabled()).toBe(true);
818
+ });
819
+
820
+ test('should open and close the calendar', () => {
821
+ const datepicker = createMockDatePicker();
822
+
823
+ const calendar = datepicker.element.querySelector('.mtrl-datepicker__calendar');
824
+ expect(calendar).toBeDefined();
825
+ expect(calendar?.style.display).toBe('none');
826
+
827
+ datepicker.open();
828
+ expect(calendar?.style.display).toBe('block');
829
+
830
+ datepicker.close();
831
+ expect(calendar?.style.display).toBe('none');
832
+ });
833
+
834
+ test('should navigate between months', () => {
835
+ const datepicker = createMockDatePicker();
836
+
837
+ // Set a specific date for testing
838
+ const testDate = new Date(2023, 0, 1); // January 1, 2023
839
+ datepicker.calendar.goToDate(testDate);
840
+
841
+ // Get the month-year display
842
+ const monthYearDisplay = datepicker.element.querySelector('.mtrl-datepicker__month-year');
843
+ expect(monthYearDisplay?.textContent).toContain('January 2023');
844
+
845
+ // Navigate to next month
846
+ datepicker.calendar.nextMonth();
847
+ expect(monthYearDisplay?.textContent).toContain('February 2023');
848
+
849
+ // Navigate to previous month
850
+ datepicker.calendar.prevMonth();
851
+ expect(monthYearDisplay?.textContent).toContain('January 2023');
852
+ });
853
+
854
+ test('should navigate between years', () => {
855
+ const datepicker = createMockDatePicker();
856
+
857
+ // Set a specific date for testing
858
+ const testDate = new Date(2023, 0, 1); // January 1, 2023
859
+ datepicker.calendar.goToDate(testDate);
860
+
861
+ // Get the month-year display
862
+ const monthYearDisplay = datepicker.element.querySelector('.mtrl-datepicker__month-year');
863
+ expect(monthYearDisplay?.textContent).toContain('January 2023');
864
+
865
+ // Navigate to next year
866
+ datepicker.calendar.nextYear();
867
+ expect(monthYearDisplay?.textContent).toContain('January 2024');
868
+
869
+ // Navigate to previous year
870
+ datepicker.calendar.prevYear();
871
+ expect(monthYearDisplay?.textContent).toContain('January 2023');
872
+ });
873
+
874
+ test('should switch between calendar views', () => {
875
+ const datepicker = createMockDatePicker();
876
+
877
+ // Default view should be day view
878
+ expect(datepicker.calendar.getCurrentView()).toBe('day');
879
+
880
+ const daysGrid = datepicker.element.querySelector('.mtrl-datepicker__days-grid');
881
+ const monthsGrid = datepicker.element.querySelector('.mtrl-datepicker__months-grid');
882
+ const yearsGrid = datepicker.element.querySelector('.mtrl-datepicker__years-grid');
883
+
884
+ // Open the datepicker to make sure display is updated
885
+ datepicker.open();
886
+
887
+ expect(daysGrid?.style.display).not.toBe('none');
888
+ expect(monthsGrid?.style.display).toBe('none');
889
+ expect(yearsGrid?.style.display).toBe('none');
890
+
891
+ // Switch to month view
892
+ datepicker.calendar.showMonthView();
893
+ expect(datepicker.calendar.getCurrentView()).toBe('month');
894
+ expect(daysGrid?.style.display).toBe('none');
895
+ expect(monthsGrid?.style.display).not.toBe('none');
896
+ expect(yearsGrid?.style.display).toBe('none');
897
+
898
+ // Switch to year view
899
+ datepicker.calendar.showYearView();
900
+ expect(datepicker.calendar.getCurrentView()).toBe('year');
901
+ expect(daysGrid?.style.display).toBe('none');
902
+ expect(monthsGrid?.style.display).toBe('none');
903
+ expect(yearsGrid?.style.display).not.toBe('none');
904
+
905
+ // Switch back to day view
906
+ datepicker.calendar.showDayView();
907
+ expect(datepicker.calendar.getCurrentView()).toBe('day');
908
+ expect(daysGrid?.style.display).not.toBe('none');
909
+ expect(monthsGrid?.style.display).toBe('none');
910
+ expect(yearsGrid?.style.display).toBe('none');
911
+ });
912
+
913
+ test('should set and get date value', () => {
914
+ const datepicker = createMockDatePicker();
915
+
916
+ // Initial value should be null
917
+ expect(datepicker.getValue()).toBeNull();
918
+
919
+ // Set a date
920
+ const testDate = new Date(2023, 0, 15); // January 15, 2023
921
+ datepicker.setValue(testDate);
922
+
923
+ // Get the selected date
924
+ const selectedDate = datepicker.getValue() as Date;
925
+ expect(selectedDate).toBeInstanceOf(Date);
926
+ expect(selectedDate.getFullYear()).toBe(2023);
927
+ expect(selectedDate.getMonth()).toBe(0);
928
+ expect(selectedDate.getDate()).toBe(15);
929
+
930
+ // Check input value
931
+ expect(datepicker.input.value).toBe('01/15/2023');
932
+ expect(datepicker.getFormattedValue()).toBe('01/15/2023');
933
+ });
934
+
935
+ test('should set and get date range', () => {
936
+ const datepicker = createMockDatePicker({ selectionMode: 'range' });
937
+
938
+ // Initial value should be null
939
+ expect(datepicker.getValue()).toBeNull();
940
+
941
+ // Set a date range
942
+ const startDate = new Date(2023, 0, 10); // January 10, 2023
943
+ const endDate = new Date(2023, 0, 20); // January 20, 2023
944
+ datepicker.setValue([startDate, endDate]);
945
+
946
+ // Get the selected date range
947
+ const selectedRange = datepicker.getValue() as [Date, Date];
948
+ expect(Array.isArray(selectedRange)).toBe(true);
949
+ expect(selectedRange.length).toBe(2);
950
+
951
+ expect(selectedRange[0].getFullYear()).toBe(2023);
952
+ expect(selectedRange[0].getMonth()).toBe(0);
953
+ expect(selectedRange[0].getDate()).toBe(10);
954
+
955
+ expect(selectedRange[1].getFullYear()).toBe(2023);
956
+ expect(selectedRange[1].getMonth()).toBe(0);
957
+ expect(selectedRange[1].getDate()).toBe(20);
958
+
959
+ // Check input value
960
+ expect(datepicker.input.value).toBe('01/10/2023 - 01/20/2023');
961
+ expect(datepicker.getFormattedValue()).toBe('01/10/2023 - 01/20/2023');
962
+ });
963
+
964
+ test('should clear the selected date', () => {
965
+ const datepicker = createMockDatePicker();
966
+
967
+ // Set a date
968
+ const testDate = new Date(2023, 0, 15);
969
+ datepicker.setValue(testDate);
970
+
971
+ // Check that the date is set
972
+ expect(datepicker.getValue()).not.toBeNull();
973
+ expect(datepicker.input.value).not.toBe('');
974
+
975
+ // Clear the date
976
+ datepicker.clear();
977
+
978
+ // Check that the date is cleared
979
+ expect(datepicker.getValue()).toBeNull();
980
+ expect(datepicker.input.value).toBe('');
981
+ expect(datepicker.getFormattedValue()).toBe('');
982
+ });
983
+
984
+ test('should respect min and max date constraints', () => {
985
+ const minDate = new Date(2023, 0, 10); // January 10, 2023
986
+ const maxDate = new Date(2023, 0, 20); // January 20, 2023
987
+
988
+ const datepicker = createMockDatePicker({
989
+ minDate,
990
+ maxDate
991
+ });
992
+
993
+ // Try to set a date before min date
994
+ const tooEarlyDate = new Date(2023, 0, 5);
995
+ datepicker.setValue(tooEarlyDate);
996
+
997
+ // The datepicker should accept the date (validation is only on UI interaction)
998
+ const selectedDate1 = datepicker.getValue() as Date;
999
+ expect(selectedDate1.getDate()).toBe(5);
1000
+
1001
+ // Try to set a date after max date
1002
+ const tooLateDate = new Date(2023, 0, 25);
1003
+ datepicker.setValue(tooLateDate);
1004
+
1005
+ // The datepicker should accept the date (validation is only on UI interaction)
1006
+ const selectedDate2 = datepicker.getValue() as Date;
1007
+ expect(selectedDate2.getDate()).toBe(25);
1008
+
1009
+ // Set constraints after selection
1010
+ datepicker.setValue(new Date(2023, 0, 15)); // Set to valid date
1011
+ datepicker.setMinDate(new Date(2023, 0, 10));
1012
+ datepicker.setMaxDate(new Date(2023, 0, 20));
1013
+
1014
+ // The current date should remain valid
1015
+ const selectedDate3 = datepicker.getValue() as Date;
1016
+ expect(selectedDate3.getDate()).toBe(15);
1017
+ });
1018
+
1019
+ test('should enable and disable the datepicker', () => {
1020
+ const datepicker = createMockDatePicker();
1021
+
1022
+ expect(datepicker.disabled.isDisabled()).toBe(false);
1023
+
1024
+ datepicker.disable();
1025
+
1026
+ expect(datepicker.disabled.isDisabled()).toBe(true);
1027
+ expect(datepicker.element.className).toContain('mtrl-datepicker--disabled');
1028
+ expect(datepicker.input.disabled).toBe(true);
1029
+
1030
+ datepicker.enable();
1031
+
1032
+ expect(datepicker.disabled.isDisabled()).toBe(false);
1033
+ expect(datepicker.element.className).not.toContain('mtrl-datepicker--disabled');
1034
+ expect(datepicker.input.disabled).toBe(false);
1035
+ });
1036
+
1037
+ test('should respect custom date format', () => {
1038
+ const datepicker = createMockDatePicker({
1039
+ dateFormat: 'YYYY-MM-DD'
1040
+ });
1041
+
1042
+ // Set a date
1043
+ const testDate = new Date(2023, 0, 15); // January 15, 2023
1044
+ datepicker.setValue(testDate);
1045
+
1046
+ // Check the formatted output
1047
+ expect(datepicker.input.value).toBe('2023-01-15');
1048
+ expect(datepicker.getFormattedValue()).toBe('2023-01-15');
1049
+ });
1050
+
1051
+ test('should emit change events', () => {
1052
+ const datepicker = createMockDatePicker();
1053
+ let eventFired = false;
1054
+ let eventValue: any = null;
1055
+
1056
+ datepicker.on('change', (value) => {
1057
+ eventFired = true;
1058
+ eventValue = value;
1059
+ });
1060
+
1061
+ // Set a date
1062
+ const testDate = new Date(2023, 0, 15);
1063
+ datepicker.setValue(testDate);
1064
+
1065
+ expect(eventFired).toBe(true);
1066
+ expect(eventValue).toBeInstanceOf(Date);
1067
+ expect(eventValue.getFullYear()).toBe(2023);
1068
+ expect(eventValue.getMonth()).toBe(0);
1069
+ expect(eventValue.getDate()).toBe(15);
1070
+ });
1071
+
1072
+ test('should emit open and close events', () => {
1073
+ const datepicker = createMockDatePicker();
1074
+ let openEventFired = false;
1075
+ let closeEventFired = false;
1076
+
1077
+ datepicker.on('open', () => {
1078
+ openEventFired = true;
1079
+ });
1080
+
1081
+ datepicker.on('close', () => {
1082
+ closeEventFired = true;
1083
+ });
1084
+
1085
+ datepicker.open();
1086
+ expect(openEventFired).toBe(true);
1087
+
1088
+ datepicker.close();
1089
+ expect(closeEventFired).toBe(true);
1090
+ });
1091
+
1092
+ test('should remove event listeners', () => {
1093
+ const datepicker = createMockDatePicker();
1094
+ let count = 0;
1095
+
1096
+ const handler = () => {
1097
+ count++;
1098
+ };
1099
+
1100
+ datepicker.on('change', handler);
1101
+
1102
+ // First change
1103
+ datepicker.setValue(new Date());
1104
+ expect(count).toBe(1);
1105
+
1106
+ // Remove listener
1107
+ datepicker.off('change', handler);
1108
+
1109
+ // Second change
1110
+ datepicker.setValue(new Date());
1111
+ expect(count).toBe(1); // Count should not increase
1112
+ });
1113
+
1114
+ test('should be properly destroyed', () => {
1115
+ const datepicker = createMockDatePicker();
1116
+ document.body.appendChild(datepicker.element);
1117
+
1118
+ expect(document.body.contains(datepicker.element)).toBe(true);
1119
+
1120
+ datepicker.destroy();
1121
+
1122
+ expect(document.body.contains(datepicker.element)).toBe(false);
1123
+ });
1124
+ });