1import { Controller } from "@hotwired/stimulus"; 2import { computePosition, offset, flip, shift, arrow, autoUpdate } from "@floating-ui/dom"; 3import AirDatepicker from "air-datepicker"; 4import localeEn from "air-datepicker/locale/en"; 5 6// AirDatepicker CSS is now imported directly. 7// Ensure air-datepicker is added to your package.json and your build system handles these imports. 8 9export default class extends Controller { 10 static targets = ["input", "inlineCalendar"]; 11 static values = { 12 placement: { type: String, default: "bottom-start" }, // Placement of the datepicker 13 range: { type: Boolean, default: false }, // Whether to allow selecting a range of dates 14 disabledDates: { type: Array, default: [] }, // Expects array of 'YYYY-MM-DD' strings 15 timepicker: { type: Boolean, default: false }, 16 timeOnly: { type: Boolean, default: false }, // New value for time-only selection 17 weekPicker: { type: Boolean, default: false }, // New value for week selection 18 timeFormat: { type: String, default: "" }, // Default empty, logic will apply 'hh:mm AA' if timepicker is true and this is empty 19 minHours: Number, // Undefined if not set, AirDatepicker uses its default 20 maxHours: Number, // Undefined if not set 21 minutesStep: Number, // Undefined if not set 22 showTodayButton: { type: Boolean, default: false }, 23 showClearButton: { type: Boolean, default: false }, 24 showThisMonthButton: { type: Boolean, default: false }, 25 showThisYearButton: { type: Boolean, default: false }, 26 dateFormat: { type: String, default: "" }, // e.g., 'MM/dd/yyyy', AirDatepicker default if empty 27 startView: { type: String, default: "days" }, // 'days', 'months', 'years' 28 minView: { type: String, default: "days" }, // 'days', 'months', 'years' 29 initialDate: { type: String, default: "" }, // 'YYYY-MM-DD' or JSON array of 'YYYY-MM-DD' for range 30 minDate: { type: String, default: "" }, // 'YYYY-MM-DD' 31 maxDate: { type: String, default: "" }, // 'YYYY-MM-DD' 32 inline: { type: Boolean, default: false }, // Makes the calendar permanently visible 33 }; 34 35 // CSS classes for preset buttons 36 static PRESET_CLASSES = { 37 active: [ 38 "bg-neutral-900", 39 "text-white", 40 "hover:!bg-neutral-700", 41 "dark:bg-neutral-100", 42 "dark:hover:!bg-neutral-200", 43 "dark:text-neutral-900", 44 ], 45 inactive: ["text-neutral-700", "hover:bg-neutral-100", "dark:text-neutral-300", "dark:hover:bg-neutral-700/50"], 46 }; 47 48 connect() { 49 // Check if this element also has dropdown-popover controller 50 // If so, don't initialize the datepicker (it's just used for preset methods) 51 const hasDropdownController = 52 this.element.hasAttribute("data-controller") && 53 this.element.getAttribute("data-controller").includes("dropdown-popover"); 54 55 if (!hasDropdownController) { 56 this.initializeDatepicker(); 57 this.inputTarget.addEventListener("keydown", this.handleKeydown.bind(this)); 58 } else { 59 // Listen for input click to detect active preset when dropdown opens 60 this.inputTarget.addEventListener("click", () => { 61 // Small delay to ensure dropdown is fully rendered 62 setTimeout(() => { 63 this._detectActivePreset(); 64 }, 50); 65 }); 66 } 67 } 68 69 initializeDatepicker() { 70 if (this.datepickerInstance) { 71 this.datepickerInstance.destroy(); 72 // Ensure autoUpdate cleanup is robustly handled if destroy didn't trigger position's cleanup 73 if (this.cleanupAutoUpdate) { 74 this.cleanupAutoUpdate(); 75 this.cleanupAutoUpdate = null; 76 } 77 // Also clear the specific one for the current position call, if any 78 if (this.currentPositionCleanupAutoUpdate) { 79 this.currentPositionCleanupAutoUpdate(); 80 this.currentPositionCleanupAutoUpdate = null; 81 } 82 } 83 84 const options = this._buildDatepickerOptions(); 85
86 if (!this.inlineValue) { 87 options.position = this._createPositionFunction(); 88 } 89 90 this.datepickerInstance = new AirDatepicker(this.inputTarget, options); 91 92 // Format initial value for week picker 93 if (this.weekPickerValue) { 94 setTimeout(() => this._updateWeekDisplay(), 0); 95 } 96 97 // Trigger change for inline calendars 98 if (this.inlineValue) { 99 setTimeout(() => this._triggerChangeEvent(), 0); 100 } 101 } 102 103 _buildDatepickerOptions() { 104 const options = { 105 locale: localeEn, 106 autoClose: !this.inlineValue, 107 inline: this.inlineValue, 108 container: this.inputTarget.closest("dialog") || undefined, 109 }; 110 111 // Date/Time format 112 if (this.timeOnlyValue) { 113 options.dateFormat = ""; 114 options.onSelect = this._createTimeOnlySelectHandler(); 115 } else if (this.weekPickerValue) { 116 options.dateFormat = ""; 117 options.onSelect = this._createWeekSelectHandler(); 118 } else if (this.hasDateFormatValue && this.dateFormatValue) { 119 options.dateFormat = this.dateFormatValue; 120 } 121 122 // Views 123 if (this.hasStartViewValue) options.view = this.startViewValue; 124 if (this.hasMinViewValue) options.minView = this.minViewValue; 125 126 // Date constraints 127 this._setDateConstraint(options, "minDate", this.minDateValue); 128 this._setDateConstraint(options, "maxDate", this.maxDateValue); 129 130 // Initial dates 131 const initialDates = this._parseInitialDates(); 132 if (initialDates.length > 0) options.selectedDates = initialDates; 133 134 // Range mode 135 if (this.rangeValue) { 136 options.range = true; 137 options.multipleDatesSeparator = " - "; 138 } 139 140 // Timepicker 141 if (this.timepickerValue || this.timeOnlyValue) { 142 Object.assign(options, { 143 timepicker: true, 144 timeFormat: this.timeFormatValue || "hh:mm AA", 145 ...(this.hasMinHoursValue && { minHours: this.minHoursValue }), 146 ...(this.hasMaxHoursValue && { maxHours: this.maxHoursValue }), 147 ...(this.hasMinutesStepValue && { minutesStep: this.minutesStepValue }), 148 }); 149 if (this.timeOnlyValue) options.classes = "only-timepicker"; 150 } 151 152 // Buttons 153 const buttons = this._buildButtons(); 154 if (buttons.length > 0) options.buttons = buttons; 155 156 // Disabled dates handling 157 const disabledDates = this._parseDisabledDates(); 158 if (disabledDates.length > 0) { 159 options.onRenderCell = this._createRenderCellHandler(disabledDates); 160 } 161 162 // General onSelect for inline calendars 163 if (!this.timeOnlyValue && !this.weekPickerValue && this.inlineValue) { 164 const originalOnSelect = options.onSelect; 165 options.onSelect = (params) => { 166 originalOnSelect?.(params); 167 setTimeout(() => this.syncToMainPicker(), 10); 168 this._triggerChangeEvent(); 169 }; 170 } 171 172 // Special handling for inline time-only picker 173 if (this.timeOnlyValue && this.inlineValue) { 174 const originalOnSelect = options.onSelect; 175 options.onSelect = ({ date, datepicker }) => { 176 if (originalOnSelect) { 177 originalOnSelect({ date, datepicker }); 178 } 179 this._triggerChangeEvent(); 180 }; 181 } 182 183 return options; 184 } 185 186 _setDateConstraint(options, key, value) { 187 if (value) { 188 const date = this._parseDate(value); 189 if (date) options[key] = date; 190 } 191 } 192 193 _parseInitialDates() { 194 if (!this.hasInitialDateValue || !this.initialDateValue) return []; 195 196 try { 197 if (this.initialDateValue.startsWith("[") && this.initialDateValue.endsWith("]")) { 198 const dateStrings = JSON.parse(this.initialDateValue); 199 return dateStrings.map((str) => this._parseDate(str)).filter(Boolean); 200 } 201 const date = this._parseDate(this.initialDateValue); 202 return date ? [date] : []; 203 } catch (e) { 204 console.error("Error parsing initialDateValue:", e, "Value was:", this.initialDateValue); 205 return []; 206 } 207 } 208 209 _buildButtons() { 210 const buttons = []; 211 const buttonConfigs = [ 212 { condition: this.showTodayButtonValue, button: this._createTodayButton() }, 213 { condition: this.showThisMonthButtonValue, button: this._createMonthButton() }, 214 { condition: this.showThisYearButtonValue, button: this._createYearButton() }, 215 { condition: this.showClearButtonValue, button: "clear" }, 216 ]; 217
218 buttonConfigs.forEach(({ condition, button }) => { 219 if (condition && button) buttons.push(button); 220 }); 221 222 return buttons; 223 } 224 225 _createTodayButton() { 226 const isTimepickerEnabled = this.timepickerValue || this.timeOnlyValue || this.weekPickerValue; 227 const buttonText = this.weekPickerValue ? "This week" : isTimepickerEnabled ? "Now" : "Today"; 228 229 return { 230 content: buttonText, 231 onClick: (dp) => { 232 const currentDate = new Date(); 233 const dates = this.rangeValue ? [currentDate, currentDate] : currentDate; 234 235 if (isTimepickerEnabled && !this.weekPickerValue) { 236 dp.clear(); 237 setTimeout(() => dp.selectDate(dates, { updateTime: true }), 0); 238 } else { 239 dp.selectDate(dates); 240 } 241 }, 242 }; 243 } 244 245 _createMonthButton() { 246 return { 247 content: "This month", 248 onClick: (dp) => { 249 const currentDate = new Date(); 250 dp.selectDate(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1)); 251 }, 252 }; 253 } 254 255 _createYearButton() { 256 return { 257 content: "This year", 258 onClick: (dp) => { 259 const currentDate = new Date(); 260 dp.selectDate(new Date(currentDate.getFullYear(), 0, 1)); 261 }, 262 }; 263 } 264 265 _parseDisabledDates() { 266 if (!this.disabledDatesValue?.length) return []; 267 268 return this.disabledDatesValue.map((str) => this._parseDate(str)).filter(Boolean); 269 } 270 271 _createRenderCellHandler(disabledDates) { 272 return ({ date, cellType }) => { 273 if (cellType !== "day") return {}; 274 275 const cellDateUTC = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); 276 const isDisabled = disabledDates.some((disabledDate) => disabledDate.getTime() === cellDateUTC.getTime()); 277 278 return isDisabled ? { disabled: true } : {}; 279 }; 280 } 281 282 _createTimeOnlySelectHandler() { 283 return ({ date, datepicker }) => { 284 if (date) { 285 const timeFormat = this.timeFormatValue || "hh:mm AA"; 286 this.inputTarget.value = datepicker.formatDate(date, timeFormat); 287 this._triggerChangeEvent(); 288 } 289 }; 290 } 291 292 _createWeekSelectHandler() { 293 return ({ date }) => { 294 if (date) { 295 const weekNumber = this._getWeekNumber(date); 296 const year = date.getFullYear(); 297 this.inputTarget.value = `${year}-W${weekNumber.toString().padStart(2, "0")}`; 298 this._triggerChangeEvent(); 299 } 300 }; 301 } 302 303 _createPositionFunction() { 304 return ({ $datepicker, $target, $pointer, done }) => { 305 const middleware = [offset(8), flip(), shift({ padding: 8 })]; 306 307 if ($pointer instanceof HTMLElement) { 308 middleware.push(arrow({ element: $pointer, padding: 5 })); 309 } 310 311 this._cleanupPositioning(); 312 313 this.currentPositionCleanupAutoUpdate = autoUpdate( 314 $target, 315 $datepicker, 316 () => { 317 computePosition($target, $datepicker, { 318 placement: this.placementValue, 319 middleware: middleware, 320 }).then(({ x, y, middlewareData }) => { 321 Object.assign($datepicker.style, { left: `${x}px`, top: `${y}px` }); 322 323 if ($pointer instanceof HTMLElement && middlewareData.arrow) { 324 const { x: arrowX, y: arrowY } = middlewareData.arrow; 325 Object.assign($pointer.style, { 326 left: arrowX != null ? `${arrowX}px` : "", 327 top: arrowY != null ? `${arrowY}px` : "", 328 }); 329 } 330 }); 331 }, 332 { animationFrame: true }, 333 ); 334 335 this.cleanupAutoUpdate = this.currentPositionCleanupAutoUpdate; 336 337 return () => { 338 this._cleanupPositioning(); 339 done(); 340 }; 341 }; 342 } 343 344 _getWeekNumber(date) { 345 const tempDate = new Date(date.getTime()); 346 const dayNumber = (tempDate.getDay() + 6) % 7; 347 tempDate.setDate(tempDate.getDate() - dayNumber + 3); 348 349 const firstThursday = new Date(tempDate.getFullYear(), 0, 4); 350 firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3); 351 352 return Math.round((tempDate.getTime() - firstThursday.getTime()) / 86400000 / 7) + 1; 353 } 354 355 _parseDate(dateString) { 356 if (!dateString || typeof dateString !== "string") return null; 357 358 // First check if it's a datetime string (YYYY-MM-DD HH:MM) 359 if (dateString.includes(" ")) { 360 const [datePart, timePart] = dateString.split(" "); 361 const dateParts = datePart.split("-"); 362 363 if (dateParts.length === 3 && timePart) { 364 const [year, month, day] = dateParts.map(Number); 365 const [hours, minutes] = timePart.split(":").map(Number); 366 367 if (!isNaN(year) && !isNaN(month) && !isNaN(day) && !isNaN(hours) && !isNaN(minutes)) { 368 return new Date(year, month - 1, day, hours, minutes); 369 } 370 } 371 } 372 373 // Otherwise try to parse as date only (YYYY-MM-DD) 374 const parts = dateString.split("-"); 375 if (parts.length === 3) { 376 const [year, month, day] = parts.map(Number); 377 if (!isNaN(year) && !isNaN(month) && !isNaN(day)) { 378 return new Date(Date.UTC(year, month - 1, day)); 379 } 380 } 381 382 console.warn(`Invalid date string format: ${dateString}. Expected YYYY-MM-DD or YYYY-MM-DD HH:MM.`); 383 return null; 384 } 385 386 handleKeydown(event) { 387 if (event.key === "Delete" || event.key === "Backspace") { 388 this.datepickerInstance?.clear(); 389 this.inputTarget.value = ""; 390 } 391 } 392 393 disconnect() { 394 if (this.datepickerInstance) { 395 this.datepickerInstance.destroy(); // This should trigger the cleanup returned by position() 396 this.datepickerInstance = null; 397 } 398 // Fallback cleanup for autoUpdate, in case destroy() didn't clear it or it was managed outside position's return. 399 if (this.cleanupAutoUpdate) { 400 this.cleanupAutoUpdate(); 401 this.cleanupAutoUpdate = null; 402 } 403 if (this.currentPositionCleanupAutoUpdate) { 404 // Also ensure any lingering position-specific cleanup is called 405 this.currentPositionCleanupAutoUpdate(); 406 this.currentPositionCleanupAutoUpdate = null; 407 } 408 409 // Only remove event listener if it was added 410 if (this.inputTarget && !this.element.getAttribute("data-controller").includes("dropdown-popover")) { 411 this.inputTarget.removeEventListener("keydown", this.handleKeydown.bind(this)); 412 } 413 } 414 415 // Preset time range methods 416 setToday(event) { 417 const today = new Date(); 418 // Set hours to noon to avoid any timezone edge cases 419 today.setHours(12, 0, 0, 0); 420 this._applyPreset(event.currentTarget, today, today); 421 } 422 423 setYesterday(event) { 424 const yesterday = new Date(); 425 yesterday.setDate(yesterday.getDate() - 1); 426 this._applyPreset(event.currentTarget, yesterday, yesterday); 427 } 428 429 setLastDays(event) { 430 const days = parseInt(event.currentTarget.dataset.days, 10); 431 if (isNaN(days) || days <= 0) { 432 console.warn("Invalid number of days specified in data-days attribute"); 433 return; 434 } 435 436 const endDate = new Date(); 437 const startDate = new Date(); 438 startDate.setDate(startDate.getDate() - (days - 1)); // days - 1 because we include today 439 this._applyPreset(event.currentTarget, startDate, endDate); 440 } 441 442 setPreset(event) { 443 const presetType = event.currentTarget.dataset.presetType; 444 445 switch (presetType) { 446 case "this-month": 447 this._setThisMonth(); 448 break; 449 case "last-month": 450 this._setLastMonth(); 451 break; 452 case "this-year": 453 this._setThisYear(); 454 break; 455 default: 456 console.warn(`Unknown preset type: ${presetType}`); 457 } 458 this._setActivePreset(event.currentTarget); 459 } 460 461 _setThisMonth() { 462 const now = new Date(); 463 const startDate = new Date(now.getFullYear(), now.getMonth(), 1); 464 const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0); 465 this._applyPreset(event.currentTarget, startDate, endDate); 466 } 467 468 _setLastMonth() { 469 const now = new Date(); 470 const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); 471 const endDate = new Date(now.getFullYear(), now.getMonth(), 0); 472 this._applyPreset(event.currentTarget, startDate, endDate); 473 } 474 475 _setThisYear() { 476 const now = new Date(); 477 const startDate = new Date(now.getFullYear(), 0, 1); 478 const endDate = new Date(now.getFullYear(), 11, 31); 479 this._applyPreset(event.currentTarget, startDate, endDate); 480 } 481 482 clearSelection() { 483 if (this.datepickerInstance) { 484 this.datepickerInstance.clear(); 485 } 486 this.inputTarget.value = "";
487 this._syncToInlineCalendar(); 488 this._triggerChangeEvent(); 489 this._clearActivePreset(); 490 } 491 492 syncFromInlineCalendar(event) { 493 // Sync the main input from the inline calendar when it changes 494 const inlineInput = event.target; 495 if (inlineInput.value) { 496 this.inputTarget.value = inlineInput.value; 497 this._triggerChangeEvent(); 498 } 499 } 500 501 syncToMainPicker(event = null) { 502 // This method is called from the inline calendar to sync to main picker 503 // Find the main date picker controller in the parent dropdown 504 const dropdownElement = this.element.closest( 505 '[data-controller*="dropdown-popover"][data-controller*="date-picker"]', 506 ); 507 if (dropdownElement) { 508 const mainController = this.application.getControllerForElementAndIdentifier(dropdownElement, "date-picker"); 509 if (mainController && mainController !== this) { 510 // Get the selected dates from the inline calendar 511 const selectedDates = this.datepickerInstance ? this.datepickerInstance.selectedDates : []; 512 513 if (selectedDates.length > 0) { 514 // Format the dates for the main input 515 const formattedValue = mainController._formatDateRange( 516 selectedDates[0], 517 selectedDates[selectedDates.length - 1], 518 ); 519 mainController.inputTarget.value = formattedValue; 520 521 // If the main controller has a datepicker instance, sync the selected dates 522 if (mainController.datepickerInstance) { 523 // For range pickers, ensure we complete the range selection properly 524 if (mainController.rangeValue) { 525 if (selectedDates.length === 1) { 526 // Select the same date twice to complete the range selection 527 mainController.datepickerInstance.selectDate([selectedDates[0], selectedDates[0]]); 528 } else { 529 mainController.datepickerInstance.selectDate(selectedDates); 530 } 531 } else { 532 mainController.datepickerInstance.selectDate(selectedDates[0]); 533 } 534 } 535 } else { 536 // Clear the main input if no dates selected 537 mainController.inputTarget.value = ""; 538 if (mainController.datepickerInstance) { 539 mainController.datepickerInstance.clear(); 540 } 541 } 542 543 mainController._triggerChangeEvent(); 544 545 // Clear active preset when dates are manually selected 546 mainController._clearActivePreset(); 547 548 // Close the dropdown after selection if we have a complete range or single date 549 if (mainController.rangeValue ? selectedDates.length >= 2 : selectedDates.length >= 1) { 550 const dropdownController = this.application.getControllerForElementAndIdentifier( 551 dropdownElement, 552 "dropdown-popover", 553 ); 554 if (dropdownController) { 555 dropdownController.close(); 556 } 557 } 558 } 559 } 560 } 561 562 // Helper methods 563 _applyPreset(button, startDate, endDate) { 564 this._setDateRange(startDate, endDate); 565 this._setActivePreset(button); 566 } 567 568 _setDateRange(startDate, endDate) { 569 if (this.datepickerInstance) { 570 const dates = this.rangeValue ? [startDate, endDate] : [startDate]; 571 this.datepickerInstance.selectDate(dates); 572 } else { 573 this.inputTarget.value = this._formatDateRange(startDate, endDate); 574 } 575
576 this._syncToInlineCalendar(); 577 this._triggerChangeEvent(); 578 } 579 580 _formatDateRange(startDate, endDate) { 581 const formatDate = (date) => { 582 const year = date.getFullYear(); 583 const month = String(date.getMonth() + 1).padStart(2, "0"); 584 const day = String(date.getDate()).padStart(2, "0"); 585 return `${month}/${day}/${year}`; 586 }; 587 588 return this.rangeValue ? `${formatDate(startDate)} - ${formatDate(endDate)}` : formatDate(startDate); 589 } 590 591 _syncToInlineCalendar() { 592 // Find inline calendar in the same dropdown and sync its value 593 const dropdownMenu = this.element.querySelector('[data-dropdown-popover-target="menu"]'); 594 if (dropdownMenu) { 595 const inlineCalendar = dropdownMenu.querySelector('.inline-calendar[data-controller="date-picker"]'); 596 if (inlineCalendar) { 597 const inlineController = this.application.getControllerForElementAndIdentifier(inlineCalendar, "date-picker"); 598 if (inlineController && inlineController.datepickerInstance) { 599 // Get selected dates from main picker and apply to inline calendar 600 if (this.datepickerInstance && this.datepickerInstance.selectedDates.length > 0) { 601 // If it's a range picker and both dates are the same, only select one date 602 const datesToSelect = 603 this.rangeValue && 604 this.datepickerInstance.selectedDates.length === 2 && 605 this.datepickerInstance.selectedDates[0].getTime() === this.datepickerInstance.selectedDates[1].getTime() 606 ? [this.datepickerInstance.selectedDates[0]] 607 : this.datepickerInstance.selectedDates; 608 inlineController.datepickerInstance.selectDate(datesToSelect); 609 } else { 610 // If no main datepicker instance, parse the input value and set dates 611 if (this.inputTarget.value) { 612 const dates = this._parseDateRangeValue(this.inputTarget.value); 613 if (dates.length > 0) { 614 // Clear any existing selection first to ensure clean state 615 inlineController.datepickerInstance.clear(); 616 617 // If it's a range picker and we have a single date or both parsed dates are the same 618 if (inlineController.rangeValue) { 619 if (dates.length === 1) { 620 // For single date in range mode, select it twice to complete the range 621 inlineController.datepickerInstance.selectDate([dates[0], dates[0]]); 622 } else if (dates.length === 2 && dates[0].getTime() === dates[1].getTime()) { 623 // For identical start/end dates, select twice to complete the range 624 inlineController.datepickerInstance.selectDate([dates[0], dates[0]]); 625 } else { 626 // For different dates, select normally 627 inlineController.datepickerInstance.selectDate(dates); 628 } 629 } else { 630 // Non-range mode, select normally 631 inlineController.datepickerInstance.selectDate(dates); 632 } 633 } 634 } else { 635 inlineController.datepickerInstance.clear(); 636 } 637 } 638 } 639 } 640 } 641 } 642 643 _parseDateRangeValue(value) { 644 // Parse a date range string like "01/15/2025 - 01/20/2025" or "01/15/2025" 645 const dates = []; 646 if (value.includes(" - ")) { 647 const [startStr, endStr] = value.split(" - "); 648 const startDate = this._parseFormattedDate(startStr.trim()); 649 const endDate = this._parseFormattedDate(endStr.trim()); 650 if (startDate) dates.push(startDate); 651 if (endDate) dates.push(endDate); 652 } else { 653 const date = this._parseFormattedDate(value.trim()); 654 if (date) dates.push(date); 655 } 656 return dates; 657 } 658 659 _parseFormattedDate(dateStr) { 660 // Parse MM/DD/YYYY format 661 const parts = dateStr.split("/"); 662 if (parts.length === 3) {
663 const month = parseInt(parts[0], 10) - 1; // Month is 0-indexed 664 const day = parseInt(parts[1], 10); 665 const year = parseInt(parts[2], 10); 666 return new Date(year, month, day); 667 } 668 return null; 669 } 670 671 _triggerChangeEvent() { 672 this.inputTarget.dispatchEvent(new Event("change", { bubbles: true })); 673 } 674 675 _setActivePreset(button) { 676 this._clearActivePreset(); 677 if (button) { 678 button.classList.remove(...this.constructor.PRESET_CLASSES.inactive); 679 button.classList.add(...this.constructor.PRESET_CLASSES.active); 680 } 681 } 682 683 _clearActivePreset() { 684 const dropdownMenu = this.element.querySelector('[data-dropdown-popover-target="menu"]'); 685 const presetButtons = 686 dropdownMenu?.querySelectorAll('[data-menu-target="item"]:not([data-action*="clearSelection"])') || []; 687 688 presetButtons.forEach((button) => { 689 button.classList.remove(...this.constructor.PRESET_CLASSES.active); 690 button.classList.add(...this.constructor.PRESET_CLASSES.inactive); 691 }); 692 } 693 694 _detectActivePreset() { 695 const dateRangeValue = this.inputTarget.value; 696 if (!dateRangeValue) return; 697 698 const dates = this._parseDateRangeValue(dateRangeValue); 699 if (dates.length !== 2) return; 700 701 const [startDate, endDate] = dates; 702 const today = new Date(); 703 [startDate, endDate, today].forEach((date) => date.setHours(0, 0, 0, 0)); 704 705 const dropdownMenu = this.element.querySelector('[data-dropdown-popover-target="menu"]'); 706 const presetButtons = 707 dropdownMenu?.querySelectorAll('[data-menu-target="item"]:not([data-action*="clearSelection"])') || []; 708 709 presetButtons.forEach((button) => { 710 const isMatch = this._checkPresetMatch(button, startDate, endDate, today); 711 if (isMatch) this._setActivePreset(button); 712 }); 713 } 714 715 _checkPresetMatch(button, startDate, endDate, today) { 716 const action = button.dataset.action; 717 718 if (action?.includes("setToday")) { 719 return this._isSameDay(startDate, today) && this._isSameDay(endDate, today); 720 } 721 722 if (action?.includes("setYesterday")) { 723 const yesterday = new Date(today); 724 yesterday.setDate(yesterday.getDate() - 1); 725 return this._isSameDay(startDate, yesterday) && this._isSameDay(endDate, yesterday); 726 } 727 728 if (action?.includes("setLastDays")) { 729 const days = parseInt(button.dataset.days, 10); 730 const expectedStart = new Date(today); 731 expectedStart.setDate(expectedStart.getDate() - (days - 1)); 732 return this._isSameDay(startDate, expectedStart) && this._isSameDay(endDate, today); 733 } 734 735 if (action?.includes("setPreset")) { 736 const presetType = button.dataset.presetType; 737 const presetRanges = { 738 "this-month": [ 739 new Date(today.getFullYear(), today.getMonth(), 1), 740 new Date(today.getFullYear(), today.getMonth() + 1, 0), 741 ], 742 "last-month": [ 743 new Date(today.getFullYear(), today.getMonth() - 1, 1), 744 new Date(today.getFullYear(), today.getMonth(), 0), 745 ], 746 "this-year": [new Date(today.getFullYear(), 0, 1), new Date(today.getFullYear(), 11, 31)], 747 }; 748 749 const range = presetRanges[presetType]; 750 return range && this._isSameDay(startDate, range[0]) && this._isSameDay(endDate, range[1]); 751 } 752 753 return false; 754 } 755 756 _isSameDay(date1, date2) { 757 return ( 758 date1.getFullYear() === date2.getFullYear() && 759 date1.getMonth() === date2.getMonth() && 760 date1.getDate() === date2.getDate() 761 ); 762 } 763 764 _updateWeekDisplay() { 765 if (!this.datepickerInstance) return; 766 767 const selectedDates = this.datepickerInstance.selectedDates; 768 if (selectedDates.length > 0) { 769 const initialDate = selectedDates[0]; 770 const weekNumber = this._getWeekNumber(initialDate); 771 const year = initialDate.getFullYear(); 772 this.inputTarget.value = `${year}-W${weekNumber.toString().padStart(2, "0")}`; 773 this._triggerChangeEvent(); 774 } else { 775 this.inputTarget.value = ""; 776 this._triggerChangeEvent(); 777 } 778 } 779 780 _cleanup() { 781 if (this.datepickerInstance) { 782 this.datepickerInstance.destroy(); 783 this.datepickerInstance = null; 784 } 785 this._cleanupPositioning(); 786 } 787 788 _cleanupPositioning() { 789 [this.cleanupAutoUpdate, this.currentPositionCleanupAutoUpdate].forEach((cleanup) => { 790 if (cleanup) cleanup(); 791 }); 792 this.cleanupAutoUpdate = null; 793 this.currentPositionCleanupAutoUpdate = null; 794 } 795};
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.