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, '"')}"` 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.