1import { Controller } from "@hotwired/stimulus"; 2import { computePosition, offset, flip, shift, autoUpdate } from "@floating-ui/dom"; 3 4export default class extends Controller { 5 static targets = ["input", "results", "suggestion", "clearButton", "searchIcon", "loadingIcon"]; 6 static values = { 7 // Remote data configuration 8 url: String, // URL for fetching suggestions from server 9 minLength: { type: Number, default: 1 }, // Minimum characters before search triggers 10 delay: { type: Number, default: 300 }, // Debounce delay in milliseconds for remote search requests (local searches are immediate, this applies to remote searches only) 11 noResultsText: { type: String, default: "No results found" }, // Text displayed when no suggestions match, if empty, the results container will be closed instead of showing "no results" message 12 loadingText: { type: String, default: "Searching..." }, // Text displayed during loading state 13 separatorText: { type: String, default: "Search Results" }, // Text displayed in separator between local and remote results 14 15 // Results and suggestions 16 maxSuggestions: { type: Number, default: 10 }, // Maximum number of suggestions to display 17 18 // Search behavior 19 highlightMatches: { type: Boolean, default: true }, // Highlight matching text in suggestions 20 autoFill: { type: Boolean, default: false }, // Automatically fill input with selected suggestion during keyboard navigation 21 autoSelectFirst: { type: Boolean, default: false }, // Automatically select the first suggestion when user actively searches (not on focus/empty queries) 22 submitOnSelect: { type: Boolean, default: true }, // Submit form when suggestion is selected 23 clearOnEscape: { type: Boolean, default: true }, // Clear input when Escape key is pressed 24 bubbleEscape: { type: Boolean, default: true }, // Allow Escape key to bubble up to parent elements 25 freeSolo: { type: Boolean, default: true }, // Allow arbitrary values in input (when false, input resets on blur if no suggestion selected) 26 27 // Local data search (alternative to remote URL) 28 localData: { type: Array, default: [] }, // Array of data for client-side search 29 searchKeys: { type: Array, default: ["title", "name", "label", "subtitle", "description", "email"] }, // Object properties to search in for local data 30 matchStrategy: { type: String, default: "contains" }, // "prefix", "contains", "exact" - how to match search queries against local data, prefix will match the beginning of the text, contains will match any part of the text, exact will match the exact text 31 32 // UI behavior 33 searchOnFocus: { type: Boolean, default: false }, // Trigger search when input gains focus (even if empty) 34 placement: { type: String, default: "bottom-start" }, // Floating UI placement for dropdown (e.g., "bottom-start", "top-end") 35 offsetDistance: { type: Number, default: 8 }, // Distance in pixels from reference element 36 anchor: { type: String, default: "input" }, // Element to position the dropdown relative to: "input" or "form" (default: "input") 37 38 // Rendering and styling 39 renderMode: { type: String, default: "popover" }, // "popover" for floating dropdown, "inline" for static positioning 40 alwaysShow: { type: Boolean, default: false }, // Keep results container always visible (useful for command palettes) 41 resultsClass: String, // CSS classes to override default results container styling 42 suggestionClass: String, // Additional CSS classes for each suggestion item 43 selectedClass: String, // CSS classes for selected suggestion item (defaults to "bg-neutral-50 dark:bg-neutral-800 selected", don't forget to include "selected") 44 45 // Interaction behavior 46 closeOnSelect: { type: Boolean, default: true }, // Close dropdown when suggestion is selected 47 closeOnClickOutside: { type: Boolean, default: true }, // Close dropdown when clicking outside 48 preserveSearchOnSelect: { type: Boolean, default: false }, // Preserve search state when selecting suggestions with URLs (don't clear input or close dropdown) 49 50 // Action configuration 51 selectAction: String, // Stimulus action to trigger on selection (e.g., "modal#close:prevent") 52 }; 53 54 connect() { 55 this.selectedIndex = -1; 56 this.isOpen = false;
57 this.searchTimeout = null; 58 this.abortController = null; 59 this.cleanupAutoUpdate = null; 60 this.keyboardNavigating = false; 61 this.lastSearchQuery = null; 62 this.lastSelectedValue = null; // Track the last selected value for freeSolo behavior 63 this.isLoading = false; // Track loading state to prevent flickering 64 65 this.setupInputHandlers(); 66 this.setupKeyboardNavigation(); 67 this.setupClickOutside(); 68 this.setupMouseLeaveHandler(); 69 this.setupBlurHandler(); 70 this.setupFormSubmissionHandler(); 71 this.updateClearButtonVisibility(); 72 73 // Apply optional results container classes when provided (override all classes) 74 if (this.hasResultsTarget && this.hasResultsClassValue && this.resultsClassValue) { 75 this.resultsTarget.className = this.resultsClassValue; 76 } 77 78 // Show results container immediately if alwaysShow is enabled 79 if (this.alwaysShowValue && this.hasResultsTarget) { 80 this.resultsTarget.classList.remove("hidden"); 81 this.resultsTarget.classList.add("block"); 82 83 // Trigger initial search to populate results immediately 84 setTimeout(() => { 85 if (this.urlValue && this.localDataValue.length > 0) { 86 // For hybrid search with empty query, only show local results (respects min-length for remote) 87 this.performHybridSearch(""); 88 } else if (this.urlValue) { 89 // Only fetch remote if min-length allows empty queries 90 if ("".length >= this.minLengthValue) { 91 this.fetchSuggestions(""); 92 } 93 } else { 94 this.searchLocalData(""); 95 } 96 }, 0); 97 } 98 99 // Always add focus listener to handle showing results when input has text 100 this.boundHandleInitialFocus = this.handleInitialFocus.bind(this); 101 this.inputTarget.addEventListener("focus", this.boundHandleInitialFocus); 102 103 // Handle cursor positioning for autofocused inputs with existing text 104 this.handleAutofocusCursorPosition(); 105 106 // Dispatch connect event for customization 107 const event = new CustomEvent("autocomplete:connect", { 108 detail: { controller: this }, 109 bubbles: true, 110 }); 111 document.dispatchEvent(event); 112 } 113 114 disconnect() { 115 if (this.searchTimeout) clearTimeout(this.searchTimeout); 116 if (this.abortController) this.abortController.abort(); 117 if (this.cleanupAutoUpdate) this.cleanupAutoUpdate(); 118 document.removeEventListener("mousedown", this.handleClickOutside); 119 120 // Remove form submission listener 121 if (this.boundHandleFormSubmit) { 122 const form = this.inputTarget.closest("form"); 123 if (form) { 124 form.removeEventListener("submit", this.boundHandleFormSubmit); 125 } 126 } 127 128 // Remove keyboard navigation listeners 129 if (this.boundHandleKeydown) { 130 this.inputTarget.removeEventListener("keydown", this.boundHandleKeydown); 131 if (this.hasResultsTarget) { 132 this.resultsTarget.removeEventListener("keydown", this.boundHandleKeydown); 133 } 134 } 135 136 // Remove focus and blur listeners 137 if (this.boundHandleInitialFocus) { 138 this.inputTarget.removeEventListener("focus", this.boundHandleInitialFocus); 139 } 140 if (this.boundHandleBlur) { 141 this.inputTarget.removeEventListener("blur", this.boundHandleBlur); 142 } 143 144 // Remove mousedown listener from results container 145 if (this.boundPreventFocusLoss && this.hasResultsTarget) { 146 this.resultsTarget.removeEventListener("mousedown", this.boundPreventFocusLoss); 147 } 148 } 149 150 setupInputHandlers() { 151 // Store bound handler for potential removal by extension controllers 152 this.boundHandleInput = this.handleInput.bind(this); 153 this.inputTarget.addEventListener("input", this.boundHandleInput); 154 this.inputTarget.setAttribute("autocomplete", "off"); 155 this.inputTarget.setAttribute("spellcheck", "false"); 156 } 157 158 setupKeyboardNavigation() { 159 // Store bound method references for cleanup 160 this.boundHandleKeydown = this.handleKeydown.bind(this); 161 162 this.inputTarget.addEventListener("keydown", this.boundHandleKeydown); 163 164 // Add keyboard navigation to results container when it's focu
164sed 165 if (this.hasResultsTarget) { 166 this.resultsTarget.addEventListener("keydown", this.boundHandleKeydown); 167 } 168 } 169 170 setupClickOutside() { 171 this.handleClickOutside = (event) => { 172 // Check if click is outside the entire autocomplete element 173 if (!this.element.contains(event.target)) { 174 // Don't close if alwaysShow is enabled - command palettes should stay open 175 if (this.closeOnClickOutsideValue && !this.alwaysShowValue) { 176 this.close(); 177 } 178 } 179 }; 180 if (this.closeOnClickOutsideValue) { 181 document.addEventListener("mousedown", this.handleClickOutside); 182 } 183 } 184 185 setupBlurHandler() { 186 this.boundHandleBlur = this.handleBlur.bind(this); 187 this.inputTarget.addEventListener("blur", this.boundHandleBlur); 188 } 189 190 setupFormSubmissionHandler() { 191 const form = this.inputTarget.closest("form"); 192 if (form) { 193 this.boundHandleFormSubmit = this.handleFormSubmit.bind(this); 194 form.addEventListener("submit", this.boundHandleFormSubmit); 195 } 196 } 197 198 setupMouseLeaveHandler() { 199 if (this.hasResultsTarget) { 200 this.resultsTarget.addEventListener("mouseleave", () => { 201 // When mouse leaves results area during keyboard navigation, 202 // ensure hover states are cleared 203 if (this.keyboardNavigating) { 204 this.clearHoverStates(); 205 } 206 }); 207 208 this.resultsTarget.addEventListener("mouseenter", () => { 209 // When mouse re-enters, allow normal hover behavior again 210 // But only reset keyboard navigation if we're not actively using keyboard 211 // This allows seamless transition between mouse and keyboard 212 if (!this.keyboardNavigating) { 213 this.updateKeyboardNavigationClass(); 214 } 215 }); 216 217 // Prevent input from losing focus when clicking inside results container 218 this.boundPreventFocusLoss = (event) => { 219 // Prevent the default behavior that would cause input to lose focus 220 event.preventDefault(); 221 }; 222 this.resultsTarget.addEventListener("mousedown", this.boundPreventFocusLoss); 223 } 224 } 225 226 handleBlur() { 227 // Cancel any pending search timeouts when losing focus (unless alwaysShow is enabled) 228 if (!this.alwaysShowValue && this.searchTimeout) { 229 clearTimeout(this.searchTimeout); 230 this.searchTimeout = null; 231 // Reset loading state if we were loading 232 this.isLoading = false; 233 } 234 235 // Reset keyboard navigation state and selection when input loses focus 236 // so a new interaction starts from the top. 237 this.keyboardNavigating = false; 238 this.selectedIndex = -1; 239 240 // If freeSolo is false and the current input value doesn't match any selected suggestion, 241 // reset the input to empty or the last selected value 242 if (!this.freeSoloValue && this.inputTarget.value.trim() !== "") { 243 const currentValue = this.inputTarget.value.trim(); 244 245 // Check if the current value matches the last selected value 246 if (this.lastSelectedValue !== currentValue) { 247 // Reset to empty or last selected value 248 this.inputTarget.value = this.lastSelectedValue || ""; 249 this.updateClearButtonVisibility(); 250 } 251 } 252 } 253 254 handleInitialFocus() { 255 const currentValue = this.inputTarget.value.trim(); 256 257 if (currentValue) { 258 // If the query hasn't changed and results container has content, just reopen it 259 if (currentValue === this.lastSearchQuery && this.resultsTarget.innerHTML.trim() !== "") { 260 this.open(); 261 } else { 262 // Query has changed, trigger new search 263 this.handleInput({ target: this.inputTarget }); 264 } 265 } else { 266 if (this.searchOnFocusValue) { 267 // If search on focus is enabled, trigger a search with empty query 268 if (this.urlValue && this.localDataValue.length > 0) { 269 // For hybrid search with empty query, only show local results (respects min-length for remote) 270 this.performHybridSearch(""); 271 } else if (this.urlValue) { 272 // Only fetch remote if min-length allows empty queries 273 if ("".length >= this.minLengthValue) { 274 // Show loading state immediately for remote searches on focus 275 this.showLoading(); 276 // Then fetch with delay, but make it cancelable 277 this.searchTimeout = setTimeout(() => { 278 // Only proceed if input is still empty (user hasn't started typing) 279 if (this.inputTarget.value.trim() === "") { 280 this.fetchSuggestions(""); 281 } 282 }, this.delayValue); 283 } 284 } else { 285 this.searchLocalData(""); 286 } 287 } 288 } 289 } 290 291 handleAutofocusCursorPosition() { 292 // If the input has autofocus and contains text, position cursor at the end 293 const hasAutofocus = this.inputTarget.hasAttribute("autofocus"); 294 const hasText = this.inputTarget.value.length > 0; 295 296 if (hasAutofocus && hasText) { 297 // Use setTimeout to ensure this runs after the browser's autofocus behavior 298 setTimeout(() => { 299 const length = this.inputTarget.value.length; 300 this.inputTarget.setSelectionRange(length, length); 301 }, 0); 302 } 303 } 304 305 handleInput(event) { 306 const query = event.target.value.trim(); 307 308 this.updateClearButtonVisibility(); 309 310 // Clear last query if current query is different 311 if (query !== this.lastSearchQuery) { 312 this.lastSearchQuery = null; 313 } 314 315 // Reset keyboard navigation state when new input triggers search 316 this.keyboardNavigating = false;
317 this.updateKeyboardNavigationClass(); 318 319 // When freeSolo is disabled, only enforce minLength for remote-only mode. 320 // If local data exists, allow showing local results even with empty/short queries. 321 if ( 322 !this.freeSoloValue && 323 this.urlValue && 324 this.localDataValue.length === 0 && 325 query.length < this.minLengthValue 326 ) { 327 this.close(); 328 return; 329 } 330 331 // Handle local-only searches immediately (no delay needed) 332 if (!this.urlValue) { 333 this.searchLocalData(query); 334 return; 335 } 336 337 // Clear existing timeout for remote searches 338 if (this.searchTimeout) clearTimeout(this.searchTimeout); 339 340 // Respect minLength for remote-only searches (hybrid path handles this inside performHybridSearch) 341 const isHybrid = this.localDataValue.length > 0; 342 if (!isHybrid && query.length < this.minLengthValue) { 343 // Too short for remote; close the dropdown (or reset when alwaysShow is true) 344 this.isLoading = false; 345 this.close(); 346 return; 347 } 348 349 // Show loading state for remote searches only if not already loading 350 if (!this.isLoading) { 351 this.showLoading(); 352 } 353 354 // Debounce only remote searches 355 this.searchTimeout = setTimeout(() => { 356 // Check if the input value has changed since this timeout was scheduled 357 const currentQuery = this.inputTarget.value.trim(); 358 if (currentQuery !== query) { 359 // Input has changed, don't execute this outdated search 360 // The new input value should have already triggered its own handler 361 return; 362 } 363 364 if (this.localDataValue.length > 0) { 365 // Hybrid search: perform both local and remote searches 366 this.performHybridSearch(query); 367 } else { 368 // Pure remote search 369 this.fetchSuggestions(query); 370 } 371 }, this.delayValue); 372 } 373 374 handleKeydown(event) { 375 if (!this.isOpen) { 376 if (event.key === "ArrowDown" || event.key === "ArrowUp") { 377 const query = this.inputTarget.value.trim(); 378 const hasLocal = this.localDataValue.length > 0; 379 const remoteRequiresMore = 380 this.urlValue && !hasLocal && this.minLengthValue > 0 && query.length < this.minLengthValue; 381 if (remoteRequiresMore) return; 382 383 // Open the dropdown (and fetch if needed) but do not pre-select 384 event.preventDefault(); 385 this.keyboardNavigating = true; // prevent any autoSelectFirst behavior 386 this.updateKeyboardNavigationClass(); 387 this.handleInput({ target: this.inputTarget }); 388 return; 389 } else if (event.key !== "Escape") { 390 return; 391 } 392 } 393 394 // If the results container is focused and navigation keys are pressed,
395 // ensure the input gets focus first for consistent behavior 396 if (event.target === this.resultsTarget && ["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) { 397 this.inputTarget.focus(); 398 } 399 400 switch (event.key) { 401 case "ArrowDown": 402 event.preventDefault(); 403 this.keyboardNavigating = true; 404 this.updateKeyboardNavigationClass(); 405 this.navigateDown(); 406 break; 407 case "ArrowUp": 408 event.preventDefault(); 409 this.keyboardNavigating = true; 410 this.updateKeyboardNavigationClass(); 411 this.navigateUp(); 412 break; 413 case "Enter": 414 event.preventDefault(); 415 this.selectCurrent(); 416 break; 417 case "Escape": 418 if (!this.bubbleEscapeValue) { 419 event.preventDefault(); 420 } 421 if (this.clearOnEscapeValue && this.inputTarget.value) { 422 this.clearInput(); 423 } else { 424 this.close(); 425 } 426 break; 427 case "Tab": 428 if (!this.alwaysShowValue) { 429 this.close(); 430 } 431 break; 432 } 433 } 434 435 async fetchSuggestions(query) { 436 // Cancel previous request 437 if (this.abortController) this.abortController.abort(); 438 this.abortController = new AbortController(); 439 440 try { 441 const url = new URL(this.urlValue, window.location.origin); 442 url.searchParams.set("q", query); 443 444 const response = await fetch(url, { 445 signal: this.abortController.signal, 446 headers: { 447 Accept: "application/json", 448 }, 449 }); 450 451 if (!response.ok) throw new Error("Network response was not ok"); 452 453 const data = await response.json(); 454 this.displaySuggestions(data.suggestions || data, query); 455 } catch (error) { 456 if (error.name !== "AbortError") { 457 console.error("Autocomplete fetch error:", error); 458 this.showError(); 459 } 460 } 461 } 462 463 async performHybridSearch(query) { 464 // Get local results immediately 465 const localResults = this.getLocalResults(query); 466 467 // Check if query meets minimum length requirement for remote search 468 const shouldFetchRemote = query.length >= this.minLengthValue; 469 470 if (!shouldFetchRemote) { 471 // Only show local results if query is too short for remote search 472 this.displaySuggestions(localResults, query); 473 return; 474 } 475 // For hybrid searches, don't show local results immediately - wait for remote 476 // This keeps the loading state visible until we have all results 477 478 // Cancel previous request 479 if (this.abortController) this.abortController.abort(); 480 this.abortController = new AbortController(); 481 482 try { 483 const url = new URL(this.urlValue, window.location.origin); 484 url.searchParams.set("q", query); 485 486 const response = await fetch(url, { 487 signal: this.abortController.signal, 488 headers: { 489 Accept: "application/json", 490 }, 491 }); 492 493 if (!response.ok) throw new Error("Network response was not ok"); 494 495 const data = await response.json(); 496 const remoteResults = data.suggestions || data; 497 498 // Combine results: local first, then remote 499 this.displayHybridSuggestions(localResults, remoteResults, query); 500 } catch (error) { 501 if (error.name !== "AbortError") { 502 console.error("Autocomplete fetch error:", error); 503 // Show local results even if remote fails 504 this.displaySuggestions(localResults, query); 505 } 506 } 507 } 508 509 getLocalResults(query) { 510 const filtered = this.localDataValue.filter((item) => { 511 // Handle simple strings 512 if (typeof item === "string") { 513 return this.matchesQuery(item, query); 514 } 515 516 // Handle objects with search keys 517 return this.searchKeysValue.some((key) => { 518 const value = item[key]; 519 return value && this.matchesQuery(value.toString(), query); 520 }); 521 }); 522 523 const sorted = this.sortSuggestionsByPrefixPriority(filtered, query); 524 return sorted.slice(0, this.maxSuggestionsValue); 525 } 526 527 searchLocalData(query) { 528 // Reuse getLocalResults to avoid duplicated filtering logic 529 const suggestions = this.getLocalResults(query); 530 this.displaySuggestions(suggestions, query); 531 } 532 533 displaySuggestions(suggestions, query) { 534 // Track the last search query 535 this.lastSearchQuery = query; 536 537 if (!suggestions || suggestions.length === 0) { 538 this.showNoResults(); 539 return; 540 } 541 542 this.isLoading = false; // End loading state 543 const sorted = this.sortSuggestionsByPrefixPriority(suggestions, query); 544 const html = this.renderSuggestions(sorted, query, 0); 545 this.resultsTarget.innerHTML = html; 546 this.open(); 547 548 // Auto-select first suggestion if enabled, not currently keyboard navigating, and user is actively searching 549 if (this.autoSelectFirstValue && suggestions.length > 0 && !this.keyboardNavigating && query && query.length > 0) { 550 this.selectedIndex = 0; 551 this.updateSelection(); 552 } 553 } 554
555 displayHybridSuggestions(localResults, remoteResults, query) { 556 // Track the last search query 557 this.lastSearchQuery = query; 558 559 if ((!localResults || localResults.length === 0) && (!remoteResults || remoteResults.length === 0)) { 560 this.showNoResults(); 561 return; 562 } 563 564 this.isLoading = false; // End loading state 565 let html = ""; 566 let indexOffset = 0; 567 568 // Add local results first 569 if (localResults && localResults.length > 0) { 570 const sortedLocal = this.sortSuggestionsByPrefixPriority(localResults, query); 571 html += this.renderSuggestions(sortedLocal, query, indexOffset); 572 indexOffset += sortedLocal.length; 573 } 574 575 // Add separator if we have both local and remote results 576 if (localResults && localResults.length > 0 && remoteResults && remoteResults.length > 0) { 577 html += this.renderSeparator(); 578 } 579 580 // Add remote results 581 if (remoteResults && remoteResults.length > 0) { 582 const sortedRemote = this.sortSuggestionsByPrefixPriority(remoteResults, query); 583 html += this.renderSuggestions(sortedRemote, query, indexOffset); 584 } 585 586 this.resultsTarget.innerHTML = html; 587 this.open(); 588 589 // Auto-select first suggestion if enabled, we have any results, not currently keyboard navigating, and user is actively searching 590 const totalResults = (localResults?.length || 0) + (remoteResults?.length || 0); 591 if (this.autoSelectFirstValue && totalResults > 0 && !this.keyboardNavigating && query && query.length > 0) { 592 this.selectedIndex = 0; 593 this.updateSelection(); 594 } 595 } 596 597 renderSuggestions(suggestions, query, startIndex = 0) { 598 return suggestions 599 .map((suggestion, index) => { 600 const actualIndex = startIndex + index; 601 const text = 602 typeof suggestion === "string" ? suggestion : suggestion.title || suggestion.name || suggestion.label; 603 const subtitle = suggestion.subtitle || suggestion.email || suggestion.description; 604 605 const displayText = this.highlightMatchesValue ? this.highlightText(text, query) : text; 606 const displaySubtitle = subtitle && this.highlightMatchesValue ? this.highlightText(subtitle, query) : subtitle; 607 const icon = suggestion.icon || "item"; 608 const customIconHtml = suggestion.customIcon; 609 const badge = suggestion.badge; 610 611 const iconSvg = customIconHtml || this.getIconSvg(icon); 612 613 // Ensure we have a proper object for JSON serialization 614 const dataForJson = typeof suggestion === "string" ? { title: suggestion } : suggestion; 615 616 // Properly encode JSON for HTML attribute 617 const jsonData = JSON.stringify(dataForJson).replace(/"/g, """); 618 619 const extraSuggestionClasses = 620 this.hasSuggestionClassValue && this.suggestionClassValue ? ` ${this.suggestionClassValue}` : ""; 621 return ` 622 <button type="button" 623 class="outline-none w-full text-left px-4 py-3 flex items-center gap-3 group${extraSuggestionClasses}" 624 data-autocomplete-target="suggestion" 625 data-action="click->autocomplete#selectSuggestion mousedown->autocomplete#applySelectionStyling mousemove->autocomplete#handleMouseMove" 626 data-value="${this.escapeHtml(text)}" 627 data-suggestion-data="${jsonData}" 628 data-index="${actualIndex}" 629 role="option" 630 tabindex="-1" 631 ${actualIndex === this.selectedIndex ? 'aria-selected="true"' : ""}> 632 ${iconSvg} 633 <div class="flex-grow"> 634 <div class="flex items-center gap-2 justify-between"> 635 <span class="text-sm text-neutral-700 dark:text-neutral-300">${displayText}</span> 636 ${badge ? `<span class="inline-flex">${badge}</span>` : ""} 637 </div> 638 ${ 639 displaySubtitle 640 ? `<div class="text-xs text-neutral-500 dark:text-neutral-400">${displaySubtitle}</div>` 641 : "" 642 } 643 </div> 644 </button> 645 `; 646 }) 647 .join(""); 648 } 649 650 renderSeparator() { 651 return ` 652 <div class="px-4 pt-3 pb-1.5 border-t border-neutral-200 dark:border-neutral-700"> 653 <div class="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wide"> 654 ${this.separatorTextValue} 655 </div> 656 </div> 657 `; 658 } 659 660 showLoading() { 661 this.isLoading = true; 662 this.resultsTarget.innerHTML = ` 663 <div class="px-4 py-8 text-center"> 664 <div class="inline-flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400"> 665 <svg class="animate-spin size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> 666 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 667 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 668 </svg> 669 ${this.loadingTextValue} 670 </div> 671 </div> 672 `; 673 this.open(); 674 } 675 676 showNoResults() { 677 this.isLoading = false; // End loading state 678 679 // If noResultsText is empty, close the results container instead of showing "no results" message 680 if (!this.noResultsTextValue || this.noResultsTextValue.trim() === "") { 681 this.close(); 682 return; 683 } 684 685 this.resultsTarget.innerHTML = ` 686 <div class="px-4 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"> 687 ${this.noResultsTextValue} 688 </div> 689 `; 690 this.open(); 691 } 692 693 showError() { 694 this.isLoading = false; // End loading state 695 this.resultsTarget.innerHTML = ` 696 <div class="px-4 py-8 text-center text-sm text-red-500 dark:text-red-400"> 697 An error occurred. Please try again. 698 </div> 699 `; 700 this.open(); 701 } 702 703 highlightText(text, query) { 704 // Regular highlighting for exact matches 705 const regex = new RegExp(`(${this.escapeRegex(query)})`, "gi"); 706 return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-400 font-medium">$1</mark>'); 707 } 708 709 navigateDown() { 710 const suggestions = this.suggestionTargets; 711 if (suggestions.length === 0) return; 712 713 // Wrap to the first item when moving down from the last item 714 if (this.selectedIndex >= suggestions.length - 1) { 715 this.selectedIndex = 0; 716 } else { 717 this.selectedIndex = this.selectedIndex + 1; 718 } 719 this.updateSelection(); 720 } 721 722 navigateUp() { 723 const suggestions = this.suggestionTargets; 724 if (suggestions.length === 0) return; 725 726 // If nothing is selected or we're at the first item, wrap to the last item 727 if (this.selectedIndex <= 0) { 728 this.selectedIndex = suggestions.length - 1; 729 } else { 730 this.selectedIndex = this.selectedIndex - 1; 731 } 732 this.updateSelection(); 733 } 734 735 updateSelection() { 736 // Get selected classes, with defaults if not specified 737 const selectedClasses = 738 this.hasSelectedClassValue && this.selectedClassValue 739 ? this.selectedClassValue.split(" ").filter((cls) => cls.trim()) 740 : ["bg-neutral-50", "dark:bg-neutral-800", "selected"]; 741
742 this.suggestionTargets.forEach((suggestion, index) => { 743 const trailingArrow = suggestion.querySelector("svg.arrow"); 744 if (index === this.selectedIndex) { 745 suggestion.classList.add(...selectedClasses); 746 suggestion.setAttribute("aria-selected", "true"); 747 if (trailingArrow) { 748 trailingArrow.classList.add("opacity-100"); 749 } 750 // Only scroll when navigating with keyboard, not when hovering with mouse 751 if (this.keyboardNavigating) { 752 this.scrollToSuggestion(suggestion); 753 } 754 } else { 755 suggestion.classList.remove(...selectedClasses); 756 suggestion.setAttribute("aria-selected", "false"); 757 if (trailingArrow) { 758 trailingArrow.classList.remove("opacity-100"); 759 } 760 } 761 }); 762 763 // Update input with selected value if autoFill is enabled 764 if (this.autoFillValue && this.selectedIndex >= 0) { 765 const selectedSuggestion = this.suggestionTargets[this.selectedIndex]; 766 this.inputTarget.value = selectedSuggestion.dataset.value; 767 this.updateClearButtonVisibility(); 768 } 769 } 770 771 scrollToSuggestion(suggestionEl) { 772 const container = this.resultsTarget; 773 if (!container) return; 774 775 const containerRect = container.getBoundingClientRect(); 776 const suggestionRect = suggestionEl.getBoundingClientRect(); 777 778 // Calculate the suggestion's position relative to the scrollable container 779 // Use getBoundingClientRect to get accurate positions relative to viewport 780 const suggestionRelativeTop = suggestionRect.top - containerRect.top; 781 const suggestionRelativeBottom = suggestionRelativeTop + suggestionRect.height; 782 const containerScrollTop = container.scrollTop; 783 const containerHeight = containerRect.height; 784 785 // Define padding: scroll when the suggestion is within this distance from the edge 786 const scrollPadding = suggestionRect.height * 1; 787 788 // Check if we need to scroll down 789 if (suggestionRelativeBottom > containerHeight - scrollPadding) { 790 // Scroll so the suggestion is visible with padding below 791 container.scrollTop = containerScrollTop + (suggestionRelativeBottom - containerHeight + scrollPadding); 792 } 793 // Check if we need to scroll up 794 else if (suggestionRelativeTop < scrollPadding) { 795 // Scroll so the suggestion is visible with padding above 796 container.scrollTop = containerScrollTop + (suggestionRelativeTop - scrollPadding); 797 } 798 } 799 800 selectCurrent() { 801 if (this.selectedIndex >= 0 && this.suggestionTargets[this.selectedIndex]) { 802 this.selectSuggestion({ currentTarget: this.suggestionTargets[this.selectedIndex] }); 803 } else if (this.inputTarget.value.trim()) { 804 // If freeSolo is enabled, allow submitting arbitrary values 805 if (this.freeSoloValue) { 806 this.lastSelectedValue = this.inputTarget.value.trim(); // Track the arbitrary value 807 this.submitSearch(this.inputTarget.value.trim()); 808 } else { 809 // When freeSolo is disabled, only submit if the input matches a valid option 810 if (this.isValidOption(this.inputTarget.value.trim())) { 811 this.submitSearch(this.inputTarget.value.trim()); 812 } 813 // If no valid option and freeSolo is disabled, do nothing (prevent submission) 814 } 815 } 816 } 817 818 selectSuggestion(event) { 819 const value = event.currentTarget.dataset.value; 820 821 let suggestionData = {}; 822 try { 823 const dataString = event.currentTarget.dataset.suggestionData; 824 if (dataString && dataString.trim() !== "") { 825 // Decode HTML entities back to quotes 826 const decodedData = dataString.replace(/"/g, '"'); 827 suggestionData = JSON.parse(decodedData); 828 } 829 } catch (error) { 830 console.warn("Failed to parse suggestion data:", error); 831 suggestionData = { title: value }; 832 } 833 834 // Update input based on URL presence and preserveSearchOnSelect setting 835 const hasUrl = suggestionData.url || suggestionData.route || suggestionData.href; 836 const shouldUpdateInput = !hasUrl || (hasUrl && !this.preserveSearchOnSelectValue); 837 if (shouldUpdateInput) { 838 this.inputTarget.value = value; 839 this.lastSelectedValue = value; // Track the selected value for freeSolo behavior 840 this.updateClearButtonVisibility(); 841 } 842 843 // Determine if we should close - respect preserveSearchOnSelect for URL items 844 const shouldClose = this.closeOnSelectValue && !(hasUrl && this.preserveSearchOnSelectValue); 845 if (shouldClose) this.close(); 846 847 // Execute configured action if specified 848 this.executeConfiguredAction(suggestionData); 849 850 // Navigate to URL if present (automatic behavior) 851 this.navigateToUrl(suggestionData); 852 853 // Dispatch custom event with full data 854 this.dispatch("select", { 855 detail: { 856 value, 857 data: suggestionData, 858 }, 859 }); 860 861 // Submit if enabled 862 if (this.submitOnSelectValue) { 863 this.submitSearch(value); 864 } 865 } 866 867 applySelectionStyling(event) { 868 // Apply visual styling immediately on mousedown for better UX 869 const clickedSuggestion = event.currentTarget; 870 const index = parseInt(clickedSuggestion.dataset.index); 871 872 if (!isNaN(index)) { 873 // Update the selected index 874 this.selectedIndex = index; 875 // Apply the visual styling immediately 876 this.updateSelection(); 877 } 878 } 879 880 submitSearch(value) { 881 // Find the parent form and submit it 882 const form = this.inputTarget.closest("form"); 883 if (form) { 884 // Dispatch custom event before submitting 885 this.dispatch("submit", { detail: { value } }); 886 form.submit(); 887 } 888 } 889 890 navigateToUrl(suggestionData) { 891 // Check if the selected item has a URL/route to navigate to 892 const targetUrl = suggestionData.url || suggestionData.route || suggestionData.href; 893 894 if (targetUrl) { 895 // Navigate to the actual URL/route 896 if (targetUrl.startsWith("http") || targetUrl.startsWith("//")) { 897 // External URL - open in same window 898 window.location.href = targetUrl; 899 } else { 900 // Internal route - use Turbo if available, otherwise regular navigation 901 if (window.Turbo && window.Turbo.visit) { 902 window.Turbo.visit(targetUrl); 903 } else { 904 window.location.href = targetUrl; 905 } 906 } 907 } 908 // If no URL is provided, do nothing - just stay in the current state 909 } 910 911 clearInput() { 912 this.inputTarget.value = ""; 913 this.close(); 914 this.updateClearButtonVisibility();
915 this.inputTarget.focus(); 916 917 // Clear last query and selected value 918 this.lastSearchQuery = null; 919 this.lastSelectedValue = null; 920 } 921 922 updateClearButtonVisibility() { 923 if (this.hasClearButtonTarget) { 924 if (this.inputTarget.value) { 925 this.clearButtonTarget.classList.remove("hidden"); 926 this.clearButtonTarget.classList.add("flex"); 927 } else { 928 this.clearButtonTarget.classList.add("hidden"); 929 this.clearButtonTarget.classList.remove("flex"); 930 } 931 } 932 } 933 934 handleFormSubmit(event) { 935 // Only validate if freeSolo is disabled and this input has a value 936 if (!this.freeSoloValue && this.inputTarget.value.trim() && !this.isValidOption(this.inputTarget.value.trim())) { 937 event.preventDefault(); 938 return false; 939 } 940 } 941 942 isValidOption(value) { 943 // Check if the value matches any local data exactly 944 if (this.localDataValue.length > 0) { 945 return this.localDataValue.some((item) => { 946 const itemValue = typeof item === "string" ? item : item.title || item.name || item.label; 947 return itemValue && itemValue.toLowerCase() === value.toLowerCase(); 948 }); 949 } 950 951 // For remote data, allow submission (can't validate without request) 952 return this.urlValue ? true : false; 953 } 954 955 async open() { 956 if (!this.isOpen) { 957 this.isOpen = true; 958 this.selectedIndex = -1; 959 960 if (this.renderModeValue === "popover") { 961 if (!this.alwaysShowValue) { 962 this.resultsTarget.classList.remove("hidden"); 963 this.resultsTarget.classList.add("block"); 964 } 965 966 // Position instantly with Floating UI (no animation for positioning) 967 await this.positionResults(); 968 969 // Add animation classes only for opacity (not positioning) 970 requestAnimationFrame(() => { 971 this.resultsTarget.classList.add("opacity-100"); 972 this.resultsTarget.classList.remove("opacity-0"); 973 }); 974 } else { 975 // Inline mode: ensure container is visible; do not set positioning 976 if (!this.alwaysShowValue) { 977 this.resultsTarget.classList.remove("hidden", "opacity-0"); 978 } 979 } 980 } 981 } 982 983 async positionResults() { 984 if (this.renderModeValue === "inline") return; // No floating/positioning in inline mode 985 if (this.cleanupAutoUpdate) this.cleanupAutoUpdate(); 986 987 const referenceEl = this.anchorValue === "form" ? this.element : this.inputTarget; 988 989 this.cleanupAutoUpdate = autoUpdate(referenceEl, this.resultsTarget, async () => { 990 const { x, y, placement } = await computePosition(referenceEl, this.resultsTarget, { 991 placement: this.placementValue, 992 middleware: [ 993 offset(this.offsetDistanceValue), 994 flip({ 995 fallbackPlacements: ["bottom-start", "top-start", "bottom-end", "top-end"], 996 }), 997 shift({ padding: 8 }), 998 ], 999 }); 1000 1001 Object.assign(this.resultsTarget.style, { 1002 left: `${x}px`, 1003 top: `${y}px`, 1004 position: "absolute", 1005 }); 1006 1007 // Update classes based on placement for better styling 1008 this.resultsTarget.classList.toggle("placement-top", placement.startsWith("top")); 1009 this.resultsTarget.classList.toggle("placement-bottom", placement.startsWith("bottom")); 1010 }); 1011 } 1012 1013 close() { 1014 if (!this.isOpen) return; 1015 1016 // Reset loading state when closing 1017 this.isLoading = false; 1018 1019 // Reset selection so next open starts fresh 1020 this.selectedIndex = -1; 1021 1022 // If alwaysShow is enabled, don't actually close - just reset to initial state 1023 if (this.alwaysShowValue) { 1024 // Reset to initial search state instead of closing 1025 if (this.urlValue && this.localDataValue.length > 0) { 1026 // For hybrid search with empty query, only show local results (respects min-length for remote) 1027 this.performHybridSearch(""); 1028 } else if (this.urlValue) { 1029 // Only fetch remote if min-length allows empty queries 1030 if ("".length >= this.minLengthValue) { 1031 this.fetchSuggestions(""); 1032 } 1033 } else { 1034 this.searchLocalData(""); 1035 } 1036 return; 1037 } 1038 1039 if (this.renderModeValue === "popover") { 1040 // Clean up positioning 1041 if (this.cleanupAutoUpdate) { 1042 this.cleanupAutoUpdate(); 1043 this.cleanupAutoUpdate = null; 1044 } 1045 1046 // Remove animation classes (only opacity, not positioning) 1047 this.resultsTarget.classList.remove("opacity-100"); 1048 this.resultsTarget.classList.add("opacity-0"); 1049 1050 // Hide after animation 1051 setTimeout(() => { 1052 this.resultsTarget.classList.add("hidden"); 1053 this.resultsTarget.classList.remove("block"); 1054 this.isOpen = false;
1055 1056 // Reset positioning styles 1057 this.resultsTarget.style.left = ""; 1058 this.resultsTarget.style.top = ""; 1059 this.resultsTarget.style.position = ""; 1060 this.resultsTarget.style.width = ""; 1061 }, 150); 1062 } else { 1063 // Inline mode: clear content and hide container 1064 this.resultsTarget.innerHTML = ""; 1065 this.resultsTarget.classList.add("hidden"); 1066 this.isOpen = false; 1067 } 1068 } 1069 1070 getIconSvg(icon) { 1071 const iconMap = { 1072 folder: 1073 '<svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M2.25,8.75V4.75c0-1.105,.895-2,2-2h1.951c.607,0,1.18,.275,1.56,.748l.603,.752h5.386c1.105,0,2,.895,2,2v2.844"></path><path d="M4.25,6.75H13.75c1.105,0,2,.895,2,2v4.5c0,1.105-.895,2-2,2H4.25c-1.105,0-2-.895-2-2v-4.5c0-1.105,.895-2,2-2Z"></path></g></svg>', 1074 file: '<svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><line x1="5.75" y1="6.75" x2="7.75" y2="6.75"></line><line x1="5.75" y1="9.75" x2="12.25" y2="9.75"></line><line x1="5.75" y1="12.75" x2="12.25" y2="12.75"></line><path d="M2.75,14.25V3.75c0-1.105,.895-2,2-2h5.586c.265,0,.52,.105,.707,.293l3.914,3.914c.188,.188,.293,.442,.293,.707v7.586c0,1.105-.895,2-2,2H4.75c-1.105,0-2-.895-2-2Z"></path><path d="M15.16,6.25h-3.41c-.552,0-1-.448-1-1V1.852"></path></g></svg>', 1075 search: 1076 '<svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M15.25,16c-.192,0-.384-.073-.53-.22l-3.965-3.965c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l3.965,3.965c.293,.293,.293,.768,0,1.061-.146,.146-.338,.22-.53,.22Z"></path><path d="M7.75,13.5c-3.17,0-5.75-2.58-5.75-5.75S4.58,2,7.75,2s5.75,2.58,5.75,5.75-2.58,5.75-5.75,5.75Zm0-10c-2.343,0-4.25,1.907-4.25,4.25s1.907,4.25,4.25,4.25,4.25-1.907,4.25-4.25-1.907-4.25-4.25-4.25Z"></path></g></svg>', 1077 recent: 1078 '<svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><circle cx="9" cy="9" r="7.25"></circle><polyline points="9 5.5 9 9 11.5 11.5"></polyline></g></svg>', 1079 item: "", // If no icon is provided, the default is an empty string 1080 }; 1081 1082 return iconMap[icon] || iconMap["item"]; 1083 } 1084 1085 escapeHtml(text) { 1086 const div = document.createElement("div"); 1087 div.textContent = text; 1088 return div.innerHTML; 1089 } 1090 1091 escapeRegex(string) { 1092 return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 1093 } 1094 1095 matchesQuery(text, query, strategy = this.matchStrategyValue) { 1096 if (!query || query.trim() === "") return true; 1097 1098 const lowerText = text.toLowerCase(); 1099 const lowerQuery = query.toLowerCase().trim(); 1100 1101 switch (strategy) { 1102 case "prefix": 1103 return lowerText.startsWith(lowerQuery); 1104 case "exact": 1105 return lowerText === lowerQuery; 1106 case "contains": 1107 default: 1108 return lowerText.includes(lowerQuery); 1109 } 1110 } 1111 1112 // Determine priority for sorting: exact prefix > prefix > contains > none 1113 computeMatchPriorityForText(text, query) { 1114 if (!query || query.trim() === "") return 3; 1115 const lowerText = (text || "").toLowerCase(); 1116 const lowerQuery = query.toLowerCase().trim(); 1117 if (lowerText === lowerQuery) return 0; 1118 if (lowerText.startsWith(lowerQuery)) return 1; 1119 if (lowerText.includes(lowerQuery)) return 2; 1120 return 3; 1121 } 1122 1123 // Returns best priority across the suggestion's searchable fields 1124 computeSuggestionPriority(suggestion, query) { 1125 if (!query || query.trim() === "") return 3; 1126 if (typeof suggestion === "string") { 1127 return this.computeMatchPriorityForText(suggestion, query); 1128 } 1129 let best = 3; 1130 for (const key of this.searchKeysValue) { 1131 const value = suggestion && suggestion[key]; 1132 if (!value) continue; 1133 const priority = this.computeMatchPriorityForText(String(value), query); 1134 if (priority < best) best = priority; 1135 if (best === 0) break; // can't get better than exact 1136 } 1137 return best; 1138 } 1139 1140 // Stable sort by prefix priority while preserving original order for ties 1141 sortSuggestionsByPrefixPriority(suggestions, query) { 1142 if (!Array.isArray(suggestions) || !query || query.trim() === "") return suggestions; 1143 return suggestions 1144 .map((item, index) => ({ item, index, priority: this.computeSuggestionPriority(item, query) })) 1145 .sort((a, b) => { 1146 if (a.priority !== b.priority) return a.priority - b.priority; 1147 return a.index - b.index; 1148 }) 1149 .map((x) => x.item); 1150 } 1151 1152 executeConfiguredAction(suggestionData) { 1153 if (!this.selectActionValue) return; 1154 1155 // Check if the suggestion has its own action override 1156 const actionToExecute = suggestionData.action || this.selectActionValue; 1157 1158 try { 1159 // Parse the action string (e.g., "modal#close:prevent") 1160 const [controllerAction, ...modifiers] = actionToExecute.split(":"); 1161 const [controllerName, actionName] = controllerAction.split("#"); 1162 1163 if (!controllerName || !actionName) { 1164 console.warn('Invalid action format. Expected format: "controller#action" or "controller#action:modifier"'); 1165 return; 1166 } 1167 1168 // Find the target controller 1169 let targetController = this.application.getControllerForElementAndIdentifier(this.element, controllerName); 1170 1171 if (!targetController) {
1172 // Look for controller in parent elements 1173 let currentElement = this.element.parentElement; 1174 1175 while (currentElement && !targetController) { 1176 targetController = this.application.getControllerForElementAndIdentifier(currentElement, controllerName); 1177 currentElement = currentElement.parentElement; 1178 } 1179 1180 if (!targetController) { 1181 console.warn(`Controller "${controllerName}" not found for action "${actionToExecute}"`); 1182 return; 1183 } 1184 } 1185 1186 // Execute the action 1187 if (typeof targetController[actionName] === "function") { 1188 // Create a fake event object that includes our suggestion data 1189 const fakeEvent = { 1190 preventDefault: () => {}, 1191 stopPropagation: () => {}, 1192 detail: { suggestionData }, 1193 type: "autocomplete:select", 1194 }; 1195 1196 // Check for prevent modifier 1197 if (modifiers.includes("prevent")) { 1198 fakeEvent.preventDefault(); 1199 } 1200 1201 targetController[actionName](fakeEvent); 1202 } else { 1203 console.warn(`Action "${actionName}" not found on controller "${controllerName}"`); 1204 } 1205 } catch (error) { 1206 console.error("Error executing configured action:", error); 1207 } 1208 } 1209 1210 updateKeyboardNavigationClass() { 1211 if (this.hasResultsTarget && this.resultsTarget) { 1212 if (this.keyboardNavigating) { 1213 this.resultsTarget.classList.add("keyboard-navigating"); 1214 } else { 1215 this.resultsTarget.classList.remove("keyboard-navigating"); 1216 } 1217 } 1218 } 1219 1220 clearHoverStates() { 1221 // Remove hover states from all suggestions when keyboard navigating 1222 // and mouse leaves the results area, but preserve selected state 1223 this.suggestionTargets.forEach((suggestion, index) => { 1224 // Don't touch the selected item's styling 1225 if (index === this.selectedIndex) return; 1226 1227 // Clear arrow opacity for non-selected items only 1228 const trailingArrow = suggestion.querySelector("svg.arrow"); 1229 if (trailingArrow) { 1230 trailingArrow.classList.remove("opacity-100"); 1231 } 1232 }); 1233 } 1234 1235 handleMouseMove(event) { 1236 // Ignore mousemove events where the mouse didn't actually move. 1237 // This happens when content scrolls under a stationary cursor (e.g., during keyboard navigation). 1238 // The browser fires mousemove events on elements that pass under the cursor, but movementX/Y will be 0. 1239 if (event.movementX === 0 && event.movementY === 0) { 1240 return; 1241 } 1242 1243 // Reset keyboard navigation when mouse is used 1244 this.keyboardNavigating = false; 1245 this.updateKeyboardNavigationClass(); 1246 1247 // Update selected index based on mouse position 1248 const suggestionElement = event.currentTarget; 1249 const index = parseInt(suggestionElement.dataset.index); 1250 if (!isNaN(index)) { 1251 this.selectedIndex = index; 1252 this.updateSelection(); 1253 } 1254 } 1255} 1256 1257// Base class for autocomplete extension controllers 1258export class BaseAutocompleteExtension extends Controller { 1259 connect() { 1260 // Find the main autocomplete controller 1261 // Wait a tick to ensure all controllers are connected 1262 setTimeout(() => { 1263 this.autocompleteController = this.findAutocompleteController(); 1264 1265 if (!this.autocompleteController) { 1266 console.warn(`${this.constructor.name} requires an autocomplete controller on the same element`); 1267 return; 1268 } 1269 1270 this.originalMethods = new Map(); 1271 this.initialize(); 1272 }, 100); 1273 } 1274 1275 disconnect() { 1276 this.restoreOriginalMethods(); 1277 } 1278 1279 // Override this method in subclasses 1280 initialize() { 1281 // Subclasses should implement their initialization logic here 1282 } 1283 1284 findAutocompleteController() { 1285 const controllers = this.application.controllers.filter( 1286 (controller) => controller.element === this.element && controller.identifier === "autocomplete", 1287 ); 1288 return controllers[0]; 1289 } 1290 1291 // Helper method to override methods safely 1292 overrideMethod(methodName, newMethod) { 1293 if (this.autocompleteController[methodName]) { 1294 // Store original method 1295 this.originalMethods.set(methodName, this.autocompleteController[methodName].bind(this.autocompleteController)); 1296 // Override with new method 1297 this.autocompleteController[methodName] = newMethod.bind(this); 1298 } 1299 } 1300 1301 // Helper method to call original method 1302 callOriginal(methodName, ...args) { 1303 const originalMethod = this.originalMethods.get(methodName); 1304 if (originalMethod) { 1305 return originalMethod(...args); 1306 } 1307 } 1308 1309 // Restore all overridden methods 1310 restoreOriginalMethods() { 1311 if (!this.autocompleteController) return; 1312 1313 this.originalMethods.forEach((originalMethod, methodName) => { 1314 this.autocompleteController[methodName] = originalMethod; 1315 }); 1316 this.originalMethods.clear(); 1317 } 1318 1319 // Shared suggestion rendering with customizable options 1320 renderSuggestion(suggestion, query, index, options = {}) { 1321 const { 1322 startIndex = 0, 1323 showParentIndicator = false, 1324 showRemoveButton = false, 1325 customClasses = "", 1326 customIcon = null, 1327 } = options; 1328 1329 const actualIndex = startIndex + index; 1330 const text = typeof suggestion === "string" ? suggestion : suggestion.title || suggestion.name || suggestion.label; 1331 const subtitle = suggestion.subtitle || suggestion.email || suggestion.description; 1332 const isParent = suggestion.children !== undefined; 1333 const isRecent = suggestion.isRecent || false; 1334 1335 const displayText = this.autocompleteController.highlightMatchesValue 1336 ? this.autocompleteController.highlightText(text, query) 1337 : text; 1338 const displaySubtitle = 1339 subtitle && this.autocompleteController.highlightMatchesValue 1340 ? this.autocompleteController.highlightText(subtitle, query) 1341 : subtitle; 1342 1343 // Determine icon 1344 let icon; 1345 if (customIcon) { 1346 icon = customIcon; 1347 } else if (suggestion.icon) { 1348 icon = suggestion.icon; 1349 } else if (isParent) { 1350 icon = "folder"; 1351 } else if (isRecent) { 1352 icon = "recent"; 1353 } else { 1354 icon = "item"; 1355 } 1356 1357 const customIconHtml = suggestion.customIcon; 1358 const badge = suggestion.badge; 1359 const iconSvg = customIconHtml || this.autocompleteController.getIconSvg(icon); 1360 1361 // Ensure we have a proper object for JSON serialization 1362 const dataForJson = typeof suggestion === "string" ? { title: suggestion } : suggestion; 1363 const jsonData = JSON.stringify(dataForJson).replace(/"/g, """); 1364 1365 const extraSuggestionClasses = 1366 this.autocompleteController.hasSuggestionClassValue && this.autocompleteController.suggestionClassValue 1367 ? ` ${this.autocompleteController.suggestionClassValue}` 1368 : ""; 1369 1370 const parentAttributes = showParentIndicator && isParent ? `data-is-parent="true"` : ""; 1371 const removeButtonSpace = showRemoveButton && isRecent ? "pr-12" : ""; 1372 1373 const removeButton = 1374 showRemoveButton && isRecent 1375 ? ` 1376 <button type="button" 1377 class="absolute right-2.5 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 p-1 hover:bg-neutral-200 text-neutral-400 hover:text-neutral-500 dark:text-neutral-500 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 rounded-full" 1378 data-action="click->
1378autocomplete-recent-searches#removeRecentSearch" 1379 data-search="${this.autocompleteController.escapeHtml(text)}" 1380 onclick="event.stopPropagation()" 1381 tabindex="-1"> 1382 <svg xmlns="http://www.w3.org/2000/svg" class="size-3.5" width="12" height="12" viewBox="0 0 12 12"> 1383 <g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"> 1384 <line x1="9" y1="3" x2="3" y2="9"></line> 1385 <line x1="3" y1="3" x2="9" y2="9"></line> 1386 </g> 1387 </svg> 1388 </button> 1389 ` 1390 : ""; 1391 1392 return ` 1393 <div class="relative group"> 1394 <button type="button" 1395 class="outline-none w-full text-left px-4 py-3 flex items-center gap-3 ${removeButtonSpace} group${extraSuggestionClasses}${customClasses}" 1396 data-autocomplete-target="suggestion" 1397 data-action="click->autocomplete#selectSuggestion mousedown->autocomplete#applySelectionStyling mousemove->autocomplete#handleMouseMove" 1398 data-value="${this.autocompleteController.escapeHtml(text)}" 1399 data-suggestion-data="${jsonData}" 1400 data-index="${actualIndex}" 1401 ${parentAttributes} 1402 role="option" 1403 tabindex="-1" 1404 ${actualIndex === this.autocompleteController.selectedIndex ? 'aria-selected="true"' : ""}> 1405 ${iconSvg} 1406 <div class="flex-grow"> 1407 <div class="flex items-center gap-2 justify-between"> 1408 <span class="text-sm text-neutral-700 dark:text-neutral-300">${displayText}</span> 1409 ${badge ? `<span class="inline-flex">${badge}</span>` : ""} 1410 </div> 1411 ${ 1412 displaySubtitle 1413 ? `<div class="text-xs text-neutral-500 dark:text-neutral-400">${displaySubtitle}</div>` 1414 : "" 1415 } 1416 </div> 1417 </button> 1418 ${removeButton} 1419 </div> 1420 `; 1421 } 1422 1423 // Helper method for rendering multiple suggestions 1424 renderSuggestions(suggestions, query, startIndex = 0, options = {}) { 1425 return suggestions 1426 .map((suggestion, index) => 1427 this.renderSuggestion(suggestion, query, index, { 1428 ...options, 1429 startIndex, 1430 }), 1431 ) 1432 .join(""); 1433 } 1434 1435 // Helper method to manage event listeners 1436 replaceEventListener(target, eventType, oldHandler, newHandler) { 1437 if (target && oldHandler) { 1438 target.removeEventListener(eventType, oldHandler); 1439 } 1440 if (target && newHandler) { 1441 target.addEventListener(eventType, newHandler); 1442 } 1443 } 1444};
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.