Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/assets/javascript/datepicker.js

O Operator
Diff

/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/assets/javascript/datepicker.js

added on local at 2026-07-01 12:34:03

Added
+824
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to b14a57ce027b
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1/*======================================================================
2 * datepicker.js -- vanilla-JS date + time picker (no deps).
3 *
4 * Auto-attaches to every `<input type="datetime-local" data-fancy>`
5 * on DOMContentLoaded. Renders a polished popover with:
6 * - month-grid calendar (prev / next month arrows, day-of-week header,
7 * cells outside the active month are dimmed, today is ringed,
8 * selected date is filled with the brand color)
9 * - hour / minute number inputs with +/- spinners + quick-set chips
10 * - "Today" / "Now" / "Clear" actions
11 * - keyboard nav: arrows move the day, Enter selects, Esc closes,
12 * PageUp/PageDown change month
13 * - smart placement (flips above when the viewport bottom is too close)
14 * - theme follows CSS vars --col-brand / --col-brand-2 / --col-bg-2 /
15 * --col-border / --col-text / --col-surface (with sensible fallbacks)
16 *
17 * The native input is hidden visually but kept in the DOM so the form
18 * still submits its value. The picker writes to .value with the standard
19 * datetime-local format (YYYY-MM-DDTHH:MM).
20 *
21 * Usage:
22 * <input type="datetime-local" name="from" data-fancy>
23 *======================================================================*/
24(function () {
25 'use strict';
26
27 // --------------------------------------------------------------------
28 // Style injection -- one global stylesheet for all pickers on the page.
29 // --------------------------------------------------------------------
30 function injectStyles() {
31 if (document.getElementById('fdp-styles')) return;
32 var css = `
33 .fdp-wrap { position: relative; display: inline-block; }
34 .fdp-display {
35 background: var(--col-bg-2, #0e1822);
36 color: var(--col-text, #e2e8f0);
37 border: 1px solid var(--col-border, rgba(148,163,184,0.18));
38 border-radius: 8px;
39 padding: 6px 32px 6px 12px;
40 font: inherit;
41 font-size: 12.5px;
42 cursor: pointer;
43 outline: none;
44 min-width: 200px;
45 transition: border-color .15s ease, box-shadow .15s ease;
46 font-variant-numeric: tabular-nums;
47 }
48 .fdp-display:hover, .fdp-display:focus { border-color: var(--col-brand, #06b6d4); }
49 .fdp-display.has-value { color: var(--col-text, #e2e8f0); }
50 .fdp-display.empty { color: var(--col-text-3, #64748b); }
51 .fdp-wrap::after {
52 content: '';
53 position: absolute;
54 right: 12px; top: 50%;
55 width: 7px; height: 7px;
56 border-right: 1.5px solid var(--col-text-3, #64748b);
57 border-bottom: 1.5px solid var(--col-text-3, #64748b);
58 transform: translateY(-70%) rotate(45deg);
59 pointer-events: none;
60 transition: transform .15s ease, border-color .15s ease;
61 }
62 .fdp-wrap.is-open::after {
63 transform: translateY(-30%) rotate(-135deg);
64 border-color: var(--col-brand, #06b6d4);
65 }
66
67 .fdp-pop {
68 position: absolute;
69 top: calc(100% + 6px); left: 0;
70 background: var(--col-surface, #0f1a26);
71 border: 1px solid var(--col-border, rgba(148,163,184,0.18));
72 border-radius: 14px;
73 box-shadow: 0 20px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(6,182,212,0.08);
74 padding: 14px;
75 z-index: 9999;
76 width: 320px;
77 font-size: 12.5px;
78 color: var(--col-text, #e2e8f0);
79 user-select: none;
80 animation: fdp-pop-in .12s ease-out;
81 backdrop-filter: blur(10px);
82 -webkit-backdrop-filter: blur(10px);
83 }
84 .fdp-pop.flip-up { top: auto; bottom: calc(100% + 6px); }
85 @keyframes fdp-pop-in {
86 from { opacity: 0; transform: translateY(-4px); }
87 to { opacity: 1; transform: translateY(0); }
88 }
89
90 .fdp-pop-head {
91 display: flex;
92 align-items: center;
93 justify-content: space-between;
94 margin-bottom: 10px;
95 gap: 6px;
96 }
97 .fdp-pop-title {
98 flex: 1;
99 text-align: center;
100 font-weight: 700;
101 font-size: 13.5px;
102 letter-spacing: .3px;
103 color: #fff;
104 background: none;
105 border: 1px solid transparent;
106 border-radius: 6px;
107 padding: 4px 10px;
108 cursor: pointer;
109 transition: all .15s ease;
110 font-family: inherit;
111 }
112 .fdp-pop-title:hover { background: var(--col-bg-2, #0e1822); border-color: var(--col-border, rgba(148,163,184,0.18)); }
113 .fdp-nav-btn {
114 background: var(--col-bg-2, #0e1822);
115 border: 1px solid var(--col-border, rgba(148,163,184,0.18));
116 color: var(--col-text-2, #cbd5e1);
117 border-radius: 6px;
118 width: 28px; height: 28px;
119 display: flex; align-items: center; justify-content: center;
120 cursor: pointer;
121 transition: all .15s ease;
122 padding: 0;
123 }
124 .fdp-nav-btn:hover { color: var(--col-brand, #06b6d4); border-color: var(--col-brand, #06b6d4); }
125 .fdp-nav-btn svg { width: 12px; height: 12px; }
126
127 .fdp-dows {
128 display: grid;
129 grid-template-columns: repeat(7, 1fr);
130 gap: 2px;
131 margin-bottom: 4px;
132 }
133 .fdp-dow {
134 font-size: 10px;
135 font-weight: 700;
136 letter-spacing: 1px;
137 text-transform: uppercase;
138 text-align: center;
139 color: var(--col-text-3, #64748b);
140 padding: 4px 0;
141 }
142
143 .fdp-grid {
144 display: grid;
145 grid-template-columns: repeat(7, 1fr);
146 gap: 2px;
147 }
148 .fdp-cell {
149 aspect-ratio: 1;
150 display: flex;
151 align-items: center;
152 justify-content: center;
153 font-size: 12px;
154 font-weight: 500;
155 color: var(--col-text-2, #cbd5e1);
156 background: transparent;
157 border: 1px solid transparent;
158 border-radius: 8px;
159 cursor: pointer;
160 transition: all .12s ease;
161 position: relative;
162 font-variant-numeric: tabular-nums;
163 padding: 0;
164 font-family: inherit;
165 }
166 .fdp-cell:hover { background: var(--col-bg-2, #0e1822); color: #fff; }
167 .fdp-cell.outside { color: var(--col-text-3, #64748b); opacity: .5; }
168 .fdp-cell.today {
169 border-color: var(--col-border, rgba(148,163,184,0.25));
170 color: var(--col-brand-2, #67e8f9);
171 font-weight: 700;
172 }
173 .fdp-cell.selected {
174 background: var(--col-brand, #06b6d4);
175 color: #0c1018;
176 font-weight: 700;
177 border-color: var(--col-brand, #06b6d4);
178 box-shadow: 0 0 0 2px rgba(6,182,212,0.25);
179 }
180 .fdp-cell.selected.today { color: #0c1018; }
181 .fdp-cell:focus { outline: none; box-shadow: 0 0 0 2px rgba(103,232,249,0.5); }
182
183 /* Year/Month grid pickers */
184 .fdp-yo {
185 display: grid;
186 grid-template-columns: repeat(4, 1fr);
187 gap: 6px;
188 padding: 4px 0;
189 }
190 .fdp-yo-cell {
191 padding: 10px 6px;
192 border-radius: 8px;
193 background: var(--col-bg-2, #0e1822);
194 border: 1px solid var(--col-border, rgba(148,163,184,0.18));
195 color: var(--col-text-2, #cbd5e1);
196 text-align: center;
197 font-size: 12.5px;
198 font-weight: 600;
199 cursor: pointer;
200 transition: all .12s ease;
201 font-family: inherit;
202 }
203 .fdp-yo-cell:hover { color: #fff; border-color: var(--col-brand, #06b6d4); }
204 .fdp-yo-cell.selected {
205 background: var(--col-brand, #06b6d4);
206 color: #0c1018;
207 border-color: var(--col-brand, #06b6d4);
208 font-weight: 700;
209 }
210
211 .fdp-time {
212 margin-top: 12px;
213 padding-top: 12px;
214 border-top: 1px solid var(--col-border, rgba(148,163,184,0.18));
215 display: flex;
216 align-items: center;
217 gap: 6px;
218 flex-wrap: nowrap;
219 }
220 .fdp-time-label {
221 font-size: 10px;
222 letter-spacing: 1.2px;
223 text-transform: uppercase;
224 font-weight: 700;
225 color: var(--col-text-3, #64748b);
226 margin-right: 2px;
227 flex: 0 0 auto;
228 }
229 .fdp-tinput {
230 display: inline-flex;
231 align-items: center;
232 background: var(--col-bg-2, #0e1822);
233 border: 1px solid var(--col-border, rgba(148,163,184,0.18));
234 border-radius: 7px;
235 overflow: hidden;
236 }
237 .fdp-tinput.focused { border-color: var(--col-brand, #06b6d4); box-shadow: 0 0 0 2px rgba(6,182,212,0.15); }
238 .fdp-tspin {
239 background: transparent;
240 border: none;
241 color: var(--col-text-3, #64748b);
242 width: 20px; height: 28px;
243 cursor: pointer;
244 font-size: 14px;
245 line-height: 1;
246 padding: 0;
247 transition: color .12s ease;
248 font-family: inherit;
249 }
250 .fdp-tspin:hover { color: var(--col-brand, #06b6d4); }
251 .fdp-tnum {
252 background: transparent;
253 border: none;
254 color: #fff;
255 width: 30px;
256 text-align: center;
257 font: inherit;
258 font-size: 13px;
259 font-weight: 600;
260 font-variant-numeric: tabular-nums;
261 outline: none;
262 -moz-appearance: textfield;
263 }
264 .fdp-tnum::-webkit-outer-spin-button,
265 .fdp-tnum::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
266 .fdp-tcolon { color: var(--col-text-3, #64748b); font-weight: 700; }
267
268 .fdp-period {
269 display: inline-flex;
270 background: var(--col-bg-2, #0e1822);
271 border: 1px solid var(--col-border, rgba(148,163,184,0.18));
272 border-radius: 7px;
273 overflow: hidden;
274 margin-left: auto;
275 flex: 0 0 auto;
276 }
277 .fdp-pseg {
278 background: transparent;
279 border: none;
280 color: var(--col-text-3, #64748b);
281 padding: 5px 7px;
282 font-size: 11px;
283 font-weight: 700;
284 letter-spacing: .5px;
285 cursor: pointer;
286 transition: all .12s ease;
287 font-family: inherit;
288 font-variant-numeric: tabular-nums;
289 }
290 .fdp-pseg:hover { color: #fff; }
291 .fdp-pseg.active {
292 background: var(--col-brand, #06b6d4);
293 color: #0c1018;
294 }
295
296 .fdp-quick {
297 display: flex;
298 gap: 4px;
299 margin-top: 10px;
300 flex-wrap: wrap;
301 }
302 .fdp-qbtn {
303 background: var(--col-bg-2, #0e1822);
304 border: 1px solid var(--col-border, rgba(148,163,184,0.18));
305 color: var(--col-text-2, #cbd5e1);
306 font-size: 11px;
307 font-weight: 600;
308 border-radius: 6px;
309 padding: 4px 9px;
310 cursor: pointer;
311 transition: all .12s ease;
312 font-family: inherit;
313 }
314 .fdp-qbtn:hover { color: var(--col-brand, #06b6d4); border-color: var(--col-brand, #06b6d4); }
315
316 .fdp-actions {
317 display: flex;
318 gap: 6px;
319 margin-top: 12px;
320 padding-top: 10px;
321 border-top: 1px solid var(--col-border, rgba(148,163,184,0.18));
322 justify-content: flex-end;
323 }
324 .fdp-act {
325 font-size: 12px;
326 font-weight: 600;
327 padding: 6px 14px;
328 border-radius: 7px;
329 cursor: pointer;
330 border: 1px solid var(--col-border, rgba(148,163,184,0.18));
331 background: var(--col-bg-2, #0e1822);
332 color: var(--col-text-2, #cbd5e1);
333 transition: all .12s ease;
334 font-family: inherit;
335 }
336 .fdp-act:hover { color: #fff; border-color: var(--col-text-3, #64748b); }
337 .fdp-act.primary {
338 background: var(--col-brand, #06b6d4);
339 color: #0c1018;
340 border-color: var(--col-brand, #06b6d4);
341 }
342 .fdp-act.primary:hover { background: var(--col-brand-2, #67e8f9); border-color: var(--col-brand-2, #67e8f9); }
343 `;
344 var s = document.createElement('style');
345 s.id = 'fdp-styles';
346 s.appendChild(document.createTextNode(css));
347 document.head.appendChild(s);
348 }
349
350 // --------------------------------------------------------------------
351 // Date utilities. All work on local time (matches HTML5 datetime-local).
352 // --------------------------------------------------------------------
353 var MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
354 var DOWS = ['Mo','Tu','We','Th','Fr','Sa','Su'];
355
356 function pad(n) { return (n < 10 ? '0' : '') + n; }
357 // 24-hour -> 12-hour display. Returns { h: 1..12, period: 'AM'|'PM' }.
358 // Midnight = 12 AM, Noon = 12 PM.
359 function to12(h24) {
360 var p = h24 >= 12 ? 'PM' : 'AM';
361 var h = h24 % 12;
362 if (h === 0) h = 12;
363 return { h: h, period: p };
364 }
365 // 12-hour + period -> 24-hour (0..23).
366 function from12(h12, period) {
367 h12 = ((h12 - 1) % 12 + 12) % 12 + 1; // clamp to 1..12
368 if (period === 'AM') return h12 === 12 ? 0 : h12;
369 return h12 === 12 ? 12 : h12 + 12;
370 }
371 function toInputString(d) {
372 if (!d) return '';
373 return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
374 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
375 }
376 function toDisplayString(d) {
377 if (!d) return '';
378 var t = to12(d.getHours());
379 return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
380 ' ' + pad(t.h) + ':' + pad(d.getMinutes()) + ' ' + t.period;
381 }
382 function parseInputString(s) {
383 // Accept YYYY-MM-DDTHH:MM, YYYY-MM-DD HH:MM, YYYY-MM-DDTHH:MM:SS, YYYY-MM-DD
384 if (!s) return null;
385 var m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::\d{2})?)?$/);
386 if (!m) return null;
387 var d = new Date(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0, 0);
388 return isNaN(d.getTime()) ? null : d;
389 }
390 function isSameDay(a, b) {
391 return a && b && a.getFullYear() === b.getFullYear() &&
392 a.getMonth() === b.getMonth() &&
393 a.getDate() === b.getDate();
394 }
395 function startOfMonth(y, m) { return new Date(y, m, 1); }
396
397 // --------------------------------------------------------------------
398 // Picker class -- one instance per attached input.
399 // --------------------------------------------------------------------
400 function Picker(input) {
401 this.input = input;
402 this.value = parseInputString(input.value); // Date | null
403 // The viewMonth controls the grid's displayed month/year. Defaults to
404 // the selected month, or the current month if no value.
405 var ref = this.value || new Date();
406 this.viewYear = ref.getFullYear();
407 this.viewMonth = ref.getMonth();
408 this.viewMode = 'days'; // 'days' | 'months' | 'years'
409 this.yearOffset = 0; // for years-grid pagination (multiples of 12)
410 this.build();
411 this.attach();
412 }
413 Picker.prototype.build = function () {
414 // Hide the native input but keep it in DOM for form submission.
415 this.input.style.position = 'absolute';
416 this.input.style.left = '-9999px';
417 this.input.style.opacity = '0';
418 this.input.style.pointerEvents = 'none';
419 this.input.tabIndex = -1;
420
421 var wrap = document.createElement('span');
422 wrap.className = 'fdp-wrap';
423 var display = document.createElement('button');
424 display.type = 'button';
425 display.className = 'fdp-display' + (this.value ? ' has-value' : ' empty');
426 display.textContent = this.value ? toDisplayString(this.value) : 'mm/dd/yyyy --:--';
427 this.display = display;
428 this.wrap = wrap;
429 wrap.appendChild(display);
430
431 this.input.parentNode.insertBefore(wrap, this.input);
432 wrap.appendChild(this.input); // move native input into wrap so layout stays
433
434 var pop = document.createElement('div');
435 pop.className = 'fdp-pop';
436 pop.style.display = 'none';
437 pop.setAttribute('role', 'dialog');
438 pop.addEventListener('mousedown', function (e) { e.stopPropagation(); });
439 pop.addEventListener('click', function (e) { e.stopPropagation(); });
440 wrap.appendChild(pop);
441 this.pop = pop;
442 };
443 Picker.prototype.attach = function () {
444 var self = this;
445 this.display.addEventListener('click', function (e) { e.preventDefault(); self.toggle(); });
446 this.display.addEventListener('keydown', function (e) {
447 if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
448 e.preventDefault();
449 self.open();
450 }
451 });
452 };
453 Picker.prototype.open = function () {
454 if (this.isOpen) return;
455 this.isOpen = true;
456 this.wrap.classList.add('is-open');
457 this.render();
458 this.pop.style.display = 'block';
459 // Flip up if not enough room below.
460 var rect = this.pop.getBoundingClientRect();
461 var below = window.innerHeight - rect.top;
462 if (below < rect.height + 8) {
463 this.pop.classList.add('flip-up');
464 } else {
465 this.pop.classList.remove('flip-up');
466 }
467 document.addEventListener('mousedown', this._outside = this._outside || this.handleOutside.bind(this));
468 document.addEventListener('keydown', this._keydown = this._keydown || this.handleKeydown.bind(this));
469 };
470 Picker.prototype.close = function () {
471 if (!this.isOpen) return;
472 this.isOpen = false;
473 this.wrap.classList.remove('is-open');
474 this.pop.style.display = 'none';
475 document.removeEventListener('mousedown', this._outside);
476 document.removeEventListener('keydown', this._keydown);
477 };
478 Picker.prototype.toggle = function () { this.isOpen ? this.close() : this.open(); };
479 Picker.prototype.handleOutside = function (e) {
480 if (!this.wrap.contains(e.target)) this.close();
481 };
482 Picker.prototype.handleKeydown = function (e) {
483 if (e.key === 'Escape') { e.preventDefault(); this.close(); this.display.focus(); return; }
484 if (this.viewMode !== 'days') return;
485 // Arrow nav on the day grid
486 var d = this.value || new Date(this.viewYear, this.viewMonth, 1);
487 var moved = null;
488 if (e.key === 'ArrowLeft') moved = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1, d.getHours(), d.getMinutes());
489 if (e.key === 'ArrowRight') moved = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, d.getHours(), d.getMinutes());
490 if (e.key === 'ArrowUp') moved = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 7, d.getHours(), d.getMinutes());
491 if (e.key === 'ArrowDown') moved = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7, d.getHours(), d.getMinutes());
492 if (e.key === 'PageUp') moved = new Date(d.getFullYear(), d.getMonth() - 1, d.getDate(), d.getHours(), d.getMinutes());
493 if (e.key === 'PageDown') moved = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes());
494 if (e.key === 'Enter') { e.preventDefault(); this.commit(); return; }
495 if (moved) {
496 e.preventDefault();
497 this.value = moved;
498 this.viewYear = moved.getFullYear();
499 this.viewMonth = moved.getMonth();
500 this.render();
501 }
502 };
503 Picker.prototype.shiftMonth = function (delta) {
504 var d = new Date(this.viewYear, this.viewMonth + delta, 1);
505 this.viewYear = d.getFullYear();
506 this.viewMonth = d.getMonth();
507 this.render();
508 };
509 Picker.prototype.commit = function () {
510 if (!this.value) this.value = new Date();
511 this.input.value = toInputString(this.value);
512 this.display.textContent = toDisplayString(this.value);
513 this.display.classList.remove('empty');
514 this.display.classList.add('has-value');
515 // Fire input + change for any framework / form listeners.
516 this.input.dispatchEvent(new Event('input', { bubbles: true }));
517 this.input.dispatchEvent(new Event('change', { bubbles: true }));
518 this.close();
519 };
520 Picker.prototype.clear = function () {
521 this.value = null;
522 this.input.value = '';
523 this.display.textContent = 'mm/dd/yyyy --:--';
524 this.display.classList.add('empty');
525 this.display.classList.remove('has-value');
526 this.input.dispatchEvent(new Event('input', { bubbles: true }));
527 this.input.dispatchEvent(new Event('change', { bubbles: true }));
528 this.render();
529 };
530 Picker.prototype.setQuick = function (kind) {
531 var now = new Date();
532 if (kind === 'today') this.value = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0);
533 if (kind === 'now') this.value = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes());
534 if (kind === 'yesterday') this.value = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0);
535 this.viewYear = this.value.getFullYear();
536 this.viewMonth = this.value.getMonth();
537 this.render();
538 };
539
540 // ------------------------------------------------------------------
541 // Render -- repaints the popover contents based on view mode.
542 // ------------------------------------------------------------------
543 Picker.prototype.render = function () {
544 var html = '';
545 if (this.viewMode === 'days') html = this.renderDays();
546 if (this.viewMode === 'months') html = this.renderMonths();
547 if (this.viewMode === 'years') html = this.renderYears();
548 this.pop.innerHTML = html;
549 this.bindPop();
550 };
551 Picker.prototype.renderDays = function () {
552 var y = this.viewYear, m = this.viewMonth;
553 var first = startOfMonth(y, m);
554 // first day-of-week (Mon=0)
555 var dow = (first.getDay() + 6) % 7;
556 var daysInMonth = new Date(y, m + 1, 0).getDate();
557 var prevMonth = new Date(y, m, 0);
558 var daysPrev = prevMonth.getDate();
559
560 var cells = [];
561 // Lead-in (previous month days)
562 for (var i = dow - 1; i >= 0; i--) {
563 cells.push({ y: prevMonth.getFullYear(), m: prevMonth.getMonth(), d: daysPrev - i, outside: true });
564 }
565 // Current month days
566 for (var d = 1; d <= daysInMonth; d++) {
567 cells.push({ y: y, m: m, d: d, outside: false });
568 }
569 // Trail (next month) to fill the grid (6 rows of 7 = 42 cells)
570 var nextDay = 1;
571 while (cells.length < 42) {
572 var nm = new Date(y, m + 1, 1);
573 cells.push({ y: nm.getFullYear(), m: nm.getMonth(), d: nextDay++, outside: true });
574 }
575
576 var today = new Date();
577 var self = this;
578 var cellHtml = cells.map(function (c) {
579 var cd = new Date(c.y, c.m, c.d);
580 var classes = 'fdp-cell';
581 if (c.outside) classes += ' outside';
582 if (isSameDay(cd, today)) classes += ' today';
583 if (isSameDay(cd, self.value)) classes += ' selected';
584 return '<button type="button" class="' + classes + '" ' +
585 'data-y="' + c.y + '" data-m="' + c.m + '" data-d="' + c.d + '">' + c.d + '</button>';
586 }).join('');
587
588 var dowHtml = DOWS.map(function (s) { return '<div class="fdp-dow">' + s + '</div>'; }).join('');
589
590 return '' +
591 '<div class="fdp-pop-head">' +
592 '<button type="button" class="fdp-nav-btn" data-act="prev" aria-label="Previous month">' +
593 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>' +
594 '</button>' +
595 '<button type="button" class="fdp-pop-title" data-act="months">' + MONTHS[m] + ' ' + y + '</button>' +
596 '<button type="button" class="fdp-nav-btn" data-act="next" aria-label="Next month">' +
597 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>' +
598 '</button>' +
599 '</div>' +
600 '<div class="fdp-dows">' + dowHtml + '</div>' +
601 '<div class="fdp-grid">' + cellHtml + '</div>' +
602 this.renderTime() +
603 this.renderQuick() +
604 this.renderActions();
605 };
606 Picker.prototype.renderMonths = function () {
607 var self = this;
608 var cells = MONTHS.map(function (mn, i) {
609 var sel = (self.value && self.value.getMonth() === i && self.value.getFullYear() === self.viewYear) ||
610 (!self.value && self.viewMonth === i);
611 return '<button type="button" class="fdp-yo-cell' + (sel ? ' selected' : '') + '" data-act="pickmonth" data-m="' + i + '">' + mn.slice(0, 3) + '</button>';
612 }).join('');
613 return '' +
614 '<div class="fdp-pop-head">' +
615 '<button type="button" class="fdp-nav-btn" data-act="prevyear" aria-label="Previous year">' +
616 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>' +
617 '</button>' +
618 '<button type="button" class="fdp-pop-title" data-act="years">' + this.viewYear + '</button>' +
619 '<button type="button" class="fdp-nav-btn" data-act="nextyear" aria-label="Next year">' +
620 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>' +
621 '</button>' +
622 '</div>' +
623 '<div class="fdp-yo">' + cells + '</div>' +
624 this.renderTime() +
625 this.renderActions();
626 };
627 Picker.prototype.renderYears = function () {
628 var self = this;
629 // Show a 12-year grid centered on viewYear + yearOffset.
630 var base = (Math.floor((this.viewYear + this.yearOffset) / 12)) * 12;
631 var cells = [];
632 for (var i = 0; i < 12; i++) {
633 var yy = base + i;
634 var sel = self.value && self.value.getFullYear() === yy;
635 cells.push('<button type="button" class="fdp-yo-cell' + (sel ? ' selected' : '') + '" data-act="pickyear" data-y="' + yy + '">' + yy + '</button>');
636 }
637 return '' +
638 '<div class="fdp-pop-head">' +
639 '<button type="button" class="fdp-nav-btn" data-act="prevdecade" aria-label="Previous 12 years">' +
640 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>' +
641 '</button>' +
642 '<div class="fdp-pop-title" style="cursor:default">' + base + ' &ndash; ' + (base + 11) + '</div>' +
643 '<button type="button" class="fdp-nav-btn" data-act="nextdecade" aria-label="Next 12 years">' +
644 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>' +
645 '</button>' +
646 '</div>' +
647 '<div class="fdp-yo">' + cells.join('') + '</div>' +
648 this.renderActions();
649 };
650 Picker.prototype.renderTime = function () {
651 var t = to12(this.value ? this.value.getHours() : 0);
652 var hh = pad(t.h);
653 var mm = this.value ? pad(this.value.getMinutes()) : '00';
654 var pAm = t.period === 'AM';
655 return '' +
656 '<div class="fdp-time">' +
657 '<span class="fdp-time-label">Time</span>' +
658 '<div class="fdp-tinput" data-time="h">' +
659 '<button type="button" class="fdp-tspin" data-act="hdec" aria-label="Decrease hour">&#9662;</button>' +
660 '<input class="fdp-tnum" type="text" data-field="h" value="' + hh + '" maxlength="2" inputmode="numeric">' +
661 '<button type="button" class="fdp-tspin" data-act="hinc" aria-label="Increase hour">&#9652;</button>' +
662 '</div>' +
663 '<span class="fdp-tcolon">:</span>' +
664 '<div class="fdp-tinput" data-time="m">' +
665 '<button type="button" class="fdp-tspin" data-act="mdec" aria-label="Decrease minute">&#9662;</button>' +
666 '<input class="fdp-tnum" type="text" data-field="m" value="' + mm + '" maxlength="2" inputmode="numeric">' +
667 '<button type="button" class="fdp-tspin" data-act="minc" aria-label="Increase minute">&#9652;</button>' +
668 '</div>' +
669 '<div class="fdp-period" role="group" aria-label="AM or PM">' +
670 '<button type="button" class="fdp-pseg' + (pAm ? ' active' : '') + '" data-act="setam">AM</button>' +
671 '<button type="button" class="fdp-pseg' + (pAm ? '' : ' active') + '" data-act="setpm">PM</button>' +
672 '</div>' +
673 '</div>';
674 };
675 Picker.prototype.renderQuick = function () {
676 return '' +
677 '<div class="fdp-quick">' +
678 '<button type="button" class="fdp-qbtn" data-quick="today">Today</button>' +
679 '<button type="button" class="fdp-qbtn" data-quick="yesterday">Yesterday</button>' +
680 '<button type="button" class="fdp-qbtn" data-quick="now">Now</button>' +
681 '</div>';
682 };
683 Picker.prototype.renderActions = function () {
684 return '' +
685 '<div class="fdp-actions">' +
686 '<button type="button" class="fdp-act" data-act="clear">Clear</button>' +
687 '<button type="button" class="fdp-act primary" data-act="apply">Apply</button>' +
688 '</div>';
689 };
690
691 // ------------------------------------------------------------------
692 // Bind all interactive elements in the rendered popover.
693 // ------------------------------------------------------------------
694 Picker.prototype.bindPop = function () {
695 var self = this;
696 // Cell click (day select)
697 this.pop.querySelectorAll('.fdp-cell').forEach(function (el) {
698 el.addEventListener('click', function () {
699 var y = +el.dataset.y, m = +el.dataset.m, d = +el.dataset.d;
700 var h = self.value ? self.value.getHours() : 0;
701 var mi = self.value ? self.value.getMinutes() : 0;
702 self.value = new Date(y, m, d, h, mi);
703 // If user clicked outside-month cell, advance the view too
704 if (m !== self.viewMonth || y !== self.viewYear) {
705 self.viewYear = y;
706 self.viewMonth = m;
707 }
708 self.render();
709 });
710 });
711 // Nav buttons / mode switches
712 this.pop.querySelectorAll('[data-act]').forEach(function (el) {
713 el.addEventListener('click', function () {
714 var act = el.dataset.act;
715 if (act === 'prev') self.shiftMonth(-1);
716 else if (act === 'next') self.shiftMonth(+1);
717 else if (act === 'prevyear') { self.viewYear--; self.render(); }
718 else if (act === 'nextyear') { self.viewYear++; self.render(); }
719 else if (act === 'prevdecade') { self.yearOffset -= 12; self.render(); }
720 else if (act === 'nextdecade') { self.yearOffset += 12; self.render(); }
721 else if (act === 'months') { self.viewMode = 'months'; self.render(); }
722 else if (act === 'years') { self.viewMode = 'years'; self.render(); }
723 else if (act === 'pickmonth') {
724 self.viewMonth = +el.dataset.m;
725 self.viewMode = 'days';
726 self.render();
727 }
728 else if (act === 'pickyear') {
729 self.viewYear = +el.dataset.y;
730 self.viewMode = 'months';
731 self.render();
732 }
733 else if (act === 'apply') self.commit();
734 else if (act === 'clear') self.clear();
735 else if (act === 'hinc') self.bumpTime('h', +1);
736 else if (act === 'hdec') self.bumpTime('h', -1);
737 else if (act === 'minc') self.bumpTime('m', +1);
738 else if (act === 'mdec') self.bumpTime('m', -1);
739 else if (act === 'setam') self.setPeriod('AM');
740 else if (act === 'setpm') self.setPeriod('PM');
741 });
742 });
743 // Quick buttons
744 this.pop.querySelectorAll('[data-quick]').forEach(function (el) {
745 el.addEventListener('click', function () { self.setQuick(el.dataset.quick); });
746 });
747 // Time inputs (live typing + wheel scroll)
748 this.pop.querySelectorAll('.fdp-tnum').forEach(function (inp) {
749 var field = inp.dataset.field;
750 inp.addEventListener('input', function () {
751 // Strip non-digits, clamp. Hour input is 12-hour (1..12);
752 // preserve the current AM/PM period when converting to 24-hour storage.
753 var n = parseInt(inp.value.replace(/[^0-9]/g, ''), 10);
754 if (isNaN(n)) n = 0;
755 if (!self.value) self.value = new Date(self.viewYear, self.viewMonth, 1, 0, 0);
756 if (field === 'h') {
757 if (n < 1) n = 1;
758 if (n > 12) n = 12;
759 var period = to12(self.value.getHours()).period;
760 self.value.setHours(from12(n, period));
761 }
762 if (field === 'm') {
763 if (n < 0) n = 0;
764 if (n > 59) n = 59;
765 self.value.setMinutes(n);
766 }
767 });
768 inp.addEventListener('blur', function () {
769 // Re-pad after blur. Hour shows 12-hour; minute shows 24-hour digit value.
770 if (field === 'h' && self.value) inp.value = pad(to12(self.value.getHours()).h);
771 else inp.value = pad(parseInt(inp.value || '0', 10));
772 });
773 inp.addEventListener('focus', function () {
774 inp.parentNode.classList.add('focused');
775 inp.select();
776 });
777 inp.addEventListener('blur', function () { inp.parentNode.classList.remove('focused'); });
778 inp.addEventListener('wheel', function (e) {
779 e.preventDefault();
780 self.bumpTime(field, e.deltaY < 0 ? +1 : -1);
781 }, { passive: false });
782 });
783 };
784 Picker.prototype.bumpTime = function (field, delta) {
785 if (!this.value) this.value = new Date(this.viewYear, this.viewMonth, 1, 0, 0);
786 if (field === 'h') {
787 var h = (this.value.getHours() + delta + 24) % 24;
788 this.value.setHours(h);
789 }
790 if (field === 'm') {
791 var m = (this.value.getMinutes() + delta + 60) % 60;
792 this.value.setMinutes(m);
793 }
794 this.render();
795 };
796 Picker.prototype.setPeriod = function (period) {
797 if (!this.value) this.value = new Date(this.viewYear, this.viewMonth, 1, 0, 0);
798 var cur = to12(this.value.getHours());
799 if (cur.period === period) { this.render(); return; }
800 this.value.setHours(from12(cur.h, period));
801 this.render();
802 };
803
804 // --------------------------------------------------------------------
805 // Auto-init.
806 // --------------------------------------------------------------------
807 function init() {
808 injectStyles();
809 var all = document.querySelectorAll('input[type="datetime-local"][data-fancy]');
810 Array.prototype.forEach.call(all, function (input) {
811 if (input.__fdp) return;
812 input.__fdp = new Picker(input);
813 });
814 }
815
816 if (document.readyState === 'loading') {
817 document.addEventListener('DOMContentLoaded', init);
818 } else {
819 init();
820 }
821
822 // Expose for manual init if scripts add inputs later.
823 window.FancyDatePicker = { init: init, Picker: Picker };
824})();