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.