1import { Controller } from "@hotwired/stimulus"; 2import { computePosition, flip, shift, offset } from "@floating-ui/dom"; 3 4const DEBUG_PREDICTION_CONE = false; // Set to true to show the prediction cone 5const HOVER_CLOSE_DELAY = 150; // Delay before closing the menu when hovering over a nested dropdown 6const NESTED_PREDICTION_CONE_CLOSE_DELAY = 500; // Delay before closing the menu when hovering over a nested dropdown 7 8export default class extends Controller { 9 static targets = ["button", "menu", "template"]; 10 static classes = ["flip"]; 11 static values = { 12 autoClose: { type: Boolean, default: true }, // Whether to close the menu when clicking on an item 13 nested: { type: Boolean, default: false }, // Whether the menu is nested 14 hover: { type: Boolean, default: false }, // Whether to show the menu on hover 15 autoPosition: { type: Boolean, default: true }, // Whether to automatically position the menu 16 lazyLoad: { type: Boolean, default: false }, // Whether to lazy load the menu content 17 placement: { type: String, default: "bottom-start" }, // The placement(s) of the menu - can be multiple separated by spaces 18 dialogMode: { type: Boolean, default: true }, // Whether to use dialog mode 19 portal: { type: Boolean, default: true }, // Whether to move top-level menus to the body while open 20 turboFrameSrc: { type: String, default: "" }, // URL for Turbo Frame lazy loading 21 lockScroll: { type: Boolean, default: false }, // Whether to lock scroll when menu is open 22 }; 23 24 connect() { 25 // Cache the menu before it is portaled outside the controller element. 26 this.menuElement = this.menuTarget; 27 this.originalParent = this.menuElement.parentNode; 28 this.originalNextSibling = this.menuElement.nextSibling; 29 this.isPortaled = false; 30 31 // Initialize scroll lock state 32 this.isScrollLocked = false; 33 34 // Initialize non-dialog menu if dialogMode is false 35 if (!this.dialogModeValue && !this.menuElement.hasAttribute("role")) { 36 this.menuElement.setAttribute("role", "menu"); 37 this.menuElement.setAttribute("aria-modal", "false"); 38 this.menuElement.setAttribute("tabindex", "-1"); 39 } 40 41 this.menuItemClickedHandler = () => { 42 if (this.autoCloseValue) { 43 this.close(); 44 let parentController = this.#parentDropdownController(); 45 while (parentController) { 46 if (parentController && parentController.autoCloseValue) { 47 parentController.close(); 48 } 49 parentController = parentController.#parentDropdownController(); 50 } 51 } 52 }; 53 this.menuElement.addEventListener("menu-item-clicked", this.menuItemClickedHandler); 54 55 this.documentKeydownHandler = (event) => { 56 if (event.key === "Escape" && this.isOpen) { 57 this.close(); 58 } 59 }; 60 document.addEventListener("keydown", this.documentKeydownHandler); 61 62 // Portaled menus leave the controller scope, so keep outside-click handling direct. 63 this.documentClickHandler = this.closeOnClickOutside.bind(this); 64 document.addEventListener("click", this.documentClickHandler); 65 66 this.menuActionHandler = this.#handleMenuAction.bind(this); 67 this.menuElement.addEventListener("click", this.menuActionHandler); 68 69 this.buttonTarget.addEventListener("keydown", (event) => { 70 if (!this.isOpen) return; 71 72 const menuController = this.application.getControllerForElementAndIdentifier(this.menuElement, "menu"); 73 if (!menuController) return; 74 75 if (event.key === "ArrowDown") { 76 event.preventDefault(); 77 menuController.selectFirst(); 78 } else if (event.key === "ArrowUp") { 79 event.preventDefault(); 80 menuController.selectLast(); 81 } 82 }); 83 84 // Add scroll listener to update position 85 this.scrollHandler = () => { 86 if (this.isOpen && this.autoPositionValue) { 87 this.#updatePosition(); 88 } 89 }; 90 window.addEventListener("scroll", this.scrollHandler, true); 91 92 // Update position when window is resized or menu content changes 93 this.resizeObserver = new ResizeObserver(() => { 94 if (this.isOpen) { 95 this.#updatePosition(); 96 } 97 }); 98 99 // Observe both document.body and the menuTarget element 100 this.resizeObserver.observe(document.body); 101 this.resizeObserver.observe(this.menuElement); 102 103 // Add MutationObserver to detect content changes inside the menu 104 this.mutationObserver = new MutationObserver(() => { 105 if (this.isOpen) { 106 // Slight delay to allow DOM changes to complete 107 setTimeout(() => this.#updatePosition(), 10); 108 } 109 }); 110 111 this.mutationObserver.observe(this.menuElement, { 112 childList: true, 113 subtree: true, 114 attributes: true, 115 attributeFilter: ["style", "class"], 116 }); 117 118 // Add hover functionality for hover-enabled dropdowns 119 if (this.hoverValue) { 120 this.isMouseOverDropdown = false;
121 122 this.buttonTarget.addEventListener("mouseenter", (event) => { 123 if (this.nestedValue && this.#openSiblingPredictionConeContains(event)) return; 124 125 this.isMouseOverDropdown = true; 126 this.predictionConeOrigin = { x: event.clientX, y: event.clientY }; 127 this.#clearHoverTimers(); 128 129 // For nested dropdowns, add a small delay before opening to prevent 130 // multiple submenus from opening when quickly moving between triggers 131 if (this.nestedValue) { 132 this.openTimeout = setTimeout(() => { 133 this.show(); 134 }, 80); // 80ms delay for nested submenus 135 } else { 136 this.show(); 137 } 138 }); 139 140 this.buttonTarget.addEventListener("mousemove", (event) => { 141 if (!this.nestedValue || !this.isOpen) return; 142 143 this.predictionConeOrigin = { x: event.clientX, y: event.clientY }; 144 this.#updatePredictionCone(); 145 }); 146 147 this.buttonTarget.addEventListener("mouseleave", (event) => { 148 if (this.#isMovingWithinDropdown(event.relatedTarget)) return; 149 150 this.isMouseOverDropdown = false; 151 clearTimeout(this.openTimeout); // Cancel pending open if mouse leaves 152 // Check if mouse is not over the menu 153 this.hoverTimeout = setTimeout( 154 () => { 155 if (!this.isMouseOverDropdown && this.isOpen && !this.#hasHoveredDropdownDescendant()) { 156 this.close(); 157 } 158 }, 159 this.nestedValue ? NESTED_PREDICTION_CONE_CLOSE_DELAY : HOVER_CLOSE_DELAY, 160 ); 161 }); 162 163 this.menuElement.addEventListener("mouseenter", () => { 164 this.isMouseOverDropdown = true; 165 clearTimeout(this.hoverTimeout); 166 }); 167 168 this.menuElement.addEventListener("mouseleave", (event) => { 169 if (this.#isMovingWithinDropdown(event.relatedTarget)) return; 170 171 this.isMouseOverDropdown = false; 172 this.hoverTimeout = setTimeout( 173 () => { 174 if (!this.isMouseOverDropdown && this.isOpen && !this.#hasHoveredDropdownDescendant()) { 175 this.close(); 176 } 177 }, 178 this.nestedValue ? NESTED_PREDICTION_CONE_CLOSE_DELAY : HOVER_CLOSE_DELAY, 179 ); 180 }); 181 182 this.menuHoverHandler = this.#handleMenuHover.bind(this); 183 this.menuElement.addEventListener("mouseover", this.menuHoverHandler); 184 185 // Keep the existing element mouseleave for nested menu handling 186 this.element.addEventListener("mouseleave", (event) => { 187 const toElement = event.relatedTarget; 188 const isMovingToNestedMenu = 189 toElement && 190 (toElement.closest('[data-dropdown-popover-target="menu"]') || 191 toElement.closest('[data-dropdown-popover-target="button"]') || 192 // Check if moving to a child menu 193 toElement.closest('[data-controller~="dropdown-popover"][data-dropdown-popover-nested-value="true"]')); 194 195 if (!isMovingToNestedMenu) { 196 this.hoverTimeout = setTimeout( 197 () => { 198 if (!this.isMouseOverDropdown && !this.#hasHoveredDropdownDescendant()) { 199 this.close(); 200 201 // Reset focus state when menu is closed with hover 202 if (this.nestedValue) { 203 this.buttonTarget.classList.remove("active-menu-path"); 204 this.buttonTarget.removeAttribute("data-state"); 205 // Only reset the parent menu if we're not still inside it 206 // (e.g., don't reset when moving from nested dropdown to another item in the same menu) 207 const parentMenu = this.element.closest('[data-controller~="menu"]'); 208 if (parentMenu && toElement && !parentMenu.contains(toElement)) { 209 const menuController = this.application.getControllerForElementAndIdentifier(parentMenu, "menu"); 210 if (menuController) { 211 menuController.reset(); 212 } 213 } 214 } 215 } 216 }, 217 this.nestedValue ? NESTED_PREDICTION_CONE_CLOSE_DELAY : 100, 218 ); 219 } 220 }); 221 } else { 222 // For non-hover dropdowns, close any open hover dropdowns when hovering the button 223 this.buttonTarget.addEventListener("mouseenter", () => { 224 const allHoverDropdowns = this.application.controllers.filter( 225 (c) => c.identifier === "dropdown-popover" && c !== this && c.isOpen && c.hoverValue, 226 ); 227 228 allHoverDropdowns.forEach((controller) => { 229 controller.close(); 230 }); 231 }); 232 } 233 234 this.#setupTouchPrelock(); 235 } 236 237 disconnect() { 238 clearTimeout(this.closeTimeout); 239 240 // Make sure to unlock scroll when controller disconnects 241 if (this.isScrollLocked) { 242 this.unlockScroll(); 243 } 244 245 if (this.menuItemClickedHandler) { 246 this.menuElement.removeEventListener("menu-item-clicked", this.menuItemClickedHandler); 247 } 248 if (this.documentKeydownHandler) { 249 document.removeEventListener("keydown", this.documentKeydownHandler); 250 } 251 if (this.documentClickHandler) { 252 document.removeEventListener("click", this.documentClickHandler); 253 } 254 if (this.menuActionHandler) { 255 this.menuElement.removeEventListener("click", this.menuActionHandler); 256 } 257 if (this.menuHoverHandler) { 258 this.menuElement.removeEventListener("mouseover", this.menuHoverHandler); 259 } 260 261 this.resizeObserver.disconnect(); 262 // Disconnect mutation observer 263 this.mutationObserver.disconnect(); 264 // Remove scroll listener 265 window.removeEventListener("scroll", this.scrollHandler, true); 266 267 if (this.buttonTouchStartHandler) { 268 this.buttonTarget.removeEventListener("touchstart", this.buttonTouchStartHandler); 269 this.buttonTouchStartHandler = null; 270 } 271 272 if (this.buttonTouchEndHandler) { 273 this.buttonTarget.removeEventListener("touchend", this.buttonTouchEndHandler); 274 this.buttonTarget.removeEventListener("touchcancel", this.buttonTouchEndHandler); 275 this.buttonTouchEndHandler = null; 276 } 277 278 this.prelockedFromTouch = false;
279 this.#clearHoverTimers(); 280 this.#removePredictionConeDebug(); 281 this.#restoreMenuMount(); 282 } 283 284 get isOpen() { 285 if (this.dialogModeValue) { 286 return this.menuElement.open; 287 } else { 288 return this.menuElement.classList.contains("hidden") === false; 289 } 290 } 291 292 async show() { 293 clearTimeout(this.closeTimeout); 294 this.closeTimeout = null; 295 296 // Close all other open dropdowns that aren't in the same hierarchy 297 const allDropdowns = this.application.controllers.filter( 298 (c) => c.identifier === "dropdown-popover" && c !== this && c.isOpen, 299 ); 300 301 allDropdowns.forEach((controller) => { 302 if (!this.#isInSameDropdownHierarchy(controller)) { 303 controller.close(); 304 } 305 }); 306 307 // Close any sibling nested dropdowns first (immediately, no animation) 308 if (this.nestedValue) { 309 const parentMenu = this.element.closest('[data-controller~="menu"]'); 310 if (parentMenu) { 311 const siblingDropdowns = parentMenu.querySelectorAll( 312 '[data-controller~="dropdown-popover"][data-dropdown-popover-nested-value="true"]', 313 ); 314 siblingDropdowns.forEach((dropdown) => { 315 const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover"); 316 if (controller && controller !== this && controller.isOpen) { 317 controller.closeImmediate(); // Close immediately to prevent both being visible 318 } 319 }); 320 } 321 } 322 323 // If we have lazy loading enabled, load the content now 324 if (this.lazyLoadValue && !this.contentLoaded) { 325 await this.#loadTemplateContent(); 326 this.contentLoaded = true; 327 } 328 329 // Only top-level dropdowns should lock scroll. 330 // Locking on nested submenus can constrain/clamp submenu positioning. 331 if (this.lockScrollValue && !this.nestedValue) { 332 this.lockScroll(); 333 this.prelockedFromTouch = false; 334 } 335 336 let previousVisibility = ""; 337 if (this.dialogModeValue) { 338 // Keep the menu hidden at a safe position while measuring to avoid Safari scroll jumps. 339 previousVisibility = this.menuElement.style.visibility; 340 this.menuElement.style.visibility = "hidden"; 341 this.menuElement.style.left = "0px"; 342 this.menuElement.style.top = "0px"; 343 if (!this.menuElement.open) { 344 this.menuElement.show(); 345 } 346 } else { 347 this.menuElement.classList.remove("hidden"); 348 } 349 350 this.#preservePortalMenuWidth(); 351 this.#mountMenuForOpen(); 352 353 this.#updateExpanded(); 354 this.#resetPortaledMenuControllers(); 355 try { 356 await this.#updatePosition(); 357 } catch (_error) { 358 // Fallback near the trigger if Floating UI fails. 359 const rect = this.buttonTarget.getBoundingClientRect(); 360 this.menuElement.style.left = `${rect.left}px`; 361 this.menuElement.style.top = `${rect.bottom + 4}px`; 362 } 363 364 // Set state attribute for all dropdowns 365 this.buttonTarget.setAttribute("data-state", "open"); 366 367 // Add active-menu-path class to the button when showing the dropdown 368 if (this.nestedValue) { 369 this.buttonTarget.classList.add("active-menu-path"); 370 if (this.hoverValue) { 371 this.#updatePredictionCone(); 372 } 373 } 374 375 requestAnimationFrame(() => { 376 if (this.dialogModeValue) { 377 this.menuElement.style.visibility = previousVisibility; 378 } 379 380 // Add appropriate classes based on placement 381 if (this.placementValue.startsWith("top")) { 382 this.menuElement.classList.add("[&[open]]:scale-100", "[&[open]]:opacity-100", "scale-100", "opacity-100"); 383 } else { 384 this.menuElement.classList.add("[&[open]]:scale-100", "[&[open]]:opacity-100", "scale-100", "opacity-100"); 385 } 386 387 // Check for autofocus elements inside the menu 388 const autofocusElement = this.menuElement.querySelector('[autofocus="true"], [autofocus]'); 389 if (autofocusElement) { 390 // Focus the autofocus element 391 setTimeout(() => { 392 this.#focusWithoutScroll(autofocusElement); 393 394 // If it's an input or textarea, position cursor at the end 395 if (autofocusElement.tagName === "INPUT" || autofocusElement.tagName === "TEXTAREA") { 396 const length = autofocusElement.value.length; 397 autofocusElement.setSelectionRange(length, length); 398 } 399 }, 0); 400 } else if (this.nestedValue) { 401 this.#focusWithoutScroll(this.menuElement); 402 } else { 403 this.#focusWithoutScroll(this.buttonTarget); 404 } 405 }); 406 } 407 408 close() { 409 this.#closeInternal(false); 410 } 411 412 // Immediate close without animation - used when closing sibling menus 413 closeImmediate() { 414 this.#closeInternal(true); 415 } 416 417 #closeInternal(immediate = false) { 418 clearTimeout(this.closeTimeout); 419 this.closeTimeout = null; 420 421 // Unlock scroll if it was locked 422 if (this.isScrollLocked) { 423 this.unlockScroll(); 424 } 425 426 // Reset focus states in the menu before closing 427 const menuController = this.application.getControllerForElementAndIdentifier(this.menuElement, "menu"); 428 if (menuController) { 429 menuController.reset(); 430 } 431 432 // Close any child dropdowns first 433 const childDropdowns = this.menuElement.querySelectorAll('[data-controller~="dropdown-popover"]'); 434 childDropdowns.forEach((dropdown) => { 435 const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover"); 436 if (controller && controller.isOpen) { 437 immediate ? controller.closeImmediate() : controller.close(); 438 } 439 }); 440 441 // Remove all active-menu-path classes within this menu 442 this.menuElement.querySelectorAll(".active-menu-path").forEach((el) => { 443 el.classList.remove("active-menu-path"); 444 }); 445 // Also remove from the button that triggered this dropdown and remove state attribute 446 this.buttonTarget.classList.remove("active-menu-path"); 447 this.buttonTarget.removeAttribute("data-state"); 448 449 // If this is a hover-enabled dropdown, ensure we blur any focused elements 450 if (this.hoverValue) { 451 const focusedElement = this.element.querySelector(":focus") || this.menuElement.querySelector(":focus"); 452 if (focusedElement) { 453 focusedElement.blur(); 454 } 455 } 456 457 this.menuElement.classList.remove("scale-100", "opacity-100", "[&[open]]:scale-100", "[&[open]]:opacity-100"); 458 this.#removePredictionConeDebug(); 459 460 const finishClose = () => { 461 if (this.dialogModeValue) { 462 if (this.menuElement.open) { 463 this.menuElement.close(); 464 } 465 } else { 466 this.menuElement.classList.add("hidden"); 467 } 468 this.#updateExpanded(); 469 this.#restoreMenuMount(); 470 this.#restorePortalMenuWidth(); 471 this.closeTimeout = null; 472 }; 473 474 if (immediate) { 475 // Close immediately without animation delay 476 finishClose(); 477 } else { 478 // Animated close with delay 479 this.closeTimeout = setTimeout(finishClose, 100); 480 } 481 } 482 483 toggle() { 484 this.isOpen ? this.close() : this.show(); 485 } 486 487 closeOnClickOutside({ target }) { 488 if (!this.isOpen) return; 489 if (!(target instanceof Element)) return; 490 491 const isClickInNestedDropdown = target.closest('[data-dropdown-popover-nested-value="true"]'); 492 if (isClickInNestedDropdown) return; 493 494 if (!this.element.contains(target) && !this.menuElement.contains(target)) { 495 this.close(); 496 } 497 } 498 499 // Prevent close method to stop click propagation 500 preventClose(event) { 501 // Stop propagation to prevent the closeOnClickOutside handler from being triggered 502 event.stopPropagation(); 503 } 504 505 shouldKeepOpenForPointer(event) { 506 if (!this.nestedValue || !this.hoverValue || !this.isOpen || !this.predictionCone) return false;
507 if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return false; 508 509 return this.#isPointInPredictionCone({ x: event.clientX, y: event.clientY }); 510 } 511 512 #handleMenuHover(event) { 513 if (!(event.target instanceof Element)) return; 514 515 const button = event.target.closest('[data-dropdown-popover-target="button"]'); 516 if (!button || !this.menuElement.contains(button)) return; 517 if (event.relatedTarget instanceof Element && button.contains(event.relatedTarget)) return; 518 519 const dropdownElement = button.closest( 520 '[data-controller~="dropdown-popover"][data-dropdown-popover-nested-value="true"]', 521 ); 522 if (!dropdownElement || !this.menuElement.contains(dropdownElement) || dropdownElement === this.element) return; 523 524 const controller = this.application.getControllerForElementAndIdentifier(dropdownElement, "dropdown-popover"); 525 if (!controller?.hoverValue) return; 526 if (controller.#openSiblingPredictionConeContains(event)) return; 527 528 this.isMouseOverDropdown = true; 529 this.#clearHoverTimers(); 530 controller.isMouseOverDropdown = true; 531 controller.predictionConeOrigin = { x: event.clientX, y: event.clientY }; 532 controller.#clearHoverTimers(); 533 if (controller.isOpen) { 534 controller.#updatePredictionCone(); 535 return; 536 } 537 538 controller.show(); 539 } 540 541 #handleMenuAction(event) { 542 if (this.element.contains(this.menuElement)) return; 543 if (!(event.target instanceof Element)) return; 544 545 const actionElement = event.target.closest("[data-action]"); 546 if (!actionElement || !this.menuElement.contains(actionElement)) return; 547 548 const closestDropdown = actionElement.closest('[data-controller~="dropdown-popover"]'); 549 if (closestDropdown && closestDropdown !== this.element) return; 550 551 const actionNames = this.#localDropdownActionNamesFor(event); 552 if (actionNames.length === 0) return; 553 554 actionNames.forEach((actionName) => { 555 if (actionName === "preventClose") { 556 this.preventClose(event); 557 return; 558 } 559 560 event.preventDefault(); 561 this[actionName](); 562 }); 563 } 564 565 #localDropdownActionNamesFor(event) { 566 const handledActions = new Set(["close", "show", "toggle", "preventClose"]); 567 if (!(event.target instanceof Element)) return []; 568 569 const actionElement = event.target.closest("[data-action]"); 570 if (!actionElement || !this.menuElement.contains(actionElement)) return []; 571 572 return (actionElement.getAttribute("data-action") || "") 573 .split(/\s+/) 574 .map((descriptor) => { 575 const match = descriptor.match(/^(?:(?:click)(?:\.[^@-]+)?->)?dropdown-popover#([A-Za-z0-9_]+)$/); 576 return match?.[1]; 577 }) 578 .filter((actionName) => handledActions.has(actionName)); 579 } 580 581 // Reset method to handle dialog close events 582 reset() { 583 // This method is called when the dialog is closed 584 // It's responsible for any cleanup needed after closing 585 586 // If we have a menu controller, reset its state 587 const menuController = this.application.getControllerForElementAndIdentifier(this.menuElement, "menu"); 588 if (menuController && typeof menuController.reset === "function") { 589 menuController.reset(); 590 } 591 592 // Dispatch a custom event that can be listened for by other controllers 593 const resetEvent = new CustomEvent("dropdown-reset", { 594 bubbles: true, 595 detail: { controller: this }, 596 }); 597 this.element.dispatchEvent(resetEvent); 598 } 599 600 #updateExpanded() { 601 this.buttonTarget.ariaExpanded = this.isOpen; 602 } 603 604 #resetPortaledMenuControllers() { 605 const searchableController = this.application.getControllerForElementAndIdentifier( 606 this.menuElement, 607 "searchable-dropdown", 608 ); 609 if (searchableController && typeof searchableController.reset === "function") { 610 searchableController.reset(); 611 } 612 } 613 614 #isMovingWithinDropdown(target) { 615 if (!(target instanceof Element)) return false; 616 617 return this.element.contains(target) || this.menuElement.contains(target); 618 } 619 620 #clearHoverTimers() { 621 clearTimeout(this.hoverTimeout); 622 clearTimeout(this.openTimeout); 623 this.hoverTimeout = null; 624 this.openTimeout = null; 625 } 626 627 #hasHoveredDropdownDescendant() { 628 if (this.element.matches(":hover") || this.menuElement.matches(":hover")) return true; 629 630 return this.application.controllers.some((controller) => { 631 if (controller.identifier !== "dropdown-popover" || controller === this || !controller.isOpen) return false;
632 if (!this.#containsDropdownController(controller)) return false; 633 634 return controller.element.matches(":hover") || controller.menuElement?.matches(":hover"); 635 }); 636 } 637 638 #openSiblingPredictionConeContains(event) { 639 const parentMenu = this.element.closest('[data-controller~="menu"]'); 640 if (!parentMenu) return false; 641 642 return Array.from( 643 parentMenu.querySelectorAll('[data-controller~="dropdown-popover"][data-dropdown-popover-nested-value="true"]'), 644 ).some((dropdown) => { 645 const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover"); 646 return controller && controller !== this && controller.isOpen && controller.shouldKeepOpenForPointer?.(event); 647 }); 648 } 649 650 #updatePredictionCone() { 651 if (!this.nestedValue || !this.hoverValue) return; 652 653 const buttonRect = this.buttonTarget.getBoundingClientRect(); 654 const menuRect = this.menuElement.getBoundingClientRect(); 655 if (buttonRect.width === 0 || buttonRect.height === 0 || menuRect.width === 0 || menuRect.height === 0) return; 656 657 const opensRight = menuRect.left >= buttonRect.left; 658 const pointerX = this.#clamp( 659 this.predictionConeOrigin?.x ?? buttonRect.left + buttonRect.width / 2, 660 buttonRect.left, 661 buttonRect.right, 662 ); 663 const targetX = opensRight ? menuRect.left : menuRect.right; 664 const originY = this.#clamp( 665 this.predictionConeOrigin?.y ?? buttonRect.top + buttonRect.height / 2, 666 buttonRect.top, 667 buttonRect.bottom, 668 ); 669 const padding = 12; 670 671 this.predictionCone = { 672 origin: { x: pointerX, y: originY }, 673 upper: { x: targetX, y: menuRect.top - padding }, 674 lower: { x: targetX, y: menuRect.bottom + padding }, 675 }; 676 677 this.#renderPredictionConeDebug(); 678 } 679 680 #isPointInPredictionCone(point) { 681 const { origin, upper, lower } = this.predictionCone; 682 const area = this.#triangleArea(origin, upper, lower); 683 const areaA = this.#triangleArea(point, upper, lower); 684 const areaB = this.#triangleArea(origin, point, lower); 685 const areaC = this.#triangleArea(origin, upper, point); 686 687 return Math.abs(area - (areaA + areaB + areaC)) < 0.5; 688 } 689 690 #triangleArea(a, b, c) { 691 return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2); 692 } 693 694 #clamp(value, min, max) { 695 return Math.min(Math.max(value, min), max); 696 } 697 698 #renderPredictionConeDebug() { 699 if (!DEBUG_PREDICTION_CONE || !this.predictionCone) return; 700 701 if (!this.predictionConeDebugElement) { 702 this.predictionConeDebugElement = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 703 this.predictionConeDebugElement.setAttribute("data-dropdown-popover-prediction-cone", ""); 704 Object.assign(this.predictionConeDebugElement.style, { 705 position: "fixed", 706 inset: "0", 707 width: "100vw", 708 height: "100vh", 709 pointerEvents: "none", 710 zIndex: "2147483647", 711 }); 712 document.body.appendChild(this.predictionConeDebugElement); 713 } 714 715 const { origin, upper, lower } = this.predictionCone; 716 this.predictionConeDebugElement.innerHTML = ` 717 <polygon 718 points="${origin.x},${origin.y} ${upper.x},${upper.y} ${lower.x},${lower.y}" 719 fill="rgba(59, 130, 246, 0.12)" 720 stroke="rgba(96, 165, 250, 0.95)" 721 stroke-width="1.5" 722 stroke-dasharray="6 4" 723 /> 724 <circle cx="${origin.x}" cy="${origin.y}" r="3" fill="rgba(96, 165, 250, 1)" /> 725 <circle cx="${upper.x}" cy="${upper.y}" r="3" fill="rgba(96, 165, 250, 1)" /> 726 <circle cx="${lower.x}" cy="${lower.y}" r="3" fill="rgba(96, 165, 250, 1)" /> 727 `; 728 } 729 730 #removePredictionConeDebug() { 731 this.predictionCone = null; 732 this.predictionConeDebugElement?.remove(); 733 this.predictionConeDebugElement = null; 734 } 735 736 #preservePortalMenuWidth() { 737 if (!this.portalValue || this.nestedValue || this.menuElement.parentElement !== this.originalParent) return; 738 739 const inlineWidth = this.menuElement.style.width; 740 const hasPercentageWidth = inlineWidth.trim().endsWith("%") || this.menuElement.classList.contains("w-full"); 741 if (!hasPercentageWidth) return; 742 743 const measuredWidth = this.menuElement.getBoundingClientRect().width; 744 if (measuredWidth <= 0) return; 745 746 this.previousMenuInlineWidth = inlineWidth; 747 this.menuElement.style.width = `${measuredWidth}px`; 748 } 749 750 #restorePortalMenuWidth() { 751 if (this.previousMenuInlineWidth === undefined) return; 752 753 this.menuElement.style.width = this.previousMenuInlineWidth; 754 this.previousMenuInlineWidth = undefined; 755 } 756 757 #mountMenuForOpen() { 758 if (!this.portalValue || this.nestedValue) return; 759 760 const appendTarget = this.element.closest("dialog[open]") || document.body; 761 if (this.menuElement.parentElement !== appendTarget) { 762 appendTarget.appendChild(this.menuElement); 763 } 764 this.isPortaled = this.menuElement.parentElement !== this.originalParent; 765 } 766 767 #restoreMenuMount() { 768 if (!this.isPortaled || !this.originalParent) return; 769 770 if (this.originalNextSibling && this.originalNextSibling.parentNode === this.originalParent) { 771 this.originalParent.insertBefore(this.menuElement, this.originalNextSibling); 772 } else { 773 this.originalParent.appendChild(this.menuElement); 774 } 775 776 this.isPortaled = false;
777 } 778 779 #isInSameDropdownHierarchy(controller) { 780 return this.#containsDropdownController(controller) || controller.#containsDropdownController(this); 781 } 782 783 #containsDropdownController(controller) { 784 if (!controller || !controller.element || !controller.menuElement) return false; 785 786 return ( 787 this.element.contains(controller.element) || 788 this.menuElement.contains(controller.element) || 789 this.element.contains(controller.menuElement) || 790 this.menuElement.contains(controller.menuElement) 791 ); 792 } 793 794 #parentDropdownController() { 795 const dropdownControllers = this.application.controllers.filter( 796 (controller) => controller.identifier === "dropdown-popover" && controller !== this, 797 ); 798 let current = this.element.parentElement; 799 800 while (current) { 801 if (current.matches?.('[data-controller~="dropdown-popover"]')) { 802 return this.application.getControllerForElementAndIdentifier(current, "dropdown-popover"); 803 } 804 805 const menuOwner = dropdownControllers.find((controller) => controller.menuElement === current); 806 if (menuOwner) return menuOwner; 807 808 current = current.parentElement; 809 } 810 811 return null; 812 } 813 814 async #loadTemplateContent() { 815 // Find the container in the menu to append content to 816 const container = this.menuElement.querySelector("[data-dropdown-popover-content]") || this.menuElement; 817 818 // Check if we should use Turbo Frame lazy loading 819 if (this.turboFrameSrcValue) { 820 // Look for a turbo-frame in the container 821 let turboFrame = container.querySelector("turbo-frame"); 822 823 if (!turboFrame) { 824 // Create a turbo-frame if it doesn't exist 825 turboFrame = document.createElement("turbo-frame"); 826 turboFrame.id = "dropdown-lazy-content"; 827 828 // Clear any loading indicators or placeholder content 829 container.innerHTML = ""; 830 container.appendChild(turboFrame); 831 } 832 833 // Set the src to trigger the lazy load 834 turboFrame.src = this.turboFrameSrcValue; 835 836 // Wait for the turbo-frame to load 837 return new Promise((resolve) => { 838 const handleLoad = () => { 839 turboFrame.removeEventListener("turbo:frame-load", handleLoad); 840 this.#refreshMenuController(); 841 resolve(); 842 }; 843 844 turboFrame.addEventListener("turbo:frame-load", handleLoad); 845 846 // Fallback timeout in case the frame doesn't load 847 setTimeout(() => { 848 turboFrame.removeEventListener("turbo:frame-load", handleLoad); 849 this.#refreshMenuController(); 850 resolve(); 851 }, 5000); 852 }); 853 } else if (this.hasTemplateTarget) { 854 // Use template-based lazy loading (existing behavior) 855 const templateContent = this.templateTarget.content.cloneNode(true); 856 857 // Clear any loading indicators or placeholder content 858 container.innerHTML = ""; 859 860 // Append the template content 861 container.appendChild(templateContent); 862 863 this.#refreshMenuController(); 864 } 865 } 866 867 #refreshMenuController() { 868 // Refresh the menu controller to pick up new targets from the loaded content 869 setTimeout(() => { 870 const menuController = this.application.getControllerForElementAndIdentifier(this.menuElement, "menu"); 871 if (menuController) { 872 // Disconnect and reconnect the menu controller to refresh its targets 873 menuController.disconnect(); 874 menuController.connect(); 875 } 876 }, 10); 877 } 878 879 async #updatePosition() { 880 // Parse placement value to support multiple placements 881 const placements = this.placementValue.split(/[\s,]+/).filter(Boolean); 882 const primaryPlacement = placements[0] || "bottom-start"; 883 const fallbackPlacements = placements.slice(1); 884 885 const middleware = [ 886 offset(this.nestedValue ? -4 : 4), // 0 offset for nested, 4px for regular 887 flip({ 888 fallbackPlacements: fallbackPlacements.length > 0 ? fallbackPlacements : ["top-start", "bottom-start"], 889 }), 890 shift({ padding: 8 }), // Keep 8px padding from window edges 891 ]; 892 893 if (this.nestedValue) { 894 // For nested dropdowns, position to the right of the button 895 const { x, y } = await computePosition(this.buttonTarget, this.menuElement, { 896 placement: "right-start", 897 middleware, 898 }); 899 900 Object.assign(this.menuElement.style, { 901 left: `${x}px`, 902 top: `${y}px`, 903 }); 904 } else { 905 // Use the primary placement from the controller 906 const { x, y } = await computePosition(this.buttonTarget, this.menuElement, { 907 placement: primaryPlacement, 908 middleware: this.autoPositionValue ? middleware : [offset(this.nestedValue ? -4 : 4)], 909 }); 910 911 Object.assign(this.menuElement.style, { 912 left: `${x}px`, 913 top: `${y}px`, 914 }); 915 } 916 } 917 918 #focusWithoutScroll(element) { 919 if (!element || typeof element.focus !== "function") return; 920 921 try { 922 element.focus({ preventScroll: true }); 923 } catch (_error) { 924 element.focus(); 925 } 926 } 927 928 #setupTouchPrelock() { 929 if (!this.lockScrollValue || this.hoverValue || this.nestedValue || !this.hasButtonTarget) return; 930 931 this.buttonTouchStartHandler = (event) => { 932 if (this.isOpen || this.isScrollLocked) return; 933 934 const target = event.target; 935 if (!(target instanceof Element)) return; 936 if (target.closest("a, input, textarea, select")) return; 937 938 this.lockScroll(); 939 this.prelockedFromTouch = true; 940 }; 941 942 this.buttonTouchEndHandler = () => { 943 if (!this.prelockedFromTouch) return; 944 945 // If touch interaction did not open the dropdown, revert the pre-lock. 946 setTimeout(() => { 947 if (!this.prelockedFromTouch || this.isOpen) return; 948 if (this.isScrollLocked) { 949 this.unlockScroll(); 950 } 951 this.prelockedFromTouch = false;
952 }, 0); 953 }; 954 955 this.buttonTarget.addEventListener("touchstart", this.buttonTouchStartHandler, { passive: true }); 956 this.buttonTarget.addEventListener("touchend", this.buttonTouchEndHandler); 957 this.buttonTarget.addEventListener("touchcancel", this.buttonTouchEndHandler); 958 } 959 960 // Add scroll locking methods 961 lockScroll() { 962 if (this.isScrollLocked) return; 963 964 this.scrollLockTarget = this.#getScrollLockTarget(); 965 966 if (this.scrollLockTarget !== document.body) { 967 this.previousTargetOverflow = this.scrollLockTarget.style.overflow; 968 this.previousTargetPaddingRight = this.scrollLockTarget.style.paddingRight; 969 this.previousTargetOverscrollBehavior = this.scrollLockTarget.style.overscrollBehavior; 970 971 const targetStyles = window.getComputedStyle(this.scrollLockTarget); 972 const borderLeft = parseFloat(targetStyles.borderLeftWidth) || 0; 973 const borderRight = parseFloat(targetStyles.borderRightWidth) || 0; 974 const rawDelta = this.scrollLockTarget.offsetWidth - this.scrollLockTarget.clientWidth; 975 const scrollbarWidth = Math.max(0, rawDelta - borderLeft - borderRight); 976 if (scrollbarWidth > 0) { 977 const computedPaddingRight = parseFloat(targetStyles.paddingRight) || 0; 978 this.scrollLockTarget.style.paddingRight = `${computedPaddingRight + scrollbarWidth}px`; 979 } 980 981 this.scrollLockTarget.style.overflow = "hidden"; 982 this.scrollLockTarget.style.overscrollBehavior = "contain"; 983 this.isScrollLocked = true; 984 return; 985 } 986 987 this.#lockBodyScrollWithCompensation(); 988 989 this.isScrollLocked = true; 990 } 991 992 unlockScroll() { 993 if (!this.isScrollLocked) return; 994 995 if (this.scrollLockTarget && this.scrollLockTarget !== document.body) { 996 this.scrollLockTarget.style.overflow = this.previousTargetOverflow || ""; 997 this.scrollLockTarget.style.paddingRight = this.previousTargetPaddingRight || ""; 998 this.scrollLockTarget.style.overscrollBehavior = this.previousTargetOverscrollBehavior || ""; 999 this.scrollLockTarget = null; 1000 this.isScrollLocked = false; 1001 return; 1002 } 1003 1004 this.#unlockBodyScrollWithCompensation(); 1005 1006 this.isScrollLocked = false; 1007 this.scrollLockTarget = null; 1008 } 1009 1010 #getScrollLockTarget() { 1011 const parentOverlay = this.element.closest( 1012 'dialog[open][data-modal-target="dialog"], dialog[open][data-slideover-target="dialog"], dialog[open][data-drawer-target="dialog"], dialog[open], .modal-div.modal-open', 1013 ); 1014 1015 if (parentOverlay instanceof HTMLElement) { 1016 const nearestScrollableWithinOverlay = this.#findNearestScrollableAncestor(this.buttonTarget, parentOverlay); 1017 return nearestScrollableWithinOverlay || parentOverlay; 1018 } 1019 1020 return document.body; 1021 } 1022 1023 #findNearestScrollableAncestor(fromElement, boundaryElement) { 1024 let current = fromElement?.parentElement; 1025 1026 while (current && current !== boundaryElement) { 1027 const style = window.getComputedStyle(current); 1028 const overflowY = style.overflowY; 1029 const isScrollable = 1030 (overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight; 1031 1032 if (isScrollable) return current; 1033 current = current.parentElement; 1034 } 1035 1036 if (boundaryElement instanceof HTMLElement) { 1037 const style = window.getComputedStyle(boundaryElement); 1038 const overflowY = style.overflowY; 1039 const isScrollable = 1040 (overflowY === "auto" || overflowY === "scroll") && boundaryElement.scrollHeight > boundaryElement.clientHeight; 1041 if (isScrollable) return boundaryElement; 1042 } 1043 1044 return null; 1045 } 1046 1047 #lockBodyScrollWithCompensation() { 1048 if (this.hasRegisteredBodyLock) return; 1049 1050 const state = this.#getBodyLockState(); 1051 if (state.count === 0) { 1052 const scrollbarWidth = this.#measureScrollbarWidth(); 1053 state.previousBodyOverflow = document.body.style.overflow; 1054 state.previousBodyPaddingRight = document.body.style.paddingRight; 1055 1056 if (scrollbarWidth > 0) { 1057 const bodyStyles = window.getComputedStyle(document.body); 1058 const computedBodyPaddingRight = parseFloat(bodyStyles.paddingRight) || 0; 1059 document.body.style.paddingRight = `${computedBodyPaddingRight + scrollbarWidth}px`; 1060 } 1061 1062 document.body.style.overflow = "hidden"; 1063 1064 state.compensatedFixedElements = []; 1065 if (scrollbarWidth > 0) { 1066 document.querySelectorAll(".fixed").forEach((element) => { 1067 if (!(element instanceof HTMLElement) || this.#shouldSkipFixedCompensation(element)) return; 1068 1069 state.compensatedFixedElements.push({ 1070 element, 1071 previousPaddingRight: element.style.paddingRight, 1072 }); 1073 1074 const fixedStyles = window.getComputedStyle(element); 1075 const computedFixedPaddingRight = parseFloat(fixedStyles.paddingRight) || 0; 1076 element.style.paddingRight = `${computedFixedPaddingRight + scrollbarWidth}px`; 1077 }); 1078 } 1079 } 1080 1081 state.count += 1; 1082 this.hasRegisteredBodyLock = true; 1083 } 1084 1085 #unlockBodyScrollWithCompensation() { 1086 if (!this.hasRegisteredBodyLock) return; 1087 1088 const state = this.#getBodyLockState(); 1089 state.count = Math.max(0, state.count - 1); 1090 1091 if (state.count === 0) { 1092 document.body.style.overflow = state.previousBodyOverflow || ""; 1093 document.body.style.paddingRight = state.previousBodyPaddingRight || ""; 1094 state.previousBodyOverflow = ""; 1095 state.previousBodyPaddingRight = ""; 1096
1097 state.compensatedFixedElements.forEach(({ element, previousPaddingRight }) => { 1098 if (!(element instanceof HTMLElement)) return; 1099 element.style.paddingRight = previousPaddingRight || ""; 1100 }); 1101 state.compensatedFixedElements = []; 1102 } 1103 1104 this.hasRegisteredBodyLock = false; 1105 } 1106 1107 #measureScrollbarWidth() { 1108 const outer = document.createElement("div"); 1109 outer.style.visibility = "hidden"; 1110 outer.style.overflow = "scroll"; 1111 outer.style.msOverflowStyle = "scrollbar"; 1112 document.body.appendChild(outer); 1113 1114 const inner = document.createElement("div"); 1115 outer.appendChild(inner); 1116 1117 const scrollbarWidth = Math.max(0, outer.offsetWidth - inner.offsetWidth); 1118 outer.parentNode?.removeChild(outer); 1119 1120 return scrollbarWidth; 1121 } 1122 1123 #getBodyLockState() { 1124 if (!window.__floatingBodyScrollLockState) { 1125 window.__floatingBodyScrollLockState = { 1126 count: 0, 1127 previousBodyOverflow: "", 1128 previousBodyPaddingRight: "", 1129 compensatedFixedElements: [], 1130 }; 1131 } 1132 1133 return window.__floatingBodyScrollLockState; 1134 } 1135 1136 #shouldSkipFixedCompensation(element) { 1137 return Boolean( 1138 element.closest( 1139 'dialog[data-context-menu-target="menu"], [data-slideover-target="dialog"], [data-drawer-target="dialog"]', 1140 ), 1141 ); 1142 } 1143};
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.