PageSourceSearch

https://railsblocks.com/assets/controllers/autocomplete_controller…312bf08e11db2a7cfd9a62f46df0ad26e0142cd4.js

js railsblocks.com collected 2026-09-25 20:22:42 UTC 55,619 bytes, 1,444 lines download raw bytes

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, "&quot;");
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(/&quot;/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, "&quot;");
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.