PageSourceSearch

https://www.eventeny.com/js/Components/DatePickerElement.js?v=1790189618

js eventeny.com collected 2026-09-24 08:54:56 UTC 57,062 bytes, 1,350 lines download raw bytes

1/**
2 * @file DatePickerElement.js
3 * @description A calendar date picker web component supporting single, range, and multi selection.
4 *
5 * @class DatePickerElement
6 * @extends WebComponent
7 *
8 * @example
9 * <date-picker-element placeholder="Select date"></date-picker-element>
10 * <date-picker-element mode="range" placeholder="Select date range"></date-picker-element>
11 * <date-picker-element mode="multi" placeholder="Select dates"></date-picker-element>
12 * <date-picker-element datetime-compact placeholder="MM/DD/YYYY h:mm a"></date-picker-element>
13 *
14 * @attribute {string} placeholder     - Placeholder text shown when no date is selected
15 * @attribute {string} value           - Pre-selected date(s); comma-separated in multi mode
16 * @attribute {string} mode            - 'single' (default) | 'range' | 'multi'
17 * @attribute {string} variant         - 'events' to enable event-date markers
18 * @attribute disabled                 - Disables the picker
19 * @attribute {string} min-date        - Earliest selectable date, as MM/DD/YYYY or the keyword "today". Earlier days render muted and are not selectable
20 * @attribute fit-width                - Makes the calendar panel match the trigger width
21 * @attribute datetime-compact         - Adds an hourly time list next to the calendar; single mode only (no-op on range/multi). Value/getValue()/setValue() format becomes "MMM D, YYYY h:mm a" (e.g. "Jul 20, 2026 1:00 am").
22 *
23 * @method getValue()              - Returns selected value; comma-separated in multi mode
24 * @method getSelectedDates()      - Returns sorted array of formatted dates (multi mode)
25 * @method getStartDate()          - Returns start date as MM/DD/YYYY (range mode)
26 * @method getEndDate()            - Returns end date as MM/DD/YYYY (range mode)
27 * @method setValue(str)           - Sets the selected date(s) from a formatted string; an empty string clears the selection
28 * @method clear()                 - Clears the selection and resets the calendar view to the current month
29 * @method setPlaceholder(str)     - Updates the placeholder text
30 * @method setEventDates(dates)    - Marks dates with an event border (events variant)
31 * @method setRestrictedDates(dates) - Marks dates in muted color
32 * @method setViewDate(str)        - Sets the month shown when the calendar opens, until a date is selected
33 *
34 * @event change - Fired when selection changes. event.detail = getValue()
35 */
36
37import { WebComponent } from './Base/WebComponent.js';
38import './TagElement.js';
39
40// months names
41const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
42const MONTH_ABBR = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
43
44// 12:00 am ... 11:00 pm, hourly.
45const TIME_SLOTS = Array.from({ length: 24 }, (_, h) => ({
46    hours: h,
47    label: `${h % 12 === 0 ? 12 : h % 12}:00 ${h < 12 ? 'am' : 'pm'}`,
48}));
49
50// Base "h:mm am/pm" pattern.
51// TIME_REGEX_TRAILING is derived from the same source extracted from a longer combined
52// date+time string (_parseDateTime).
53const TIME_REGEX = /(\d{1,2}):(\d{2})\s*(am|pm)/i;
54const TIME_REGEX_TRAILING = new RegExp(`${TIME_REGEX.source}\\s*$`, TIME_REGEX.flags);
55
56class DatePickerElement extends WebComponent {
57
58    constructor() {
59        super();
60        this.loadIcons();
61
62        this._today = new Date();
63        this._today.setHours(0, 0, 0, 0);
64        this._viewDate = new Date(this._today);
65
66        this._selectedDate    = null;  // single mode
67        this._startDate       = null;  // range mode
68        this._endDate         = null;  // range mode
69        this._selectingEnd    = false; // range mode: true after first click
70        this._hoverDate       = null;  // range mode: hover preview
71        this._selectedDates   = [];    // multi mode
72        this._eventDates      = [];    // events variant: dates with event markers
73        this._restrictedDates = [];    // dates shown in muted color
74
75        this._open = false;
76
77        this._hiddenInput = document.createElement('input');
78        this._hiddenInput.type = 'hidden';
79
80        this._handleOutsideClick = (e) => {
81            if (!e.composedPath().includes(this)) {
82                this._setOpen(false);
83            }
84        };
85    }
86
87    static get observedAttributes() {
88        return ['value', 'placeholder', 'disabled', 'mode', 'name', 'min-date'];
89    }
90
91    attributeChangedCallback(name, oldValue, newValue) {
92        if (name === 'value') {
93            this._setValueFromString(newValue);
94        } else if (name === 'min-date') {
95            if (this.shadowRoot?.getElementById('cal-body')) this._renderCalendar(
95);
96        } else if (name === 'placeholder') {
97            if (this._isMulti) {
98                const ph = this.shadowRoot?.getElementById('tags-placeholder');
99                if (ph) ph.textContent = newValue || '';
100            } else {
101                const input = this.shadowRoot?.getElementById('trigger-text');
102                if (input) input.placeholder = newValue || '';
103            }
104        } else if (name === 'name') {
105            this._hiddenInput.name = newValue || '';
106        }
107    }
108
109    connectedCallback() {
110        super.connectedCallback(); // calls render()
111        document.addEventListener('click', this._handleOutsideClick);
112        this.after(this._hiddenInput);
113        this._syncHiddenInput();
114    }
115
116    disconnectedCallback() {
117        document.removeEventListener('click', this._handleOutsideClick);
118        this._hiddenInput.remove();
119    }
120
121    // ─── Public API ───────────────────────────────────────────────────────────
122
123    get _isRange() {
124        return this.getAttribute('mode') === 'range';
125    }
126
127    get _isMulti() {
128        return this.getAttribute('mode') === 'multi';
129    }
130
131    get _isEvents() {
132        return this.getAttribute('variant') === 'events';
133    }
134
135    // Earliest selectable date, or null when the picker is unbounded
136    get _minDate() {
137        const attr = this.getAttribute('min-date');
138        if (!attr) return null;
139        if (attr.trim().toLowerCase() === 'today') return this._today;
140        return this._parseFlexibleDate(attr);
141    }
142
143    _isBeforeMin(date) {
144        const min = this._minDate;
145        return !!(min && date && date.getTime() < min.getTime());
146    }
147
148    // datetime-compact only supports single mode today; range/multi are out of scope.
149    // Remove the !_isRange/!_isMulti guard here (and the matching :not([mode=...]) CSS
150    // selectors in render()) if datetime-compact needs to support them in the future.
151    get _isDatetimeCompact() {
152        return this.hasAttribute('datetime-compact') && !this._isRange && !this._isMulti;
153    }
154
155    getValue() {
156        if (this._isMulti) {
157            return [...this._selectedDates]
158                .sort((a, b) => a - b)
159                .map(d => this._formatDate(d))
160                .join(',');
161        }
162        if (this._isRange) {
163            if (this._startDate && this._endDate) {
164                return `${this._formatDate(this._startDate)} - ${this._formatDate(this._endDate)}`;
165            }
166            return '';
167        }
168        if (!this._selectedDate) return '';
169        return this._isDatetimeCompact ? this._formatDateTime(this._selectedDate) : this._formatDate(this._selectedDate);
170    }
171
172    getSelectedDates() {
173        return [...this._selectedDates]
174            .sort((a, b) => a - b)
175            .map(d => this._formatDate(d));
176    }
177
178    getStartDate() {
179        return this._startDate ? this._formatDate(this._startDate) : '';
180    }
181
182    getEndDate() {
183        return this._endDate ? this._formatDate(this._endDate) : '';
184    }
185
186    setValue(str) {
187        if (!str) {
188            this.clear();
189            return;
190        }
191        this._setValueFromString(str);
192    }
193
194    clear() {
195        this._selectedDate  = null;
196        this._startDate     = null;
197        this._endDate       = null;
198        this._selectingEnd  = false;
199        this._hoverDate     = null;
200        this._selectedDates = [];
201        this._viewDate      = new Date(this._today);
202        this._updateTrigger();
203        this._renderCalendar();
204    }
205
206    setPlaceholder(str) {
207        this.setAttribute('placeholder', str);
208    }
209
210    setEventDates(dates) {
211        this._eventDates = (dates || []).map(d => this._parseDate(d)).filter(Boolean);
212        this._renderCalendar();
213    }
214
215    setRestrictedDates(dates) {
216        this._restrictedDates = (dates || []).map(d => this._parseDate(d)).filter(Boolean);
217        this._renderCalendar();
218    }
219
220    setViewDate(str) {
221        const date = this._parseDate(str);
222        if (date) {
223            this._viewDate = date;
224            this._renderCalendar();
225        }
226    }
227
228    // ─── WebComponent lifecycle ───────────────────────────────────────────────
229
230    render() {
231        const placeholder = this.getAttribute('placeholder') ||
232            (this._isRange ? 'MM/DD/YYYY - MM/DD/YYYY' : this._isDatetimeCompact ? 'MM/DD/YYYY h:mm a' : 'MM/DD/YYYY');
233        // aria-label on the host can't name the shadow input, so forward it
234        const ariaLabel = this.getAttribute('aria-label')
235            ? ` aria-label="${this.getAttribute('aria-label').replace(/"/g, '&quot;')}"`
236            : '';
237
238        this.loadTemplate(`
239            <style>
240                :host {
241                    display: block;
242                    position: relative;
243                    font-family: 'Plus Jakarta Sans', sans-serif;
244                    box-sizing: border-box;
245                }
246
247                /* Trigger */
248                .trigger {
249                    display: flex;
250                    align-items: center;
251                    padding: 0 12px;
252                    gap: 8px;
253                    height: 38px;
254                    border: 1px solid #9DA6A5;
255                    border-radius: 4px;
256                    cursor: pointer;
257                    background: #fff;
258                    box-sizing: border-box;
259                    user-select: none;
260                    font-family: 'Plus Jakarta Sans', sans-serif;
261                }
262                .trigger:hover { border-color: #08A6A0; }
263                .trigger.open  { border-color: #0389A0; }
264                .trigger.disabled { background: #D8DBDB; border-color: #D8DBDB; color: #9DA6A5; cursor: not-allowed; }
265                :host([error]) .trigger { border: 1px solid #D9534F; }
266                .trigger-icon { font-size: 16px; color: #3F4242; flex-shrink: 0; cursor: pointer; }
267                .trigger-input {
268                    flex: 1;
269                    font-size: 16px;
270                    font-weight: 400;
271                    color: #222;
272                    border: none;
273                    outline: none;
274                    background: transparent;
275                    font-family: 'Plus Jakarta Sans', sans-serif;
276                    cursor: text;
277                    min-width: 0;
278                    padding: 0;
279                }
280                .trigger-input::placeholder { color: #5E6463; }
281                .trigger.disabled .trigger-input { color: #9DA6A5; pointer-events: none; }
282                .trigger-caret { font-size: 24px; color: #5E6463; flex-shrink: 0; margin-left: auto; cursor: pointer; }
283
284                /* Multi-select trigger */
285                .trigger.multi {
286                    height: auto;
287                    min-height: 38px;
288                    flex-wrap: wrap;
289                    padding: 4px 8px;
290                    gap: 4px;
291                }
292                .trigger-tags {
293                    display: flex;
294                    flex-wrap: wrap;
295                    gap: 4px;
296                    flex: 1;
297                    align-items: center;
298                    min-width: 0;
299                    cursor: pointer;
300                }
301                .tags-placeholder {
302                    color: #5E6463;
303                    font-size: 16px;
304                    font-weight: 400;
305                }
306                .trigger-clear {
307                    font-size: 24px;
308                    color: #5E6463;
309                    flex-shrink: 0;
310                    cursor: pointer;
311                    line-height: 1;
312                }
313                .trigger-clear:hover { color: #222; }
314                .trigger.multi .trigger-caret { margin-left: 0; }
315
316                /* Panel */
317                .panel {
318                    display: none;
319                    position: absolute;
320                    top: calc(100% + 4px);
321                    left: 0;
322                    background: #fff;
323                    border: 1px solid #D1D5DB;
324                    border-radius: 8px;
325                    box-shadow: 0 4px 16px rgba(0,0,0,0.12);
326                    z-index: 200;
327                    padding: 8px;
328                    width: 380px;
329                    box-sizing: border-box;
330                }
331                .panel.open { display: block; }
332
333                /*
334                 * DATETIME-COMPACT specific style
335                 * datetime-compact only supports single mode today; range/multi are
336                 * explicitly excluded via :not([mode="range"]):not([mode="multi"]) below.
337                 * Remove those two :not(...) clauses (and the matching JS guard in the
338                 * _isDatetimeCompact getter) if datetime-compact needs to support
339                 * range/multi in the future.
340                 */
341                :host([datetime-compact]:not([mode="range"]):not([mode="multi"])) .panel.open {
342                    display: flex;
343                    align-items: stretch;
344                    width: auto;
345                }
346                :host([datetime-compact]:not([mode="range"]):not([mode="multi"])) .cal-day-view,
347                :host([datetime-compact]:not([mode="range"]):not([mode="multi"])) .cal-month-view {
348                    flex: 0 0 364px;
349                }
350                :host([fit-width]) .panel { width: 100%; }
351
352                .time-divider {
353                    align-self: stretch;
354                    width: 1px;
355                    background: #D1D5DB;
356                    margin: 0 8px;
357                    flex-shrink: 0;
358                }
359                .time-panel {
360                    display: flex;
361                    flex-direction: column;
362                    flex: 0 0 auto;
363                }
364                .time-caret {
365                    background: none;
366                    border: none;
367                    cursor: pointer;
368                    border-radius: 4px;
369                    color: #5E6463;
370                    font-size: 18px;
371                    padding: 4px;
372                    flex-shrink: 0;
373                }
374                .time-caret:hover:not(:disabled) { background: #F5F6F6; color: #222; }
375                .time-caret:disabled { color: #EBEDED; cursor: default; }
376                .time-list {
377                    /* height set in JS (_syncTimeListHeight); hidden overflow since the
378                       caret buttons/wheel are the only intended way to move the list —
379                       they set scrollTop directly, which still works with overflow:hidden. */
380                    overflow: hidden;
381                }
382                .time-option {
383                    padding: 8px;
384                    border-radius: 4px;
385                    font-size: 14px;
386                    color: #222;
387                    white-space: nowrap;
388                    cursor: pointer;
389                    user-select: none;
390                }
391                .time-option:hover { background: #EBEDED; }
392                .time-option.selected, .time-option.selected:hover { background: #038580; color: #fff; }
393
394                /* Header row */
395                .cal-header {
396                    display: flex;
397                    align-items: center;
398                    gap: 2px;
399                    margin-bottom: 10px;
400                }
401                .nav-btn, .home-btn {
402                    background: none;
403                    border: none;
404                    cursor: pointer;
405                    border-radius: 4px;
406                    color: #5E6463;
407                    display: flex;
408                    align-items: center;
409                    justify-content: center;
410                    flex-shrink: 0;
411                    padding: 4px;
412                }
413                .nav-btn { font-size: 20px; }
414                .home-btn { font-size: 18px; color: #222; margin-left: auto; }
415                .nav-btn:hover, .home-btn:hover { background: #F5F6F6; color: #222; }
416                .nav-group {
417                    display: flex;
418                    align-items: center;
419                    gap: 15px;
420                }
421                .month-year {
422                    font-size: 14px;
423                    font-weight: 600;
424                    color: #222;
425                    white-space: nowrap;
426                }
427
428                /* Grid */
429                .cal-grid {
430                    width: 100%;
431                    border-collapse: collapse;
432                    table-layout: fixed;
433                }
434                .cal-grid th {
435                    text-align: center;
436                    font-size: 12px;
437                    font-weight: 400;
438                    color: #222;
439                    padding-bottom: 6px;
440                }
441                .cal-grid td {
442                    text-align: center;
443                    padding: 0;
444                }
445                .day {
446                    display: inline-flex;
447                    align-items: center;
448                    justify-content: center;
449                    width: 100%;
450                    aspect-ratio: 1;
451                    border-radius: 0;
452                    font-size: 13px;
453                    font-weight: 500;
454                    cursor: pointer;
455                    position: relative;
456                    color: #222;
457                    box-sizing: border-box;
458                }
459                .day:hover { background: #08A6A0; color: #fff; border-radius: 4px; }
460                .day.other-month { color: #5E6463; font-weight: 400; }
461                .day.other-month:hover { background: #08A6A0; color: #fff; border-radius: 4px; }
462
463                /* Single / multi selection */
464                .day.selected {
465                    background: #038580;
466                    color: #fff;
467                    border-radius: 4px;
468                }
469                .day.selected:hover { background: #026b67; }
470
471                /* Range selection */
472                .day.range-start {
473                    background: #038580;
474                    color: #fff;
475                    border-radius: 8px 0 0 8px;
476                }
477                .day.range-end {
478                    background: #038580;
479                    color: #fff;
480                    border-radius: 0 8px 8px 0;
481                }
482                .day.range-start.range-end { border-radius: 8px; }
483                .day.range-start:hover { background: #026b67; border-radius: 8px 0 0 8px; }
484                .day.range-end:hover   { background: #026b67; border-radius: 0 8px 8px 0; }
485                .day.in-range {
486                    background: #E6F6F5;
487                    color: #222;
488                    border-radius: 0;
489                    border-top: 0.33px solid #038580;
490                    border-bottom: 0.33px solid #038580;
491                }
492                .day.in-range:hover { background: #08A6A0; color: #fff; }
493
494                /* Today dot */
495                .day.today::after {
496                    content: '';
497                    position: absolute;
498                    bottom: 4px;
499                    left: 50%;
500                    transform: translateX(-50%);
501                    width: 4px;
502                    height: 4px;
503                    border-radius: 50%;
504                    background: #222;
505                }
506                .day.today.selected::after,
507                .day.today.range-start::after,
508                .day.today.range-end::after { background: #fff; }
509
510                /* Restricted dates */
511                .day.restricted { color: #9DA6A5; }
512
513                /* Days before min-date: muted and unselectable */
514                .day.out-of-range:not(.selected) { color: #C6C2C2;
514 cursor: not-allowed; }
515                .day.out-of-range:not(.selected):hover { background: transparent; color: #C6C2C2; }
516
517                /* Event date marker border */
518                .day.has-event {
519                    border: 0.5px solid #3F4242;
520                    border-radius: 4px;
521                }
522                .day.has-event.selected,
523                .day.has-event.range-start,
524                .day.has-event.range-end { border-color: transparent; }
525
526                /* Month picker view */
527                .cal-month-view { display: none; }
528                .cal-month-view.active { display: block; }
529                .cal-day-view.hidden { display: none; }
530                .month-grid {
531                    display: grid;
532                    grid-template-columns: repeat(3, 1fr);
533                    gap: 4px;
534                    margin-top: 8px;
535                }
536                .month-cell {
537                    display: flex;
538                    flex-direction: column;
539                    align-items: center;
540                    justify-content: center;
541                    height: 40px;
542                    font-size: 13px;
543                    font-weight: 500;
544                    color: #222;
545                    cursor: pointer;
546                    border-radius: 4px;
547                    position: relative;
548                    gap: 3px;
549                }
550                .month-cell:hover { background: #F5F6F6; }
551                .month-cell.today-month {
552                    background: #038580;
553                    color: #fff;
554                }
555                .month-cell.today-month:hover { background: #026b67; }
556                .month-cell.today-month::after {
557                    content: '';
558                    width: 4px;
559                    height: 4px;
560                    border-radius: 50%;
561                    background: #fff;
562                }
563            </style>
564
565            <div class="trigger${this._isMulti ? ' multi' : ''}" id="trigger">
566                <i class="uil uil-calendar-alt trigger-icon" id="trigger-icon"></i>
567                ${this._isMulti ? `
568                    <div class="trigger-tags" id="trigger-tags">
569                        <span class="tags-placeholder" id="tags-placeholder">${placeholder}</span>
570                    </div>
571                    <i class="uil uil-times trigger-clear" id="trigger-clear" style="display:none;"></i>
572                ` : `
573                    <input type="text" class="trigger-input" id="trigger-text"
574                        placeholder="${placeholder}"${ariaLabel}
575                        autocomplete="off" spellcheck="false" />
576                `}
577                <i class="uil uil-angle-down trigger-caret" id="trigger-caret"></i>
578            </div>
579
580            <div class="panel" id="panel">
581
582                <!-- Day view -->
583                <div class="cal-day-view" id="cal-day-view">
584                    <div class="cal-header">
585                        <div class="nav-group">
586                            <button class="nav-btn" id="prev"><i class="uil uil-angle-left"></i></button>
587                            <span class="month-year" id="month-year"></span>
588                            <button class="nav-btn" id="next"><i class="uil uil-angle-right"></i></button>
589                        </div>
590                        <button class="home-btn" id="home"><i class="uil uil-home"></i></button>
591                    </div>
592                    <table class="cal-grid">
593                        <thead>
594                            <tr>
595                                <th>Sun</th><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th>
596                            </tr>
597                        </thead>
598                        <tbody id="cal-body"></tbody>
599                    </table>
600                </div>
601
602                <!-- Month picker view -->
603                <div class="cal-month-view" id="cal-month-view">
604                    <div class="cal-header">
605                        <div class="nav-group">
606                            <button class="nav-btn" id="year-prev"><i class="uil uil-angle-left"></i></button>
607                            <span class="month-year" id="year-label"></span>
608                            <button class="nav-btn" id="year-next"><i class="uil uil-angle-right"></i></button>
609                        </div>
610                        <button class="home-btn" id="home-month"><i class="uil uil-home"></i></button>
611                    </div>
612                    <div class="month-grid" id="month-grid"></div>
613                </div>
614
615                ${this._isDatetimeCompact ? `
616                    <div class="time-divider"></div>
617                    <div class="time-panel" id="time-panel">
618                        <button class="time-caret" id="time-caret-up" aria-label="Scroll time list up"><i class="uil uil-angle-up"></i></button>
619                        <div class="time-list" id="time-list" role="listbox" aria-label="Select time"></div>
620                        <button class="time-caret" id="time-caret-down" aria-label="Scroll time list down"><i class="uil uil-angle-down"></i></button>
621                    </div>
622                ` : ''}
623
624            </div>
625        `);
626
627        this._renderCalendar();
628        this._renderTimeList();
629        this._updateTrigger();
630
631        const val = this.getAttribute('value');
632        if (val) this._setValueFromString(val);
633
634        if (this._isEvents && this._eventDates.length === 0) {
635            const t0 = new Date(this._today);
636            const t1 = new Date(this._today); t1.setDate(t1.getDate() + 1);
637            const t2 = new Date(this._today); t2.setDate(t2.getDate() + 2);
638            this._eventDates = [t0, t1, t2];
639            this._renderCalendar();
640        }
641
642        if (this.hasAttribute('disabled')) {
643            this.shadowRoot.getElementById('trigger').classList.add('disabled');
644        }
645    }
646
647    setEventListeners() {
648        const root = this.shadowRoot;
649
650        // Icon and caret always toggle the panel
651        ['trigger-icon', 'trigger-caret'].forEach(id => {
652            root.getElementById(id)?.addEventListener('click', (e) => {
653                if (this.hasAttribute('disabled')) return;
654                e.stopPropagation();
655                this._setOpen(!this._open);
656            });
657        });
658
659        if (this._isMulti) {
660            // Clicking the tags area opens the panel (doesn't close)
661            root.getElementById('trigger-tags')?.addEventListener('click', (e) => {
662                if (this.hasAttribute('disabled')) return;
663                e.stopPropagation();
664                this._setOpen(!this._open);
665            });
666
667            // Clear-all button
668            root.getElementById('trigger-clear')?.addEventListener('click', (e) => {
669                if (this.hasAttribute('disabled')) return;
670                e.stopPropagation();
671                this._selectedDates = [];
672                this._updateTrigger();
673                this._renderCalendar();
674                this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, detail: this.getValue() }));
675            });
676
677            // Tag close events bubble up from tag-element children
678            root.getElementById('trigger-tags')?.addEventListener('close', (e) => {
679                const ts = parseInt(e.detail.tag.dataset.ts);
680                this._selectedDates = this._selectedDates.filter(d => d.getTime() !== ts);
681                this._updateTrigger();
682                this._renderCalendar();
683                this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, detail: this.getValue() }));
684            });
685
686        } else {
687            const input = root.getElementById('trigger-text');
688
689            // Clicking the input opens the panel (but doesn't close it)
690            input.addEventListener('click', (e) => {
691                if (this.hasAttribute('disabled')) return;
692                e.stopPropagation();
693                if (!this._open) this._setOpen(true);
694            });
695
696            // Auto-format and parse as the user types
697            input.addEventListener('input', () => {
698                const val = input.value.trim();
699                if (this._isRange) {
700                    const parts = val.split(' - ');
701                    if (parts.length === 2 && parts[0].length === 10 && parts[1].length === 10) {
702                        const a = this._parseDate(parts[0]);
703                        const b = this._parseDate(parts[1]);
704                        if (a && b && !this._isBeforeMin(a) && !this._isBeforeMin(b)) {
705                            this._startDate    = a <= b ? a : b;
706                            this._endDate      = a <= b ? b : a;
707                            this._selectingEnd = false;
708                            this._commitInputChange(this._startDate);
709                        }
710                    }
711                } else if (this._isDatetimeCompact) {
712                    // No length gate needed: _parseDateTime's regexes are self-anchored,
713                    // so partial input while typing simply fails to match.
714                    const date = this._parseDateTime(val);
715                    if (date && !this._isBeforeMin(date)) {
716                        this._selectedDate = date;
717                        this._commitInputChange(date);
718                        this._renderTimeList();
719                    }
720                } else {
721                    // Existing slash-format fast path, plus a month-name fallback
722                    // ("Jul 20, 2026" / "Jul 20, 26") — also for the plain single variant.
723                    let date = val.length === 10 ? this._parseDate(val) : null;
724                    if (!date) date = this._parseMonthNameDate(val);
725                    if (date && !this._isBeforeMin(date)) {
726                        this._selectedDate = date;
727                        this._commitInputChange(date);
728                    }
729                }
730            });
731
732            // On blur, revert to last committed value if the field contains something invalid
733            input.addEventListener('blur', () => {
734                const val = input.value.trim();
735                if (val === '') {
736                    const hadValue     = !!(this._selectedDate || this._startDate || this._endDate);
737                    this._selectedDate = null;
738                    this._startDate    = null;
739                    this._endDate      = null;
740                    this._selectingEnd = false;
741                    this._renderCalendar();
742                    // Emptying the field is a real change, so consumers reading getValue() stay in sync
743                    if (hadValue) {
744                        this._syncHiddenInput();
745                        this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, detail: this.getValue() }));
746                    }
747                } else {
748                    const committed = this.getValue();
749                    const partial   = this._isRange && this._startDate && !this._endDate
750                        ? this._formatDate(this._startDate) + ' - ...'
751                        : null;
752                    input.value = committed || partial || '';
753                }
754            });
755
756            // Enter / Escape close the panel
757            input.addEventListener('keydown', (e) => {
758                if (e.key === 'Enter' || e.key === 'Escape') {
759                    this._setOpen(false);
760                    input.blur();
761                }
762            });
763        }
764
765        root.getElementById('prev').addEventListener('click', (e) => {
766            e.stopPropagation();
767            this._viewDate = new Date(this._viewDate.getFullYear(), this._viewDate.getMonth() - 1, 1);
768            this._renderCalendar();
769        });
770
771        root.getElementById('next').addEventListener('click', (e) => {
772            e.stopPropagation();
773            this._viewDate = new Date(this._viewDate.getFullYear(), this._viewDate.getMonth() + 1, 1);
774            this._renderCalendar();
775        });
776
777        root.getElementById('home').addEventListener('click', (e) => {
778            e.stopPropagation();
779            this._setView('month');
780        });
781
782        root.getElementById('year-prev').addEventListener('click', (e) => {
783            e.stopPropagation();
784            this._viewDate = new Date(this._viewDate.getFullYear() - 1, this._viewDate.getMonth(), 1);
785            this._renderMonthPicker();
786        });
787
788        root.getElementById('year-next').addEventListener('click', (e) => {
789            e.stopPropagation();
790            this._viewDate = new Date(this._viewDate.getFullYear() + 1, this._viewDate.getMonth(), 1);
791            this._renderMonthPicker();
792        });
793
794        root.getElementById('home-month').addEventListener('click', (e) => {
795            e.stopPropagation();
796            this._viewDate = new Date(this._today);
797            this._setView('day');
798        });
799
800        // Prevent focus from leaving the trigger input when clicking inside the panel.
801        // Without this, mousedown on a .day cell blurs the input, the blur handler
802        // calls _renderCalendar() and replaces the DOM, and the subsequent click
803        // event fires on a detached node — so the date never gets selected on the
804        // first click.
805        root.getElementById('panel')?.addEventListener('mousedown', (e) => {
806            e.preventDefault();
807        });
808
809        // Clears hover preview when cursor leaves the calendar grid
810        root.getElementById('cal-body').addEventListener('mouseleave', () => {
811            if (this._isRange && this._selectingEnd) {
812                this._hoverDate = null;
813                this._renderCalendar();
814            }
815        });
816
817        // datetime-compact: caret buttons, wheel, and keyboard nav for the time list
818        if (this._isDatetimeCompact) {
819            root.getElementById('time-caret-up').addEventListener('click', (e) => {
820                e.stopPropagation();
821                this._scrollTimeList(-1);
822            });
823            root.getElementById('time-caret-down').addEventListener('click', (e) => {
824                e.stopPropagation();
825                this._scrollTimeList(1);
826            });
827
828            const timeList = root.getElementById('time-list');
829            // Wheel scroll moves exactly one row per tick, same as a caret click —
830            // not a native free-scroll.
831            timeList.addEventListener('wheel', (e) => {
832                e.preventDefault();
833                this._scrollTimeList(Math.sign(e.deltaY));
834            });
835            timeList.addEventListener('scroll', () => this._updateTimeCarets());
836            timeList.addEventListener('keydown', (e) => {
837                if (e.key === 'ArrowDown') {
838                    e.preventDefault();
839                    this._moveTimeOptionFocus(1);
840                } else if (e.key === 'ArrowUp') {
841                    e.preventDefault();
842                    this._moveTimeOptionFocus(-1);
843                }
844            });
845        }
846    }
847
848    // ─── Private ──────────────────────────────────────────────────────────────
849
850    _formatDate(date) {
851        const m = String(date.getMonth() + 1).padStart(2, '0');
852        const d = String(date.getDate()).padStart(2, '0');
853        const y = date.getFullYear();
854        return `${m}/${d}/${y}`;
855    }
856
857    _parseDate(str) {
858        if (!str) return null;
859        const [m, d, y] = str.trim().split('/');
860        if (!m || !d || !y) return null;
861        if (!/^\d{4}$/.test(y)) return null;
862        const date = new Date(parseInt(y), parseInt(m) - 1, parseInt(d));
863        date.setHours(0, 0, 0, 0);
864        return date;
865    }
866
867    // "Jul 20, 2026" or "Jul 20, 26"
868    _parseMonthNameDate(str) {
869        if (!str) return null;
870        const m = str.trim().match(/^([A-Za-z]{3,})\s+(\d{1,2}),?\s+(\d{4}|\d{2})$/);
871        if (!m) return null;
872        const monthIdx = MONTH_ABBR.findIndex(a => a.toLowerCase() === m[1].slice(0, 3).toLowerCase());
873        if (monthIdx === -1) return null;
874        const year = m[3].length === 2 ? parseInt(m[3]) + 2000 : parseInt(m[3]);
875        const date = new Date(year, monthIdx, parseInt(m[2]));
876        date.setHours(0, 0, 0, 0);
877        return date;
878    }
879
880    _parseFlexibleDate(str) {
881        return this._parseDate(str) || this._parseMonthNameDate(str);
882    }
883
884    // "1:00 am" / "11:30 pm" — must match the ENTIRE trimmed string.
885    _parseTime(str) {
886        if (!str) return null;
887        const trimmed = str.trim();
888        const m = trimmed.match(TIME_REGEX);
889        if (!m || m[0] !== trimmed) return null;
890        const hour    = parseInt(m[1]);
891        const minutes = parseInt(m[2]);
892        if (hour < 1 || hour > 12 || minutes > 59) return null;
893        const isPm   = m[3].toLowerCase() === 'pm';
894        const hours  = (hour % 12) + (isPm ? 12 : 0);
895        return { hours, minutes };
896    }
897
898    _formatDateTime(date) {
899        if (!date) return '';
900        const month   = MONTH_ABBR[date.getMonth()];
901        const hour12  = date.getHours() % 12 === 0 ? 12 : date.getHours() % 12;
902        const ampm    = date.getHours() < 12 ? 'am' : 'pm';
903        const minutes = String(date.getMinutes()).padStart(2, '0');
904        return `${month} ${date.getDate()}, ${date.getFullYear()} ${hour12}:${minutes} ${ampm}`;
905    }
906
907    // Combined "Jul 20, 2026 1:00 am" (or slash-format date) string.
908    _parseDateTime(str) {
909        if (!str) return null;
910        const trimmed   = str.trim();
911        const timeMatch = trimmed.match(TIME_REGEX_TRAILING);
912        const datePart  = (timeMatch ? trimmed.slice(0, timeMatch.index) : trimmed).trim();
913        const date = this._parseFlexibleDate(datePart);
914        if (!date) return null;
915        if (timeMatch) {
916            const time = this._parseTime(timeMatch[0]);
917            if (time) date.setHours(time.hours, time.minutes, 0, 0);
918        }
919        return date;
920    }
921
922    _setValueFromString(str) {
923        if (!str) return;
924        if (this._isMulti) {
925            this._selectedDates = str.split(',')
926                .map(s => this._parseDate(s.trim()))
927                .filter(Boolean);
928            if (this._selectedDates.length) {
929                this._viewDate = new Date([...this._selectedDates].sort((a, b) => a - b)[0]);
930            }
931           
932        }else if (this._isRange) {
933            const parts = str.split(' - ');
934            if (parts.length === 2) {
935                this._startDate = this._parseDate(parts[0]);
936                this._endDate   = this._parseDate(parts[1]);
937                if (this._startDate) this._viewDate = new Date(this._startDate);
938                this._selectingEnd = false;
939            } else {
940                this._startDate = this._parseDate(str);
941                this._endDate   = null;
942                if (this._startDate) this._viewDate = new Date(this._startDate);
943                this._selectingEnd = true;
944            }
945        } else {
946            const date = this._isDatetimeCompact ? this._parseDateTime(str) : this._parseFlexibleDate(str);
947            if (!date) return;
948            this._selectedDate = date;
949            this._viewDate = new Date(date);
950        }
951        this._updateTrigger();
952        this._renderCalendar();
953        this._renderTimeList();
954    }
955
956    // Shared tail of the input-listener's parse-as-you-type handlers: move the view to
957    // the newly-recognized date, re-render, and fire change.
958    _commitInputChange(date) {
959        this._viewDate = new Date(date);
960        this._renderCalendar();
961        this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, detail: this.getValue() }));
962    }
963
964    _sameDay(a, b) {
965        return a && b &&
966            a.getFullYear() === b.getFullYear() &&
967            a.getMonth()    === b.getMonth()    &&
968            a.getDate()     === b.getDate();
969    }
970
971    _isBetween(d, start, end) {
972        if (!d || !start || !end) return false;
973        const t = d.getTime();
974        return t > start.getTime() && t < end.getTime();
975    }
976
977    _setView(view) {
978        const dayView   = this.shadowRoot.getElementById('cal-day-view');
979        const monthView = this.shadowRoot.getElementById('cal-month-view');
980        if (view === 'month') {
981            dayView.classList.add('hidden');
982            monthView.classList.add('active');
983            this._renderMonthPicker();
984        } else {
985            monthView.classList.remove('active');
986            dayView.classList.remove('hidden');
987            this._renderCalendar();
988        }
989    }
990
991    _renderMonthPicker() {
992        const yearLabel = this.shadowRoot.getElementById('year-label');
993        const monthGrid = this.shadowRoot.getElementById('month-grid');
994        if (!yearLabel || !monthGrid) return;
995
996        const year = this._viewDate.getFullYear();
997        yearLabel.textContent = year;
998
999        const todayMonth = this._today.getMonth();
1000        const todayYear  = this._today.getFullYear();
1001
1002        monthGrid.innerHTML = MONTH_ABBR.map((name, i) => {
1003            const isCurrent = i === todayMonth && year === todayYear;
1004            return `<div class="month-cell${isCurrent ? ' today-month' : ''}" data-month="${i}">${name}</div>`;
1005        }).join('');
1006
1007        monthGrid.querySelectorAll('.month-cell').forEach(el => {
1008            el.addEventListener('click', (e) => {
1009                e.stopPropagation();
1010                this._viewDate = new Date(this._viewDate.getFullYear(), parseInt(el.dataset.month), 1);
1011                this._setView('day');
1012            });
1013        });
1014    }
1015
1016    _setOpen(open) {
1017        if (open && DatePickerElement._openInstance && DatePickerElement._openInstance !== this) {
1018            DatePickerElement._openInstance._setOpen(false);
1019        }
1020        this._open = open;
1021        this.shadowRoot.getElementById('panel').classList.toggle('open', open);
1022        this.shadowRoot.getElementById('trigger').classList.toggle('open', open);
1023        if (open) {
1024            DatePickerElement._openInstance = this;
1025            // Heights/scroll position aren't measurable while the panel is
1026            // display:none, so (re)sync them now that it's visible.
1027            if (this._isDatetimeCompact) {
1028                this._syncTimeListHeight();
1029                this._renderTimeList();
1030            }
1031        } else {
1032            if (DatePickerElement._openInstance === this) DatePickerElement._openInstance = null;
1033            this._hoverDate = null;
1034        }
1035    }
1036
1037    _renderCalendar() {
1038        const root = this.shadowRoot;
1039        const monthYearEl = root.getElementById('month-year');
1040        const body        = root.getElementById('cal-body');
1041        if (!monthYearEl || !body) return;
1042
1043        const year   = this._viewDate.getFullYear();
1044        const month  = this._viewDate.getMonth();
1045        monthYearEl.textContent = `${MONTHS[month]} ${year}`;
1046
1047        const firstOfMonth = new Date(year, month, 1);
1048        const daysInMonth  = new Date(year, month + 1, 0).getDate();
1049        const startOffset  = firstOfMonth.getDay();
1050        const numRows      = Math.ceil((startOffset + daysInMonth) / 7);
1051
1052        const cursor = new Date(year, month, 1 - startOffset);
1053
1054        let rStart = null, rEnd = null;
1055        if (this._isRange) {
1056            const hoverEnd = this._selectingEnd && this._hoverDate ? this._hoverDate : null;
1057            rStart = this._startDate;
1058            rEnd   = this._endDate || hoverEnd;
1059            if (rStart && rEnd && rStart.getTime() > rEnd.getTime()) {
1060                [rStart, rEnd] = [rEnd, rStart];
1061            }
1062        }
1063
1064        const minDate = this._minDate;
1065
1066        let html = '';
1067        for (let row = 0; row < numRows; row++) {
1068            html += '<tr>';
1069            for (let col = 0; col < 7; col++) {
1070                const d          = new Date(cursor);
1071                const otherMonth = d.getMonth() !== month;
1072                const isToday    = this._sameDay(d, this._today);
1073
1074                let cls = 'day';
1075                if (otherMonth) cls += ' other-month';
1076                if (isToday)    cls += ' today';
1077                if (minDate && d.getTime() < minDate.getTime()) cls += ' out-of-range';
1078
1079                if (this._isRange) {
1080                    const isStart   = this._sameDay(d, rStart);
1081                    const isEnd     = this._sameDay(d, rEnd);
1082                    const inBetween = this._isBetween(d, rStart, rEnd);
1083                    if (isStart)   cls += ' range-start';
1084                    if (isEnd)     cls += ' range-end';
1085                    if (inBetween) cls += ' in-range';
1086                } else if (this._isMulti) {
1087                    if (this._selectedDates.some(sd => this._sameDay(sd, d))) cls += ' selected';
1088                } else {
1089                    if (this._sameDay(d, this._selectedDate)) cls += ' selected';
1090                }
1091
1092                if (this._eventDates.some(ed => this._sameDay(d, ed)))      cls += ' has-event';
1093                if (this._restrictedDates.some(rd => this._sameDay(d, rd))) cls += ' restricted';
1094
1095                html += `<td><span class="${cls}" data-ts="${d.getTime()}">${d.getDate()}</span></td>`;
1096                cursor.setDate(cursor.getDate() + 1);
1097            }
1098            html += '</tr>';
1099        }
1100
1101        body.innerHTML = html;
1102
1103        body.querySelectorAll('.day').forEach(el => {
1104            if (this._isRange) {
1105                el.addEventListener('mouseover', (e) => {
1106                    e.stopPropagation();
1107                    if (!this._selectingEnd) return;
1108                    const d = new Date(parseInt(el.dataset.ts));
1109                    d.setHours(0, 0, 0, 0);
1110                    if (!this._sameDay(d, this._hoverDate)) {
1111                        this._hoverDate = d;
1112                        this._renderCalendar();
1113                    }
1114                });
1115            }
1116
1117            el.addEventListener('click', (e) => {
1118                e.stopPropagation();
1119                const date = new Date(parseInt(el.dataset.ts));
1120                date.setHours(0, 0, 0, 0);
1121
1122                if (this._isBeforeMin(date)) return;
1123
1124                if (this._isRange) {
1125                    if (!this._selectingEnd) {
1126                        this._startDate    = date;
1127                        this._endDate      = null;
1128                        this._hoverDate    = null;
1129                        this._selectingEnd = true;
1130                        this._updateTrigger();
1131                        this._renderCalendar();
1132                    } else {
1133                        if (date.getTime() >= this._startDate.getTime()) {
1134                            this._endDate = date;
1135                        } else {
1136                            this._endDate   = this._startDate;
1137                            this._startDate = date;
1138                        }
1139                        this._selectingEnd = false;
1140                        this._hoverDate    = null;
1141                        this._updateTrigger();
1142                        this._renderCalendar();
1143                        this.dispatchEvent(new CustomEvent('change', {
1144                            bubbles: true, composed: true, detail: this.getValue(),
1145                        }));
1146                    }
1147                } else if (this._isMulti) {
1148                    const idx = this._selectedDates.findIndex(sd => this._sameDay(sd, date));
1149                    if (idx >= 0) {
1150                        this._selectedDates.splice(idx, 1);
1151                    } else {
1152                        this._selectedDates.push(date);
1153                    }
1154                    this._updateTrigger();
1155                    this._renderCalendar();
1156                    this.dispatchEvent(new CustomEvent('change', {
1157                        bubbles: true, composed: true, detail: this.getValue(),
1158                    }));
1159                } else if (this._isDatetimeCompact) {
1160                    if (this._selectedDate) date.setHours(this._selectedDate.getHours(), this._selectedDate.getMinutes(), 0, 0);
1161                    this._selectedDate = date;
1162                    this._viewDate = new Date(date);
1163                    this._updateTrigger();
1164                    this._renderCalendar();
1165                    this._renderTimeList();
1166                    // Panel stays open — the user still needs to pick a time. It closes
1167                    // on time selection (_selectTime) or on an outside click.
1168                    this.dispatchEvent(new CustomEvent('change', {
1169                        bubbles: true, composed: true, detail: this.getValue(),
1170                    }));
1171                } else {
1172                    this._selectedDate = date;
1173                    this._viewDate = new Date(date);
1174                    this._updateTrigger();
1175                    this._renderCalendar();
1176                    this._setOpen(false);
1177                    this.dispatchEvent(new CustomEvent('change', {
1178                        bubbles: true, composed: true, detail: this.getValue(),
1179                    }));
1180                }
1181            });
1182        });
1183
1184        this._syncTimeListHeight();
1185    }
1186
1187    _updateTrigger() {
1188        if (this._isMulti) {
1189            const container   = this.shadowRoot?.getElementById('trigger-tags');
1190            const placeholder = this.shadowRoot?.getElementById('tags-placeholder');
1191            const clearBtn    = this.shadowRoot?.getElementById('trigger-clear');
1192            if (container) {
1193                container.querySelectorAll('tag-element').forEach(t => t.remove());
1194                const sorted = [...this._selectedDates].sort((a, b) => a - b);
1195                sorted.forEach(d => {
1196                    const tag = document.createElement('tag-element');
1197                    tag.setAttribute('variant', 'teal');
1198                    tag.setAttribute('closable', '');
1199                    tag.dataset.ts = d.getTime();
1200                    tag.textContent = this._formatDate(d);
1201                    container.appendChild(tag);
1202                });
1203                if (placeholder) placeholder.style.display = sorted.length ? 'none' : '';
1204                if (clearBtn)    clearBtn.style.display    = sorted.length ? '' : 'none';
1205            }
1206        } else {
1207            const el = this.shadowRoot?.getElementById('trigger-text');
1208            if (el) {
1209                const val = this.getValue();
1210                if (val) {
1211                    el.value = val;
1212                } else if (this._isRange && this._startDate && !this._endDate) {
1213                    el.value = this._formatDate(this._startDate) + ' - ...';
1214                } else {
1215                    el.value = '';
1216                }
1217            }
1218        }
1219        this._syncHiddenInput();
1220    }
1221
1222    _syncHiddenInput() {
1223        this._hiddenInput.name  = this.getAttribute('name') || '';
1224        this._hiddenInput.value = this.getValue();
1225    }
1226
1227    // ─── datetime-compact: time list (build/select/scroll/keyboard) ────────────
1228
1229    // Matches the time list's height to the calendar's actual rendered height (minus
1230    // the two caret buttons) so it scrolls within that space instead of growing the
1231    // whole panel to fit all 24 rows. Re-run on every calendar render since the
1232    // calendar's height varies (4-6 week rows depending on the month).
1233    _syncTimeListHeight() {
1234        if (!this._isDatetimeCompact) return;
1235        const calView = this.shadowRoot.getElementById('cal-day-view');
1236        const list    = this.shadowRoot.getElementById('time-list');
1237        const up      = this.shadowRoot.getElementById('time-caret-up');
1238        const down    = this.shadowRoot.getElementById('time-caret-down');
1239        if (!calView || !list || !up || !down) return;
1240        const height = calView.offsetHeight - up.offsetHeight - down.offsetHeight;
1241        list.style.height = `${Math.max(height, 0)}px`;
1242    }
1243
1244    _renderTimeList() {
1245        if (!this._isDatetimeCompact) return;
1246        const list = this.shadowRoot.getElementById('time-list');
1247        if (!list) return;
1248
1249        list.innerHTML = TIME_SLOTS.map(slot => {
1250            const isSelected = !!this._selectedDate
1251                && this._selectedDate.getHours() === slot.hours
1252                && this._selectedDate.getMinutes() === 0;
1253            return `<div class="time-option${isSelected ? ' selected' : ''}"
1254                         id="time-option-${slot.hours}" data-hours="${slot.hours}"
1255                         role="option" tabindex="0" aria-selected="${isSelected}">${slot.label}</div>`;
1256        }).join('');
1257
1258        list.querySelectorAll('.time-option').forEach(opt => {
1259            opt.addEventListener('click', (e) => {
1260                e.stopPropagation();
1261                this._selectTime(parseInt(opt.dataset.hours));
1262            });
1263            opt.addEventListener('keydown', (e) => {
1264                if (e.key === 'Enter' || e.key === ' ') {
1265                    e.preventDefault();
1266                    this._selectTime(parseInt(opt.dataset.hours));
1267                }
1268            });
1269        });
1270
1271        const selectedEl = list.querySelector('.time-option.selected');
1272        if (selectedEl) this._scrollToSelectedTimeOption(selectedEl);
1273        else this._updateTimeCarets();
1274    }
1275
1276    // Commits an hour selection (minutes always 0 — the list only offers hourly slots).
1277    _selectTime(hours) {
1278        // If no day was picked yet, this implicitly falls back to the 1st of the
1279        // currently displayed month (_viewDate) — re-render the calendar too, since
1280        // that's a day-selection change, not just a time-list change.
1281        const base = new Date(this._selectedDate || this._viewDate);
1282        base.setHours(hours, 0, 0, 0);
1283        this._selectedDate = base;
1284        this._viewDate = new Date(base);
1285        this._updateTrigger();
1286        this._renderCalendar();
1287        this._renderTimeList();
1288        this._setOpen(false);
1289        this.dispatchEvent(new CustomEvent('change', {
1290            bubbles: true, composed: true, detail: this.getValue(),
1291        }));
1292    }
1293
1294    // Scroll support: caret click, mouse wheel, and manual scroll all funnel through
1295    // here or through _updateTimeCarets so the up/down caret disabled state always
1296    // reflects the list's actual scroll position.
1297    _scrollTimeList(direction) {
1298        const list  = this.shadowRoot.getElementById('time-list');
1299        const first = list?.querySelector('.time-option');
1300        if (!list || !first) return;
1301        list.scrollTop += direction * first.offsetHeight;
1302        this._updateTimeCarets();
1303    }
1304
1305    _updateTimeCarets() {
1306        const list = this.shadowRoot.getElementById('time-list');
1307        const up   = this.shadowRoot.getElementById('time-caret-up');
1308        const down = this.shadowRoot.getElementById('time-caret-down');
1309        if (!list || !up || !down) return;
1310        const atTop    = list.scrollTop <= 0;
1311        const atBottom = Math.ceil(list.scrollTop + list.clientHeight) >= list.scrollHeight;
1312        up.disabled   = atTop;
1313        down.disabled = atBottom;
1314        up.setAttribute('aria-disabled', String(atTop));
1315        down.setAttribute('aria-disabled', String(atBottom));
1316    }
1317
1318    _scrollToSelectedTimeOption(el) {
1319        const container = this.shadowRoot.getElementById('time-list');
1320        if (!container) return;
1321        const top    = el.offsetTop;
1322        const bottom = top + el.offsetHeight;
1323        if (top < container.scrollTop) {
1324            container.scrollTop = top;
1325        } else if (bottom > container.scrollTop + container.clientHeight) {
1326            container.scrollTop = bottom - container.clientHeight;
1327        }
1328        this._updateTimeCarets();
1329    }
1330
1331    // Roving keyboard focus (ArrowUp/ArrowDown) across .time-option elements, wrapping
1332    // at both ends — same pattern as BasicDropdown.js's option list navigation.
1333    _moveTimeOptionFocus(direction) {
1334        const options = [...this.shadowRoot.querySelectorAll('.time-option')];
1335        if (!options.length) return;
1336        let index = options.indexOf(this.shadowRoot.activeElement);
1337        if (index === -1) index = direction > 0 ? -1 : 0;
1338        let next = index + direction;
1339        if (next < 0) next = options.length - 1;
1340        if (next >= options.length) next = 0;
1341        options[next].focus();
1342        this._scrollToSelectedTimeOption(options[next]);
1343    }
1344}
1345
1346DatePickerElement._openInstance = null;
1347
1348if (!customElements.get('date-picker-element')) {
1349    customElements.define('date-picker-element', DatePickerElement);
1350}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.