@vanduo-oss/framework 1.3.5 → 1.3.7

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.
@@ -7,7 +7,126 @@
7
7
  'use strict';
8
8
 
9
9
  const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
10
- const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
10
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
11
+
12
+ function escapeRegexChar(c) {
13
+ return c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
+ }
15
+
16
+ function buildParseFormat(format) {
17
+ let regex = '^';
18
+ const order = [];
19
+ let i = 0;
20
+ while (i < format.length) {
21
+ const slice = format.slice(i);
22
+ if (slice.toLowerCase().startsWith('yyyy')) {
23
+ regex += '(\\d{4})';
24
+ order.push('y');
25
+ i += 4;
26
+ } else if (slice.toLowerCase().startsWith('mm')) {
27
+ regex += '(\\d{2})';
28
+ order.push('m');
29
+ i += 2;
30
+ } else if (slice.toLowerCase().startsWith('dd')) {
31
+ regex += '(\\d{2})';
32
+ order.push('d');
33
+ i += 2;
34
+ } else {
35
+ regex += escapeRegexChar(format[i]);
36
+ i++;
37
+ }
38
+ }
39
+ regex += '$';
40
+ return { regex: new RegExp(regex), order };
41
+ }
42
+
43
+ function parseDateFromFormat(value, format) {
44
+ if (!value || !format) return null;
45
+ const { regex, order } = buildParseFormat(format);
46
+ const m = value.trim().match(regex);
47
+ if (!m) return null;
48
+ let y;
49
+ let mo;
50
+ let d;
51
+ let ci = 1;
52
+ for (let k = 0; k < order.length; k++) {
53
+ const part = order[k];
54
+ const v = parseInt(m[ci++], 10);
55
+ if (Number.isNaN(v)) return null;
56
+ if (part === 'y') y = v;
57
+ else if (part === 'm') mo = v - 1;
58
+ else if (part === 'd') d = v;
59
+ }
60
+ if (y === undefined || mo === undefined || d === undefined) return null;
61
+ const dt = new Date(y, mo, d);
62
+ if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;
63
+ return dt;
64
+ }
65
+
66
+ function formatDate(d, format) {
67
+ const yyyy = String(d.getFullYear());
68
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
69
+ const dd = String(d.getDate()).padStart(2, '0');
70
+ let out = '';
71
+ let i = 0;
72
+ while (i < format.length) {
73
+ const slice = format.slice(i);
74
+ if (slice.toLowerCase().startsWith('yyyy')) {
75
+ out += yyyy;
76
+ i += 4;
77
+ } else if (slice.toLowerCase().startsWith('mm')) {
78
+ out += mm;
79
+ i += 2;
80
+ } else if (slice.toLowerCase().startsWith('dd')) {
81
+ out += dd;
82
+ i += 2;
83
+ } else {
84
+ out += format[i];
85
+ i++;
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ function dateKey(d) {
92
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
93
+ }
94
+
95
+ function addDays(d, n) {
96
+ const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
97
+ x.setDate(x.getDate() + n);
98
+ return x;
99
+ }
100
+
101
+ function addMonthsClamped(d, n) {
102
+ return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());
103
+ }
104
+
105
+ function parseYmdLocal(ymd) {
106
+ if (!ymd || typeof ymd !== 'string') return null;
107
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd.trim());
108
+ if (!m) return null;
109
+ const y = +m[1];
110
+ const mo = +m[2] - 1;
111
+ const day = +m[3];
112
+ const dt = new Date(y, mo, day);
113
+ if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;
114
+ return dt;
115
+ }
116
+
117
+ function startOfWeekSunday(d) {
118
+ const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
119
+ const day = x.getDay();
120
+ x.setDate(x.getDate() - day);
121
+ return x;
122
+ }
123
+
124
+ function endOfWeekSunday(d) {
125
+ const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
126
+ const day = x.getDay();
127
+ x.setDate(x.getDate() + (6 - day));
128
+ return x;
129
+ }
11
130
 
12
131
  const Datepicker = {
13
132
  instances: new Map(),
@@ -22,33 +141,79 @@
22
141
 
23
142
  initInstance: function (input) {
24
143
  const cleanup = [];
25
- const format = input.getAttribute('data-vd-datepicker-format') || 'yyyy-mm-dd';
144
+ const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';
26
145
  const minStr = input.getAttribute('data-vd-datepicker-min');
27
146
  const maxStr = input.getAttribute('data-vd-datepicker-max');
28
- const minDate = minStr ? new Date(minStr) : null;
29
- const maxDate = maxStr ? new Date(maxStr) : null;
147
+ const minDate = minStr ? parseYmdLocal(minStr) : null;
148
+ const maxDate = maxStr ? parseYmdLocal(maxStr) : null;
30
149
 
31
150
  const today = new Date();
32
151
  let viewYear = today.getFullYear();
33
152
  let viewMonth = today.getMonth();
34
153
  let selectedDate = null;
35
154
  let viewMode = 'days'; // days | months | years
155
+ let focusedDate = null;
156
+ /** Prevents focus() after close from immediately re-opening the popup */
157
+ let skipNextFocusOpen = false;
158
+
159
+ const isDisabled = (d) => {
160
+ if (minDate) {
161
+ const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
162
+ if (t < minDate.getTime()) return true;
163
+ }
164
+ if (maxDate) {
165
+ const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
166
+ if (t > maxDate.getTime()) return true;
167
+ }
168
+ return false;
169
+ };
170
+
171
+ const ensureMonthInRange = (y, m) => {
172
+ if (!minDate && !maxDate) return { y: y, m: m };
173
+ const first = new Date(y, m, 1);
174
+ const last = new Date(y, m + 1, 0);
175
+ if (minDate && last.getTime() < minDate.getTime()) {
176
+ return { y: minDate.getFullYear(), m: minDate.getMonth() };
177
+ }
178
+ if (maxDate && first.getTime() > maxDate.getTime()) {
179
+ return { y: maxDate.getFullYear(), m: maxDate.getMonth() };
180
+ }
181
+ return { y: y, m: m };
182
+ };
183
+
184
+ const firstSelectableInMonth = (y, m) => {
185
+ const last = new Date(y, m + 1, 0).getDate();
186
+ for (let day = 1; day <= last; day++) {
187
+ const dt = new Date(y, m, day);
188
+ if (!isDisabled(dt)) return dt;
189
+ }
190
+ return new Date(y, m, 1);
191
+ };
36
192
 
37
- // Parse existing value
38
193
  if (input.value) {
39
- const parsed = new Date(input.value);
40
- if (!isNaN(parsed.getTime())) {
194
+ const trimmed = input.value.trim();
195
+ let parsed = parseDateFromFormat(trimmed, format);
196
+ if (!parsed) {
197
+ const fallback = new Date(trimmed);
198
+ if (!isNaN(fallback.getTime())) parsed = fallback;
199
+ }
200
+ if (parsed) {
41
201
  selectedDate = parsed;
42
202
  viewYear = parsed.getFullYear();
43
203
  viewMonth = parsed.getMonth();
44
204
  }
45
205
  }
46
206
 
207
+ const clampedInit = ensureMonthInRange(viewYear, viewMonth);
208
+ viewYear = clampedInit.y;
209
+ viewMonth = clampedInit.m;
210
+
47
211
  // Create popup
48
212
  const popup = document.createElement('div');
49
213
  popup.className = 'vd-datepicker-popup';
50
214
  popup.setAttribute('role', 'dialog');
51
215
  popup.setAttribute('aria-label', 'Choose date');
216
+ popup.tabIndex = -1;
52
217
 
53
218
  const wrapper = document.createElement('div');
54
219
  wrapper.className = 'vd-suggest-wrapper';
@@ -58,24 +223,94 @@
58
223
  wrapper.appendChild(input);
59
224
  wrapper.appendChild(popup);
60
225
 
61
- const formatDate = (d) => {
62
- const yyyy = d.getFullYear();
63
- const mm = String(d.getMonth() + 1).padStart(2, '0');
64
- const dd = String(d.getDate()).padStart(2, '0');
65
- return format.replace('yyyy', yyyy).replace('mm', mm).replace('dd', dd);
66
- };
67
-
68
- const isDisabled = (d) => {
69
- if (minDate && d < minDate) return true;
70
- if (maxDate && d > maxDate) return true;
71
- return false;
72
- };
73
-
74
226
  const isSameDay = (a, b) => a && b &&
75
227
  a.getFullYear() === b.getFullYear() &&
76
228
  a.getMonth() === b.getMonth() &&
77
229
  a.getDate() === b.getDate();
78
230
 
231
+ const selectDate = (date) => {
232
+ selectedDate = date;
233
+ viewYear = date.getFullYear();
234
+ viewMonth = date.getMonth();
235
+ input.value = formatDate(date, format);
236
+ skipNextFocusOpen = true;
237
+ close();
238
+ input.dispatchEvent(new CustomEvent('datepicker:select', {
239
+ detail: { date: date, formatted: input.value },
240
+ bubbles: true
241
+ }));
242
+ input.dispatchEvent(new Event('change', { bubbles: true }));
243
+ input.focus();
244
+ };
245
+
246
+ const focusFocusedDay = () => {
247
+ if (viewMode !== 'days' || !focusedDate) return;
248
+ const key = dateKey(focusedDate);
249
+ const btn = popup.querySelector('[data-vd-date="' + key + '"]');
250
+ if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {
251
+ btn.focus();
252
+ }
253
+ };
254
+
255
+ const skipDisabled = (d, stepDir, maxSteps) => {
256
+ let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
257
+ const step = stepDir > 0 ? 1 : -1;
258
+ for (let i = 0; i < maxSteps; i++) {
259
+ if (!isDisabled(x)) return x;
260
+ x = addDays(x, step);
261
+ }
262
+ return d;
263
+ };
264
+
265
+ const createDayBtn = (day, outside, date) => {
266
+ const btn = document.createElement('button');
267
+ btn.type = 'button';
268
+ btn.className = 'vd-datepicker-day';
269
+ btn.textContent = day;
270
+ btn.setAttribute('role', 'gridcell');
271
+
272
+ if (outside) {
273
+ btn.classList.add('is-outside');
274
+ btn.tabIndex = -1;
275
+ btn.setAttribute('aria-disabled', 'true');
276
+ return btn;
277
+ }
278
+
279
+ btn.setAttribute('data-vd-date', dateKey(date));
280
+
281
+ if (date && isSameDay(date, today)) btn.classList.add('is-today');
282
+ if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');
283
+ if (date && isDisabled(date)) {
284
+ btn.classList.add('is-disabled');
285
+ btn.setAttribute('aria-disabled', 'true');
286
+ btn.tabIndex = -1;
287
+ return btn;
288
+ }
289
+
290
+ if (date) {
291
+ const isFocused = focusedDate && isSameDay(date, focusedDate);
292
+ btn.tabIndex = isFocused ? 0 : -1;
293
+
294
+ btn.addEventListener('click', () => {
295
+ selectedDate = date;
296
+ viewYear = date.getFullYear();
297
+ viewMonth = date.getMonth();
298
+ focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
299
+ input.value = formatDate(date, format);
300
+ skipNextFocusOpen = true;
301
+ close();
302
+ input.dispatchEvent(new CustomEvent('datepicker:select', {
303
+ detail: { date: date, formatted: input.value },
304
+ bubbles: true
305
+ }));
306
+ input.dispatchEvent(new Event('change', { bubbles: true }));
307
+ input.focus();
308
+ });
309
+ }
310
+
311
+ return btn;
312
+ };
313
+
79
314
  const render = () => {
80
315
  popup.innerHTML = '';
81
316
 
@@ -121,46 +356,61 @@
121
356
  popup.appendChild(header);
122
357
 
123
358
  if (viewMode === 'days') {
124
- // Weekday headers
359
+ const gridWrap = document.createElement('div');
360
+ gridWrap.className = 'vd-datepicker-grid';
361
+ gridWrap.setAttribute('role', 'grid');
362
+ gridWrap.setAttribute('aria-label', 'Calendar');
363
+
125
364
  const weekdays = document.createElement('div');
126
365
  weekdays.className = 'vd-datepicker-weekdays';
127
- DAYS.forEach(d => {
366
+ weekdays.setAttribute('role', 'row');
367
+ DAYS.forEach(function (d) {
128
368
  const span = document.createElement('span');
369
+ span.setAttribute('role', 'columnheader');
370
+ span.setAttribute('aria-label', d);
129
371
  span.textContent = d;
130
372
  weekdays.appendChild(span);
131
373
  });
132
- popup.appendChild(weekdays);
133
-
134
- // Days grid
135
- const grid = document.createElement('div');
136
- grid.className = 'vd-datepicker-days';
374
+ gridWrap.appendChild(weekdays);
137
375
 
138
376
  const firstDay = new Date(viewYear, viewMonth, 1).getDay();
139
377
  const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
140
378
  const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();
141
379
 
142
- // Previous month padding
380
+ const cells = [];
381
+
143
382
  for (let i = firstDay - 1; i >= 0; i--) {
144
- const btn = createDayBtn(daysInPrev - i, true);
145
- grid.appendChild(btn);
383
+ const dayNum = daysInPrev - i;
384
+ const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;
385
+ const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;
386
+ const date = new Date(prevYear, prevMonth, dayNum);
387
+ cells.push({ day: dayNum, outside: true, date: date });
146
388
  }
147
389
 
148
- // Current month
149
390
  for (let d = 1; d <= daysInMonth; d++) {
150
391
  const date = new Date(viewYear, viewMonth, d);
151
- const btn = createDayBtn(d, false, date);
152
- grid.appendChild(btn);
392
+ cells.push({ day: d, outside: false, date: date });
153
393
  }
154
394
 
155
- // Next month padding
156
395
  const totalCells = firstDay + daysInMonth;
157
396
  const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);
158
397
  for (let i = 1; i <= remaining; i++) {
159
- const btn = createDayBtn(i, true);
160
- grid.appendChild(btn);
398
+ const date = new Date(viewYear, viewMonth + 1, i);
399
+ cells.push({ day: i, outside: true, date: date });
161
400
  }
162
401
 
163
- popup.appendChild(grid);
402
+ for (let r = 0; r < cells.length; r += 7) {
403
+ const row = document.createElement('div');
404
+ row.className = 'vd-datepicker-row';
405
+ row.setAttribute('role', 'row');
406
+ for (let c = 0; c < 7; c++) {
407
+ const cell = cells[r + c];
408
+ row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));
409
+ }
410
+ gridWrap.appendChild(row);
411
+ }
412
+
413
+ popup.appendChild(gridWrap);
164
414
  } else if (viewMode === 'months') {
165
415
  const grid = document.createElement('div');
166
416
  grid.className = 'vd-datepicker-months';
@@ -194,47 +444,105 @@
194
444
  }
195
445
  };
196
446
 
197
- const createDayBtn = (day, outside, date) => {
198
- const btn = document.createElement('button');
199
- btn.type = 'button';
200
- btn.className = 'vd-datepicker-day';
201
- btn.textContent = day;
447
+ const handleGridKeydown = (e) => {
448
+ if (!popup.classList.contains('is-open') || viewMode !== 'days') return;
449
+ const grid = popup.querySelector('.vd-datepicker-grid');
450
+ if (!grid || !grid.contains(e.target)) return;
202
451
 
203
- if (outside) {
204
- btn.classList.add('is-outside');
205
- btn.tabIndex = -1;
206
- return btn;
452
+ const key = e.key;
453
+ if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&
454
+ key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&
455
+ key !== 'Enter' && key !== ' ' && key !== 'Escape') {
456
+ return;
207
457
  }
208
458
 
209
- if (date && isSameDay(date, today)) btn.classList.add('is-today');
210
- if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');
211
- if (date && isDisabled(date)) {
212
- btn.classList.add('is-disabled');
213
- return btn;
459
+ if (key === 'Escape') {
460
+ e.preventDefault();
461
+ e.stopPropagation();
462
+ skipNextFocusOpen = true;
463
+ close();
464
+ input.focus();
465
+ return;
214
466
  }
215
467
 
216
- if (date) {
217
- btn.addEventListener('click', () => {
218
- selectedDate = date;
219
- viewYear = date.getFullYear();
220
- viewMonth = date.getMonth();
221
- input.value = formatDate(date);
222
- close();
223
- input.dispatchEvent(new CustomEvent('datepicker:select', {
224
- detail: { date, formatted: input.value },
225
- bubbles: true
226
- }));
227
- input.dispatchEvent(new Event('change', { bubbles: true }));
228
- });
468
+ if (!focusedDate) {
469
+ focusedDate = firstSelectableInMonth(viewYear, viewMonth);
229
470
  }
230
471
 
231
- return btn;
472
+ if (key === 'Enter' || key === ' ') {
473
+ e.preventDefault();
474
+ if (focusedDate && !isDisabled(focusedDate)) {
475
+ selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));
476
+ }
477
+ return;
478
+ }
479
+
480
+ e.preventDefault();
481
+
482
+ let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());
483
+ let skipDir = 1;
484
+
485
+ if (key === 'ArrowLeft') {
486
+ next = addDays(next, -1);
487
+ skipDir = -1;
488
+ } else if (key === 'ArrowRight') {
489
+ next = addDays(next, 1);
490
+ skipDir = 1;
491
+ } else if (key === 'ArrowUp') {
492
+ next = addDays(next, -7);
493
+ skipDir = -1;
494
+ } else if (key === 'ArrowDown') {
495
+ next = addDays(next, 7);
496
+ skipDir = 1;
497
+ } else if (key === 'Home') {
498
+ next = startOfWeekSunday(next);
499
+ skipDir = 1;
500
+ } else if (key === 'End') {
501
+ next = endOfWeekSunday(next);
502
+ skipDir = -1;
503
+ } else if (key === 'PageUp') {
504
+ next = addMonthsClamped(next, -1);
505
+ skipDir = -1;
506
+ } else if (key === 'PageDown') {
507
+ next = addMonthsClamped(next, 1);
508
+ skipDir = 1;
509
+ }
510
+
511
+ next = skipDisabled(next, skipDir, 400);
512
+
513
+ if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {
514
+ viewYear = next.getFullYear();
515
+ viewMonth = next.getMonth();
516
+ const cl = ensureMonthInRange(viewYear, viewMonth);
517
+ viewYear = cl.y;
518
+ viewMonth = cl.m;
519
+ }
520
+
521
+ focusedDate = next;
522
+ render();
523
+ requestAnimationFrame(focusFocusedDay);
232
524
  };
233
525
 
234
526
  const open = () => {
527
+ viewMode = 'days';
528
+ if (selectedDate) {
529
+ viewYear = selectedDate.getFullYear();
530
+ viewMonth = selectedDate.getMonth();
531
+ }
532
+ const cl = ensureMonthInRange(viewYear, viewMonth);
533
+ viewYear = cl.y;
534
+ viewMonth = cl.m;
535
+
536
+ if (selectedDate) {
537
+ focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());
538
+ } else {
539
+ focusedDate = firstSelectableInMonth(viewYear, viewMonth);
540
+ }
541
+
235
542
  render();
236
543
  popup.classList.add('is-open');
237
544
  input.setAttribute('aria-expanded', 'true');
545
+ requestAnimationFrame(focusFocusedDay);
238
546
  };
239
547
 
240
548
  const close = () => {
@@ -243,16 +551,29 @@
243
551
  viewMode = 'days';
244
552
  };
245
553
 
246
- // Events
247
- const focusHandler = () => open();
554
+ const focusHandler = () => {
555
+ if (skipNextFocusOpen) {
556
+ skipNextFocusOpen = false;
557
+ return;
558
+ }
559
+ open();
560
+ };
248
561
  const outsideHandler = (e) => {
249
562
  if (!wrapper.contains(e.target)) close();
250
563
  };
251
- const escHandler = (e) => { if (e.key === 'Escape') close(); };
564
+ const escHandler = (e) => {
565
+ if (e.key === 'Escape' && popup.classList.contains('is-open')) {
566
+ skipNextFocusOpen = true;
567
+ close();
568
+ input.focus();
569
+ }
570
+ };
252
571
 
253
572
  input.addEventListener('focus', focusHandler);
254
573
  document.addEventListener('click', outsideHandler, true);
255
574
  document.addEventListener('keydown', escHandler);
575
+ popup.addEventListener('keydown', handleGridKeydown);
576
+
256
577
  input.setAttribute('aria-haspopup', 'dialog');
257
578
  input.setAttribute('aria-expanded', 'false');
258
579
  input.setAttribute('autocomplete', 'off');
@@ -260,10 +581,11 @@
260
581
  cleanup.push(
261
582
  () => input.removeEventListener('focus', focusHandler),
262
583
  () => document.removeEventListener('click', outsideHandler, true),
263
- () => document.removeEventListener('keydown', escHandler)
584
+ () => document.removeEventListener('keydown', escHandler),
585
+ () => popup.removeEventListener('keydown', handleGridKeydown)
264
586
  );
265
587
 
266
- this.instances.set(input, { cleanup, open, close, popup });
588
+ this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });
267
589
  },
268
590
 
269
591
  destroy: function (el) {