PageSourceSearch

https://railsblocks.com/assets/controllers/menu_controller-8f656dc…aa015721d737cd78e58b4d44725b91b27428be4f.js

js railsblocks.com collected 2026-09-25 20:23:40 UTC 24,729 bytes, 696 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3export default class extends Controller {
4  static targets = ["item"]; // Targets for the menu items
5  static values = { index: Number }; // Index of the current item
6
7  connect() {
8    this.indexValue = -1;
9    this.searchString = "";
10    this.keyboardNavigation = false; // Track keyboard navigation state
11    this.#updateTabstops();
12
13    if (!this.documentKeydownHandler) {
14      this.documentKeydownHandler = (event) => {
15        if (document.activeElement.tagName === "INPUT") return;
16
17        this.keyboardNavigation = true;
18
19        if (this.element.open && event.key.length === 1 && /\S/.test(event.key)) {
20          event.preventDefault();
21          event.stopPropagation();
22          this.#handleTextSearch(event.key.toLowerCase());
23        }
24      };
25      document.addEventListener("keydown", this.documentKeydownHandler);
26    }
27
28    this.itemTargets.forEach((item) => {
29      if (item.dataset.menuControllerListenersAttached === "true") return;
30      item.dataset.menuControllerListenersAttached = "true";
31
32      item.addEventListener("click", (event) => {
33        if (this.keyboardInputActivation) return;
34
35        // Ensure the item maintains focus when clicked
36        this.#focusWithoutScroll(item);
37
38        // Update the index to match the clicked item
39        this.indexValue = this.itemTargets.indexOf(item);
40        this.#updateTabstops();
41
42        // If clicking on a label with checkbox/radio, prevent the input from stealing focus
43        const input = item.querySelector('input[type="checkbox"], input[type="radio"]');
44        if (input && event.target !== input) {
45          // Allow the click to propagate to toggle the checkbox, but maintain focus on the label
46          setTimeout(() => {
47            this.#focusWithoutScroll(item);
48          }, 0);
49        }
50
51        if (!this.#isNestedDropdownButton(item)) {
52          if (this.#shouldAutoClose()) {
53            this.element.dispatchEvent(new Event("menu-item-clicked", { bubbles: true }));
54          }
55        }
56      });
57
58      item.addEventListener("mousemove", (event) => {
59        // Ignore mousemove events fired when content scrolls under a stationary cursor.
60        if (event.movementX === 0 && event.movementY === 0) return;
61        this.keyboardNavigation = false;
62      });
63
64      item.addEventListener("mouseenter", (event) => {
65        if (!this.element.open) return;
66        event.preventDefault();
67
68        // Don't interfere with keyboard navigation
69        if (this.keyboardNavigation) {
70          return;
71        }
72
73        // Only handle mouse enter for visible items
74        const currentItem = event.currentTarget;
75        if (
76          currentItem.disabled ||
77          currentItem.classList.contains("disabled") ||
78          currentItem.classList.contains("hidden") ||
79          currentItem.style.display === "none"
80        ) {
81          return;
82        }
83
84        // Check computed styles for actual visibility (handles Tailwind responsive classes)
85        const computedStyle = window.getComputedStyle(currentItem);
86        if (computedStyle.display === "none" || computedStyle.visibility === "hidden") {
87          return;
88        }
89
90        const openNestedControllers = this.#openNestedDropdownControllers();
91        const pointerInPredictionCone = openNestedControllers.some((controller) =>
92          controller.shouldKeepOpenForPointer?.(event),
93        );
94        const isCurrentItemOpenNestedTrigger = openNestedControllers.some(
95          (controller) => controller.buttonTarget === event.currentTarget,
96        );
97        if (pointerInPredictionCone && !isCurrentItemOpenNestedTrigger) {
98          this.#scheduleDeferredHover(event.currentTarget, event);
99          return;
100        }
101
102        // Close any sibling nested dropdowns with a delay
103        const siblingDropdowns = this.element.querySelectorAll(
104          '[data-controller~="dropdown-popover"][data-dropdown-popover-nested-value="true"]',
105        );
106        siblingDropdowns.forEach((dropdown) => {
107          const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
108          const isHoverEnabled = controller?.hoverValue;
109          const isCurrentItemNestedDropdown = event.currentTarget.hasAttribute("data-dropdown-popover-target");
110
111          // Only close if hover is enabled and current item is not a nested dropdown
112          if (
113            controller &&
114            controller.isOpen &&
115            !dropdown.contains(event.currentTarget) &&
116            isHoverEnabled &&
117            !isCurrentItemNestedDropdown &&
118            !controller.shouldKeepOpenForPointer?.(event)
119          ) {
120            setTimeout(() => {
121              if (!controller.shouldKeepOpenForPointer?.(event)) {
122                controller.close();
123              }
124            }, 100);
125          }
126        });
127
128        this.#applyHoverToItem(event.currentTarget);
129      });
130
131      item.addEventListener("mouseleave", (event) => {
132        if (!this.element.open) return;
133
134        if (this.deferredHoverItem === event.currentTarget) {
135          this.#clearDeferredHover();
136        }
137
138        // During keyboard navigation, scrolling items under a stationary cursor can trigger mouseleave.
139        // Don't let that steal focus back to the button / reset state.
140        if (this.keyboardNavigation) {
141          return;
142        }
143
144        const contextMenuController = this.#owningContextMenuController();
145        const dropdownController = this.#owningDropdownController();
146
147        // Find all open nested dropdown controllers within this menu
148        const openNestedControllers = this.#openNestedDropdownControllers();
149
150        // If pointer is moving into an open nested submenu, don't steal focus back
151        // to the submenu trigger button. Let child item mouseenter manage highlight/focus.
152        const relatedTarget = event.relatedTarget;
153        const movingIntoOpenNestedMenuController =
154          relatedTarget &&
155          openNestedControllers.find(
156            (controller) =>
157              controller.menuElement.contains(relatedTarget) ||
158              controller.buttonTarget?.contains(relatedTarget) ||
159              controller.element.contains(relatedTarget),
160          );
161        if (
162          movingIntoOpenNestedMenuController &&
163          (movingIntoOpenNestedMenuController.buttonTarget === event.currentTarget ||
164            movingIntoOpenNestedMenuController.element.contains(event.currentTarget))
165        ) {
166          return;
167        }
168
169        const pointerInPredictionCone = openNestedControllers.some((controller) =>
170          controller.shouldKeepOpenForPointer?.(event),
171        );
172        if (pointerInPredictionCone) {
173          return;
174        }
175
176        if (openNestedControllers.length > 0) {
177          // Focus the last (deepest) open nested controller's button
178          const deepestController = openNestedControllers[openNestedControllers.length - 1];
179          const parentMenu = deepestController.buttonTarget.closest('[data-controller~="menu"]');
180          const parentMenuController = this.application.getControllerForElementAndIdentifier(parentMenu, "menu");
181
182          // Update tabindex state in the parent menu using public method
183          const buttonIndex = parentMenuController.itemTargets.indexOf(deepestController.buttonTarget);
184          parentMenuController.updateTabstopsWithIndex(buttonIndex);
185          this.#focusWithoutScroll(deepestController.buttonTarget);
186        } else if (this.indexValue === this.itemTargets.indexOf(event.currentTarget)) {
187          if (contextMenuController) {
188            this.#focusWithoutScroll(this.element);
189          } else if (dropdownController) {
190            this.#focusWithoutScroll(dropdownController.buttonTarget);
191          }
192          // Remove state attribute when leaving item
193          event.currentTarget.removeAttribute("data-state");
194          this.indexValue = -1;
195          this.#updateTabstops();
196        }
197      });
198
199      item.addEventListener("keydown", (event) => {
200        this.keyboardNavigation = true;
201        if (event.key === "Home") {
202          event.preventDefault();
203          this.selectFirst();
204        } else if (event.key === "End") {
205          event.preventDefault();
206          this.selectLast();
207        } else if (this.#isActivationKey(event)) {
208          this.#activateItem(item, event);
209        } else if (event.key === "ArrowRight") {
210          if (this.#isNestedDropdownButton(item)) {
211            event.preventDefault();
212            this.#stopEvent(event);
213            const dropdownController = this.#dropdownControllerForItem(item);
214            dropdownController?.show();
215            // Add state attribute when opening nested dropdown
216            if (dropdownController?.nestedValue) {
217              item.setAttribute("data-state", "open");
218            }
219          }
220        } else if (event.key === "ArrowLeft") {
221          const parentDropdownController = this.#parentNestedDropdownController();
222          if (parentDropdownController) {
223            event.stopPropagation();
224            // Remove state attribute when closing nested dropdown
225            parentDropdownController.buttonTarget.removeAttribute("data-state");
226            parentDropdownController.close();
227            this.#focusWithoutScroll(parentDropdownController.buttonTarget);
228          }
229        } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
230          this.element.querySelectorAll(".active-menu-path").forEach((el) => {
231            el.classList.remove("active-menu-path");
232          });
233
234          if (this.#isNestedDropdownButton(item)) {
235            const dropdownController = this.#dropdownControllerForItem(item);
236
237            if (dropdownController?.isOpen) {
238              event.preventDefault();
239              event.stopPropagation();
240              const childMenuController = this.application.getControllerForElementAndIdentifier(
241                dropdownController.menuElement,
242                "menu",
243              );
244
245              // Add active path class and state attribute to parent item
246              item.classList.add("active-menu-path");
247              item.setAttribute("data-state", "open");
248
249              if (event.key === "ArrowDown") {
250                childMenuController.selectFirst();
251              } else {
252                childMenuController.selectLast();
253              }
254              return;
255            }
256          }
257        }
258      });
259    });
260
261    if (!this.elementKeydownHandler) {
262      this.elementKeydownHandler = (event) => {
263        if (this.#isActivationKey(event) && !event.defaultPrevented) {
264          const item = this.#eventItemOrCurrentItem(event);
265          if (item) {
266            this.#activateItem(item, event);
267          }
268        } else if (event.key === "ArrowRight" && !event.defaultPrevented) {
269          const item = this.#eventItemOrCurrentItem(event);
270          if (item && this.#isNestedDropdownButton(item)) {
271            event.preventDefault();
272            this.#stopEvent(event);
273            const dropdownController = this.#dropdownControllerForItem(item);
274            dropdownController?.show();
275            if (dropdownController?.nestedValue) {
276              item.setAttribute("data-state", "open");
277            }
278          }
279        } else if (event.key === "ArrowLeft") {
280          const parentDropdownController = this.#parentNestedDropdownController();
281          if (parentDropdownController) {
282            event.stopPropagation();
283            // Remove state attribute when closing nested dropdown
284            parentDropdownController.buttonTarget.removeAttribute("data-state");
285            parentDropdownController.close();
286            this.#focusWithoutScroll(parentDropdownController.buttonTarget);
287          }
288        }
289      };
290      this.element.addEventListener("keydown", this.elementKeydownHandler);
291    }
292
293    if (!this.elementMouseleaveHandler) {
294      this.elementMouseleaveHandler = (event) => {
295        if (!this.element.open) return;
296
297        // Same idea as above: ignore scroll-induced mouseleave while keyboard navigating.
298        if (this.keyboardNavigation) {
299          return;
300        }
301
302        // Check all nested dropdown controllers for open state
303        const hasOpenNested = this.#openNestedDropdownControllers().length > 0;
304
305        if (!hasOpenNested) {
306          const dropdownController = this.#owningDropdownController();
307          if (dropdownController?.buttonTarget) {
308            this.#focusWithoutScroll(dropdownController.buttonTarget);
309          }
310        }
311      };
312      this.element.addEventListener("mouseleave", this.elementMouseleaveHandler);
313    }
314  }
315
316  reset() {
317    this.indexValue = -1;
318    this.searchString = "";
319    this.#updateTabstops();
320  }
321
322  // Helper method to get only visible and enabled items
323  #getVisibleItems() {
324    return this.itemTargets.filter((item) => {
325      // Check if item is disabled
326      if (item.disabled || item.classList.contains("disabled")) {
327        return false;
328      }
329
330      // Check if item is explicitly hidden
331      if (item.classList.contains("hidden")) {
332        return false;
333      }
334
335      // Check if item has inline style display none
336      if (item.style.display === "none") {
337        return false;
338      }
339
340      // Check computed styles for actual visibility (handles Tailwind responsive classes)
341      const computedStyle = window.getComputedStyle(item);
342      if (computedStyle.display === "none" || computedStyle.visibility === "hidden") {
343        return false;
344      }
345
346      return true;
347    });
348  }
349
350  prev() {
351    this.keyboardNavigation = true;
352    this.element.querySelectorAll(".active-menu-path").forEach((el) => {
353      el.classList.remove("active-menu-path");
354      el.removeAttribute("data-state");
355    });
356
357    // Get only visible and enabled items
358    const visibleItems = this.#getVisibleItems();
359    if (visibleItems.length === 0) return;
360
361    const currentVisibleIndex = visibleItems.indexOf(this.itemTargets[this.indexValue]);
362
363    if (currentVisibleIndex === -1) {
364      this.indexValue = this.itemTargets.indexOf(visibleItems[visibleItems.length - 1]);
365    } else if (currentVisibleIndex > 0) {
366      this.indexValue = this.itemTargets.indexOf(visibleItems[currentVisibleIndex - 1]);
367    } else {
368      this.indexValue = this.itemTargets.indexOf(visibleItems[visibleItems.length - 1]);
369    }
370
371    this.#updateTabstops();
372    this.#focusCurrentItem();
373  }
374
375  next() {
376    this.keyboardNavigation = true;
377    this.element.querySelectorAll(".active-menu-path").forEach((el) => {
378      el.classList.remove("active-menu-path");
379      el.removeAttribute("data-state");
380    });
381
382    // Get only visible and enabled items
383    const visibleItems = this.#getVisibleItems();
384    if (visibleItems.length === 0) return;
385
386    const currentVisibleIndex = visibleItems.indexOf(this.itemTargets[this.indexValue]);
387
388    if (currentVisibleIndex === -1) {
389      this.indexValue = this.itemTargets.indexOf(visibleItems[0]);
390    } else if (currentVisibleIndex < visibleItems.length - 1) {
391      this.indexValue = this.itemTargets.indexOf(visibleItems[currentVisibleIndex + 1]);
392    } else {
393      this.indexValue = this.itemTargets.indexOf(visibleItems[0]);
394    }
395
396    this.#updateTabstops();
397    this.#focusCurrentItem();
398  }
399
400  preventScroll(event) {
401    event.preventDefault();
402  }
403
404  #updateTabstops() {
405    this.itemTargets.forEach((element, index) => {
406      const isHighlighted = index === this.indexValue && this.indexValue !== -1;
407      element.tabIndex = isHighlighted ? 0 : -1;
408
409      // Add/remove data-highlighted attribute for CSS styling (Base UI pattern)
410      if (isHighlighted) {
411        element.setAttribute("data-highlighted", "");
412      } else {
413        element.removeAttribute("data-highlighted");
414      }
415    });
416  }
417
418  #focusCurrentItem(options = {}) {
419    const item = this.itemTargets[this.indexValue];
420    this.#focusWithoutScroll(item, options);
421  }
422
423  #applyHoverToItem(item) {
424    this.indexValue = this.itemTargets.indexOf(item);
425    this.#updateTabstops();
426    // Focus without scrolling for mouse hover to prevent aggressive auto-scroll
427    this.#focusCurrentItem({ preventScroll: true });
428
429    if (this.#isNestedDropdownButton(item)) {
430      const dropdownController = this.#dropdownControllerForItem(item);
431      if (dropdownController?.hoverValue && !dropdownController.isOpen) {
432        dropdownController.show();
433      }
434    }
435  }
436
437  #scheduleDeferredHover(item, event) {
438    this.deferredHoverItem = item;
439    this.deferredHoverPointer = { clientX: event.clientX, clientY: event.clientY };
440    clearTimeout(this.deferredHoverTimeout);
441
442    this.deferredHoverTimeout = setTimeout(() => {
443      if (!this.element.open || this.keyboardNavigation || this.deferredHoverItem !== item || !item.matches(":hover")) {
444        this.#clearDeferredHover();
445        return;
446      }
447
448      const pointerInPredictionCone = this.#openNestedDropdownControllers().some((controller) =>
449        controller.shouldKeepOpenForPointer?.(this.deferredHoverPointer),
450      );
451
452      if (pointerInPredictionCone) {
453        this.#scheduleDeferredHover(item, this.deferredHoverPointer);
454        return;
455      }
456
457      this.#clearDeferredHover();
458      this.#applyHoverToItem(item);
459    }, 100);
460  }
461
462  #clearDeferredHover() {
463    clearTimeout(this.deferredHoverTimeout);
464    this.deferredHoverTimeout = null;
465    this.deferredHoverItem = null;
466    this.deferredHoverPointer = null;
467  }
468
469  #focusWithoutScroll(element, options = {}) {
470    if (!element || typeof element.focus !== "function") return;
471
472    try {
473      element.focus({ preventScroll: true, ...options });
474    } catch (_error) {
475      element.focus(options);
476    }
477  }
478
479  #activateItem(item, event) {
480    if (this.#isDisabledItem(item)) {
481      event.preventDefault();
482      this.#stopEvent(event);
483      return;
484    }
485
486    const input = item.querySelector('input[type="checkbox"], input[type="radio"]');
487    if (input) {
488      event.preventDefault();
489      this.#stopEvent(event);
490      this.#activateInputItem(item, input);
491      return;
492    }
493
494    if (this.#isNestedDropdownButton(item)) {
495      event.preventDefault();
496      this.#stopEvent(event);
497      const dropdownController = this.#dropdownControllerForItem(item);
498      dropdownController?.show();
499      if (dropdownController?.nestedValue) {
500        item.setAttribute("data-state", "open");
501      }
502      return;
503    }
504
505    const activatable = item.matches("a, button") ? item : item.querySelector("a, button");
506    if (!activatable) return;
507
508    event.preventDefault();
509    this.#stopEvent(event);
510    activatable.click();
511  }
512
513  #activateInputItem(item, input) {
514    if (input.type === "checkbox") {
515      input.checked = !input.checked;
516      this.#dispatchInputChange(input);
517    } else if (input.type === "radio" && !input.checked) {
518      if (input.name) {
519        document.querySelectorAll('input[type="radio"]').forEach((radio) => {
520          if (radio !== input && radio.name === input.name && radio.form === input.form) {
521            radio.checked = false;
522          }
523        });
524      }
525
526      input.checked = true;
527      this.#dispatchInputChange(input);
528    }
529
530    this.indexValue = this.itemTargets.indexOf(item);
531    this.#updateTabstops();
532    this.#focusWithoutScroll(item);
533
534    if (this.#shouldAutoClose()) {
535      this.element.dispatchEvent(new Event("menu-item-clicked", { bubbles: true }));
536    }
537  }
538
539  #isDisabledItem(item) {
540    const input = item.querySelector('input[type="checkbox"], input[type="radio"]');
541    return (
542      item.disabled ||
543      item.getAttribute("aria-disabled") === "true" ||
544      item.classList.contains("disabled") ||
545      input?.disabled
546    );
547  }
548
549  #isActivationKey(event) {
550    return event.key === "Enter" || event.key === " " || event.key === "Space" || event.key === "Spacebar";
551  }
552
553  #itemFromEvent(event) {
554    return this.itemTargets.find((item) => item === event.target || item.contains(event.target));
555  }
556
557  #eventItemOrCurrentItem(event) {
558    const eventItem = this.#itemFromEvent(event);
559    if (eventItem) return eventItem;
560
561    // Nested menus live inside parent menus. Do not let the parent menu
562    // activate its highlighted submenu trigger for key events from child menu content.
563    if (event.target !== this.element) return null;
564
565    return this.itemTargets[this.indexValue];
566  }
567
568  #dispatchInputChange(input) {
569    input.dispatchEvent(new Event("input", { bubbles: true }));
570    input.dispatchEvent(new Event("change", { bubbles: true }));
571  }
572
573  #stopEvent(event) {
574    if (typeof event.stopImmediatePropagation === "function") {
575      event.stopImmediatePropagation();
576    } else {
577      event.stopPropagation();
578    }
579  }
580
581  #isNestedDropdownButton(item) {
582    return item.hasAttribute("data-dropdown-popover-target");
583  }
584
585  #shouldAutoClose() {
586    const dropdownController = this.#owningDropdownController();
587    if (dropdownController) return dropdownController.autoCloseValue;
588
589    const contextMenuController = this.#owningContextMenuController();
590    if (contextMenuController) return contextMenuController.autoCloseValue;
591
592    return true;
593  }
594
595  #owningDropdownController() {
596    const closestDropdown = this.element.closest('[data-controller~="dropdown-popover"]');
597    const closestController =
598      closestDropdown && this.application.getControllerForElementAndIdentifier(closestDropdown, "dropdown-popover");
599
600    if (closestController) return closestController;
601
602    return this.application.controllers.find(
603      (controller) =>
604        controller.identifier === "dropdown-popover" &&
605        (controller.menuElement === this.element || controller.menuElement?.contains(this.element)),
606    );
607  }
608
609  #owningContextMenuController() {
610    const closestContextMenu = this.element.closest('[data-controller~="context-menu"]');
611    const closestController =
612      closestContextMenu && this.application.getControllerForElementAndIdentifier(closestContextMenu, "context-menu");
613
614    if (closestController) return closestController;
615
616    return this.application.controllers.find(
617      (controller) =>
618        controller.identifier === "context-menu" &&
619        (controller.menuElement === this.element || controller.menuElement?.contains(this.element)),
620    );
621  }
622
623  #dropdownControllerForItem(item) {
624    const dropdown = item.closest('[data-controller~="dropdown-popover"]');
625    const controller = dropdown && this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
626    if (controller) return controller;
627
628    return this.application.controllers.find(
629      (controller) => controller.identifier === "dropdown-popover" && controller.buttonTarget === item,
630    );
631  }
632
633  #parentNestedDropdownController() {
634    const parentDropdown = this.element.closest('[data-dropdown-popover-nested-value="true"]');
635    return parentDropdown && this.application.getControllerForElementAndIdentifier(parentDropdown, "dropdown-popover");
636  }
637
638  #openNestedDropdownControllers() {
639    return Array.from(this.element.querySelectorAll('[data-controller~="dropdown-popover"]'))
640      .map((dropdown) => this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover"))
641      .filter((controller) => controller?.isOpen);
642  }
643
644  get #lastIndex() {
645    return this.itemTargets.length - 1;
646  }
647
648  selectFirst() {
649    this.keyboardNavigation = true;
650    const visibleItems = this.#getVisibleItems();
651    if (visibleItems.length === 0) return;
652
653    this.indexValue = this.itemTargets.indexOf(visibleItems[0]);
654    this.#updateTabstops();
655    this.#focusCurrentItem();
656  }
657
658  selectLast() {
659    this.keyboardNavigation = true;
660    const visibleItems = this.#getVisibleItems();
661    if (visibleItems.length === 0) return;
662
663    this.indexValue = this.itemTargets.indexOf(visibleItems[visibleItems.length - 1]);
664    this.#updateTabstops();
665    this.#focusCurrentItem();
666  }
667
668  #handleTextSearch(key) {
669    this.keyboardNavigation = true;
670    clearTimeout(this.searchTimeout);
671    this.searchString += key;
672
673    const searchStr = this.searchString;
674    const visibleItems = this.#getVisibleItems();
675    const matchedItem = visibleItems.find((item) => {
676      const textElement = item.querySelector(".menu-item-text") || item;
677      return textElement.textContent.trim().toLowerCase().startsWith(searchStr);
678    });
679
680    if (matchedItem) {
681      this.indexValue = this.itemTargets.indexOf(matchedItem);
682      this.#updateTabstops();
683      this.#focusCurrentItem();
684    }
685
686    this.searchTimeout = setTimeout(() => {
687      this.searchString = "";
688    }, 500);
689  }
690
691  // Add public method for external access
692  updateTabstopsWithIndex(newIndex) {
693    this.indexValue = newIndex;
694    this.#updateTabstops();
695  }
696};

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.