PageSourceSearch

https://railsblocks.com/assets/controllers/dropdown_popover_contro…2f36d49d04c89ba4afbe7b395d43340fe9c12bf9.js

js railsblocks.com collected 2026-09-25 20:23:14 UTC 41,170 bytes, 1,143 lines download raw bytes

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.