1import { Controller } from "@hotwired/stimulus"; 2 3export default class extends Controller { 4 static targets = ["input", "item", "results", "noResults"]; 5 static values = { 6 placeholder: { type: String, default: "Search..." }, 7 noResultsText: { type: String, default: "No results found" }, 8 }; 9 10 connect() { 11 this.originalItems = [...this.itemTargets]; 12 this.selectedIndex = -1; 13 this.keyboardNavigation = false; // Track if user is navigating with keyboard 14 15 this.setupAccessibility(); 16 this.bindEvents(); 17 this.bindHoverEvents(); 18 } 19 20 disconnect() { 21 this.unbindEvents(); 22 this.unbindHoverEvents(); 23 } 24 25 setupAccessibility() { 26 // Set up ARIA attributes for better accessibility 27 if (this.hasInputTarget) { 28 this.inputTarget.setAttribute("role", "combobox"); 29 this.inputTarget.setAttribute("aria-expanded", "false"); 30 this.inputTarget.setAttribute("aria-autocomplete", "list"); 31 this.inputTarget.setAttribute("aria-haspopup", "listbox"); 32 33 // Generate unique IDs if not present 34 if (!this.inputTarget.id) { 35 this.inputTarget.id = `searchable-dropdown-input-${Math.random().toString(36).substring(7)}`; 36 } 37 } 38 39 if (this.hasResultsTarget) { 40 this.resultsTarget.setAttribute("role", "listbox"); 41 if (this.hasInputTarget) { 42 this.resultsTarget.setAttribute("aria-labelledby", this.inputTarget.id); 43 } 44 } 45 46 // Set up items with proper roles and IDs 47 this.itemTargets.forEach((item, index) => { 48 item.setAttribute("role", "option"); 49 item.setAttribute("aria-selected", "false"); 50 if (!item.id) { 51 item.id = `searchable-dropdown-option-${index}-${Math.random().toString(36).substring(7)}`; 52 } 53 }); 54 } 55 56 bindEvents() { 57 // Bind regular event handlers (arrow functions don't need binding) 58 this.handleKeydown = this.handleKeydown.bind(this); 59 this.handleInputFocus = this.handleInputFocus.bind(this); 60 61 if (this.hasInputTarget) { 62 this.inputTarget.addEventListener("keydown", this.handleKeydown); 63 this.inputTarget.addEventListener("focus", this.handleInputFocus); 64 } 65 } 66 67 bindHoverEvents() { 68 this.itemTargets.forEach((item) => { 69 // Use capture phase to intercept events before menu controller 70 item.addEventListener("mouseenter", this.handleItemMouseEnter, true); 71 item.addEventListener("mouseleave", this.handleItemMouseLeave, true); 72 // Reset keyboard navigation when mouse moves 73 item.addEventListener("mousemove", this.handleMouseMove, true); 74 }); 75 76 // Add mouse leave to the results container to clear selection when leaving all items 77 if (this.hasResultsTarget) { 78 this.resultsTarget.addEventListener("mouseleave", this.handleResultsMouseLeave, true); 79 } 80 } 81 82 unbindEvents() { 83 if (this.hasInputTarget) { 84 this.inputTarget.removeEventListener("keydown", this.handleKeydown); 85 this.inputTarget.removeEventListener("focus", this.handleInputFocus); 86 } 87 } 88 89 unbindHoverEvents() { 90 this.itemTargets.forEach((item) => { 91 item.removeEventListener("mouseenter", this.handleItemMouseEnter, true); 92 item.removeEventListener("mouseleave", this.handleItemMouseLeave, true); 93 item.removeEventListener("mousemove", this.handleMouseMove, true); 94 }); 95 96 // Remove results container event listener 97 if (this.hasResultsTarget) { 98 this.resultsTarget.removeEventListener("mouseleave", this.handleResultsMouseLeave, true); 99 } 100 } 101 102 handleInputFocus() { 103 if (this.hasInputTarget) { 104 this.inputTarget.setAttribute("aria-expanded", "true"); 105 } 106 } 107 108 handleItemMouseEnter = (event) => { 109 // Prevent the menu controller from handling this event 110 event.preventDefault(); 111 event.stopPropagation(); 112 113 // Don't interfere with keyboard navigation 114 if (this.keyboardNavigation) { 115 return; 116 } 117 118 const visibleItems = this.getVisibleItems(); 119 const hoveredItem = event.currentTarget; 120 121 // Only update selection if the item is visible 122 if (!hoveredItem.classList.contains("hidden")) { 123 this.selectedIndex = visibleItems.indexOf(hoveredItem); 124 this.updateSelection(visibleItems); 125 } 126 }; 127 128 handleItemMouseLeave = (event) => { 129 // Prevent the menu controller from handling this event 130 event.preventDefault(); 131 event.stopPropagation(); 132 133 // Keep the input focused when leaving an item 134 this.ensureInputFocus(); 135 }; 136 137 handleResultsMouseLeave = (event) => { 138 // During keyboard navigation, scrolling items under a stationary cursor can trigger mouseleave 139 // and we don't want that to clear the selection / "snap" the experience. 140 if (this.keyboardNavigation) { 141 return; 142 } 143 144 // Clear selection when mouse leaves the entire results area 145 this.clearSelection(); 146 this.ensureInputFocus(); 147 }; 148 149 handleMouseMove = (event) => { 150 // Ignore mousemove events where the mouse didn't actually move. 151 // This happens when content scrolls under a stationary cursor (e.g., during keyboard navigation). 152 if (event.movementX === 0 && event.movementY === 0) return; 153 154 // Reset keyboard navigation flag when mouse moves 155 this.keyboardNavigation = false;
156 }; 157 158 handleKeydown(event) { 159 const visibleItems = this.getVisibleItems(); 160 161 switch (event.key) { 162 case "ArrowDown": 163 event.preventDefault(); 164 this.navigateDown(visibleItems); 165 break; 166 case "ArrowUp": 167 event.preventDefault(); 168 this.navigateUp(visibleItems); 169 break; 170 case "Enter": 171 event.preventDefault(); 172 this.selectCurrentItem(visibleItems); 173 break; 174 case "Escape": 175 event.preventDefault(); 176 this.closeDropdown(); 177 break; 178 case "Tab": 179 this.clearSelection(); 180 break; 181 case "Home": 182 event.preventDefault(); 183 this.navigateToFirst(visibleItems); 184 break; 185 case "End": 186 event.preventDefault(); 187 this.navigateToLast(visibleItems); 188 break; 189 } 190 } 191 192 search(event) { 193 // Reset keyboard navigation when user starts typing 194 this.keyboardNavigation = false; 195 196 const query = event.target.value.toLowerCase().trim(); 197 let hasVisibleItems = false; 198 199 this.originalItems.forEach((item) => { 200 const searchText = item.dataset.searchableText || item.textContent.toLowerCase(); 201 const matches = searchText.includes(query); 202 203 if (matches) { 204 item.classList.remove("hidden"); 205 hasVisibleItems = true; 206 } else { 207 item.classList.add("hidden"); 208 } 209 }); 210 211 // Reset selection when searching 212 this.clearSelection(); 213 214 // Show/hide no results message 215 this.toggleNoResults(!hasVisibleItems); 216 217 // Update ARIA live region for screen readers 218 this.announceResults(hasVisibleItems, query); 219 220 // Ensure input stays focused during search 221 this.ensureInputFocus(); 222 } 223 224 getVisibleItems() { 225 return this.itemTargets.filter((item) => !item.classList.contains("hidden")); 226 } 227 228 navigateDown(visibleItems) { 229 if (visibleItems.length === 0) return; 230 231 this.keyboardNavigation = true; 232 this.selectedIndex = Math.min(this.selectedIndex + 1, visibleItems.length - 1); 233 this.updateSelection(visibleItems); 234 } 235 236 navigateUp(visibleItems) { 237 if (visibleItems.length === 0) return; 238 239 this.keyboardNavigation = true; 240 this.selectedIndex = Math.max(this.selectedIndex - 1, -1); 241 this.updateSelection(visibleItems); 242 } 243 244 navigateToFirst(visibleItems) { 245 if (visibleItems.length === 0) return; 246 247 this.keyboardNavigation = true; 248 this.selectedIndex = 0; 249 this.updateSelection(visibleItems); 250 } 251 252 navigateToLast(visibleItems) { 253 if (visibleItems.length === 0) return; 254 255 this.keyboardNavigation = true; 256 this.selectedIndex = visibleItems.length - 1; 257 this.updateSelection(visibleItems); 258 } 259 260 updateSelection(visibleItems) { 261 // Clear all selections 262 this.itemTargets.forEach((item) => { 263 item.setAttribute("aria-selected", "false"); 264 item.classList.remove("bg-neutral-100", "dark:bg-neutral-700/50"); 265 }); 266 267 // Update input aria-activedescendant 268 if (this.hasInputTarget) { 269 if (this.selectedIndex >= 0 && visibleItems[this.selectedIndex]) { 270 const selectedItem = visibleItems[this.selectedIndex]; 271 this.inputTarget.setAttribute("aria-activedescendant", selectedItem.id); 272 selectedItem.setAttribute("aria-selected", "true"); 273 selectedItem.classList.add("bg-neutral-100", "dark:bg-neutral-700/50"); 274 275 // Only scroll item into view for keyboard navigation, not mouse hover 276 if (this.keyboardNavigation) { 277 this.scrollItemIntoView(selectedItem); 278 } 279 280 // Keep focus on input for continuous typing 281 this.inputTarget.focus(); 282 } else { 283 this.inputTarget.removeAttribute("aria-activedescendant"); 284 // Keep focus on input 285 this.inputTarget.focus(); 286 } 287 } 288 } 289 290 scrollItemIntoView(item) { 291 if (this.hasResultsTarget) { 292 const container = this.resultsTarget; 293 const containerRect = container.getBoundingClientRect(); 294 const itemRect = item.getBoundingClientRect(); 295 296 // Calculate the item's position relative to the scrollable content 297 const itemRelativeTop = itemRect.top - containerRect.top; 298 const itemRelativeBottom = itemRelativeTop + itemRect.height; 299 const containerScrollTop = container.scrollTop; 300 const containerHeight = containerRect.height; 301 302 // Define padding: scroll when the item is within this distance from the edge 303 // This ensures we can see approximately one more item beyond the current selection 304 const scrollPadding = itemRect.height + 6; 305 306 // Check if we need to scroll down 307 if (itemRelativeBottom > containerHeight - scrollPadding) { 308 container.scrollTop = containerScrollTop + (itemRelativeBottom - containerHeight + scrollPadding); 309 } 310 // Check if we need to scroll up 311 else if (itemRelativeTop < scrollPadding) { 312 container.scrollTop = containerScrollTop + (itemRelativeTop - scrollPadding); 313 } 314 } 315 } 316 317 selectCurrentItem(visibleItems) { 318 if (this.selectedIndex >= 0 && visibleItems[this.selectedIndex]) { 319 const selectedItem = visibleItems[this.selectedIndex]; 320 this.selectItem(selectedItem); 321 } 322 } 323 324 selectItem(item) { 325 // Dispatch custom event with selected item data 326 const selectEvent = new CustomEvent("searchable-dropdown:select", { 327 detail: { 328 item: item, 329 value: item.dataset.value || item.textContent.trim(), 330 text: item.textContent.trim(), 331 }, 332 bubbles: true, 333 }); 334 335 this.element.dispatchEvent(selectEvent); 336 337 // Optional: Update input with selected value 338 if (this.hasInputTarget) { 339 // You might want to clear the input or set it to the selected value 340 // this.inputTarget.value = item.textContent.trim() 341 } 342 343 // Close dropdown (this will be handled by the dropdown-popover controller) 344 this.closeDropdown(); 345 } 346 347 closeDropdown() { 348 // Update aria-expanded before closing 349 if (this.hasInputTarget) { 350 this.inputTarget.setAttribute("aria-expanded", "false"); 351 } 352 353 // Get the dropdown-popover controller and call its close method. 354 // The searchable controller can live on a menu that is portaled outside the 355 // dropdown wrapper, so find the owner by cached menu element when needed. 356 const dropdownController = this.#dropdownController(); 357 358 if (dropdownController) { 359 dropdownController.close(); 360 } 361 362 this.clearSelection(); 363 } 364 365 clearSelection() { 366 this.selectedIndex = -1; 367 this.keyboardNavigation = false;
368 this.itemTargets.forEach((item) => { 369 item.setAttribute("aria-selected", "false"); 370 item.classList.remove("bg-neutral-100", "dark:bg-neutral-700/50"); 371 }); 372 373 if (this.hasInputTarget) { 374 this.inputTarget.removeAttribute("aria-activedescendant"); 375 } 376 } 377 378 toggleNoResults(show) { 379 if (this.hasNoResultsTarget) { 380 if (show) { 381 this.noResultsTarget.classList.remove("hidden"); 382 } else { 383 this.noResultsTarget.classList.add("hidden"); 384 } 385 } 386 } 387 388 announceResults(hasResults, query) { 389 // Create or update ARIA live region for screen reader announcements 390 let liveRegion = this.element.querySelector("[aria-live]"); 391 if (!liveRegion) { 392 liveRegion = document.createElement("div"); 393 liveRegion.setAttribute("aria-live", "polite"); 394 liveRegion.setAttribute("aria-atomic", "true"); 395 liveRegion.className = "sr-only"; 396 this.element.appendChild(liveRegion); 397 } 398 399 if (query.length > 0) { 400 const visibleCount = this.getVisibleItems().length; 401 if (hasResults) { 402 liveRegion.textContent = `${visibleCount} ${visibleCount === 1 ? "result" : "results"} available`; 403 } else { 404 liveRegion.textContent = "No results found"; 405 } 406 } else { 407 liveRegion.textContent = ""; 408 } 409 } 410 411 // Action method for clicking on items 412 itemClick(event) { 413 event.preventDefault(); 414 event.stopPropagation(); 415 this.selectItem(event.currentTarget); 416 } 417 418 // Reset search when dropdown opens 419 reset() { 420 this.keyboardNavigation = false; 421 422 if (this.hasInputTarget) { 423 this.inputTarget.value = ""; 424 } 425 426 // Show all items 427 this.originalItems.forEach((item) => { 428 item.classList.remove("hidden"); 429 }); 430 431 // Hide no results 432 this.toggleNoResults(false); 433 434 // Clear selection 435 this.clearSelection(); 436 437 // Focus input and ensure it stays focused 438 this.ensureInputFocus(); 439 } 440 441 // Ensure input maintains focus for continuous typing 442 ensureInputFocus() { 443 if (this.hasInputTarget) { 444 // Use setTimeout to ensure focus happens after any other focus changes 445 setTimeout(() => { 446 this.inputTarget.focus(); 447 }, 0); 448 } 449 } 450 451 // Handle when new items are added (Stimulus target callbacks) 452 itemTargetConnected(element) { 453 // Bind hover events to new items with capture phase 454 element.addEventListener("mouseenter", this.handleItemMouseEnter, true); 455 element.addEventListener("mouseleave", this.handleItemMouseLeave, true); 456 element.addEventListener("mousemove", this.handleMouseMove, true); 457 } 458 459 itemTargetDisconnected(element) { 460 // Clean up hover events from removed items 461 element.removeEventListener("mouseenter", this.handleItemMouseEnter, true); 462 element.removeEventListener("mouseleave", this.handleItemMouseLeave, true); 463 element.removeEventListener("mousemove", this.handleMouseMove, true); 464 } 465 466 #dropdownController() { 467 const closestDropdown = this.element.closest('[data-controller~="dropdown-popover"]'); 468 if (closestDropdown) { 469 const controller = this.application.getControllerForElementAndIdentifier(closestDropdown, "dropdown-popover"); 470 if (controller) return controller; 471 } 472 473 return this.application.controllers.find( 474 (controller) => controller.identifier === "dropdown-popover" && controller.menuElement === this.element, 475 ); 476 } 477};
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.