PageSourceSearch

https://railsblocks.com/assets/controllers/autocomplete_navigation…0c3d3f01e30b00d40955db7a3d3766fcdb19a7fc.js

js railsblocks.com collected 2026-09-25 20:22:45 UTC 38,878 bytes, 1,014 lines download raw bytes

1import { BaseAutocompleteExtension } from "controllers/autocomplete_controller";
2
3export default class extends BaseAutocompleteExtension {
4  static targets = ["breadcrumb"];
5  static values = {
6    breadcrumb: { type: Boolean, default: false },
7  };
8
9  // This controller is specifically for hierarchical navigation
10  // If you don't need hierarchical behavior, don't add this controller
11
12  initialize() {
13    // Initialize hierarchical state
14    this.currentPath = [];
15    this.currentPathIcons = []; // Track icons for each level
16
17    // Check if autocomplete controller is available before proceeding
18    if (!this.autocompleteController) {
19      return;
20    }
21
22    this.originalLocalData = [...this.autocompleteController.localDataValue];
23    this.originalPlaceholder = this.autocompleteController.inputTarget.placeholder;
24
25    // Override methods for hierarchical navigation
26    this.overrideMethod("handleInput", this.handleInputWithHierarchy);
27    this.overrideMethod("handleInitialFocus", this.handleInitialFocusWithHierarchy);
28    this.overrideMethod("searchLocalData", this.searchLocalDataWithHierarchy);
29    this.overrideMethod("selectSuggestion", this.selectSuggestionWithHierarchy);
30    this.overrideMethod("displaySuggestions", this.displaySuggestionsWithHierarchy);
31    this.overrideMethod("updateClearButtonVisibility", this.updateClearButtonVisibilityWithHierarchy);
32    this.overrideMethod("close", this.closeWithHierarchy);
33
34    // Override hybrid search methods if they exist
35    if (this.autocompleteController.performHybridSearch) {
36      this.overrideMethod("performHybridSearch", this.performHybridSearchWithHierarchy);
37    }
38
39    if (this.autocompleteController.displayHybridSuggestions) {
40      this.overrideMethod("displayHybridSuggestions", this.displayHybridSuggestionsWithHierarchy);
41    }
42
43    if (this.autocompleteController.getLocalResults) {
44      this.overrideMethod("getLocalResults", this.getLocalResultsWithHierarchy);
45    }
46
47    // Handle input event listener replacement
48    // Use the bound handler reference that was stored during setupInputHandlers
49    this.boundHandleInputWithHierarchy = this.handleInputWithHierarchy.bind(this);
50    this.replaceEventListener(
51      this.autocompleteController.inputTarget,
52      "input",
53      this.autocompleteController.boundHandleInput,
54      this.boundHandleInputWithHierarchy,
55    );
56
57    // Handle focus event listener replacement
58    this.boundHandleInitialFocusWithHierarchy = this.handleInitialFocusWithHierarchy.bind(this);
59    this.replaceEventListener(
60      this.autocompleteController.inputTarget,
61      "focus",
62      this.autocompleteController.boundHandleInitialFocus,
63      this.boundHandleInitialFocusWithHierarchy,
64    );
65
66    // Add our own keydown listener for hierarchical navigation
67    this.setupHierarchicalKeydownHandler();
68
69    // Initialize breadcrumb if enabled
70    this.updateBreadcrumb();
71
72    // If alwaysShow is enabled, trigger initial search after setup
73    if (this.autocompleteController.alwaysShowValue) {
74      setTimeout(() => {
75        // Always start with local search for initial display in hierarchical mode
76        this.searchLocalDataWithHierarchy("");
77      }, 50);
78    }
79  }
80
81  setupHierarchicalKeydownHandler() {
82    // Add our own keydown listener that handles backspace specifically
83    // This runs before the base controller's handler due to event order
84    this.hierarchicalKeydownHandler = (event) => {
85      if (
86        event.key === "Backspace" &&
87        this.autocompleteController.inputTarget.value === "" &&
88        this.currentPath.length > 0
89      ) {
90        event.preventDefault();
91        event.stopPropagation();
92        this.navigateBack();
93        return;
94      }
95    };
96
97    this.autocompleteController.inputTarget.addEventListener("keydown", this.hierarchicalKeydownHandler, true);
98  }
99
100  disconnect() {
101    // Remove our hierarchical keydown handler
102    if (this.hierarchicalKeydownHandler && this.autocompleteController?.inputTarget) {
103      this.autocompleteController.inputTarget.removeEventListener("keydown", this.hierarchicalKeydownHandler, true);
104    }
105
106    // Restore input event listener
107    this.replaceEventListener(
108      this.autocompleteController?.inputTarget,
109      "input",
110      this.boundHandleInputWithHierarchy,
111      this.autocompleteController?.boundHandleInput,
112    );
113
114    // Restore focus event listener
115    this.replaceEventListener(
116      this.autocompleteController?.inputTarget,
117      "focus",
118      this.boundHandleInitialFocusWithHierarchy,
119      this.autocompleteController?.boundHandleInitialFocus,
120    );
121
122    super.disconnect();
123  }
124
125  handleInputWithHierarchy(event) {
126    const query = event.target.value.trim();
127
128    this.updateClearButtonVisibilityWithHierarchy();
129
130    // Clear last query if current query is different
131    if (query !== this.autocompleteController.lastSearchQuery) {
132      this.autocompleteController.lastSearchQuery = null;
133    }
134
135    // Reset keyboard navigation state when new input triggers search
136    this.autocompleteController.keyboardNavigating = false;
137    this.autocompleteController.updateKeyboardNavigationClass();
138
139    // Get group config once for this entire method
140    const remoteConfig = this.getCurrentGroupRemoteConfig();
141    // Respect explicit 0 for minLength; only fall back when undefined
142    const minLength =
143      remoteConfig && remoteConfig.minLength !== undefined
144        ? remoteConfig.minLength
145        : this.autocompleteController.minLengthValue;
146
147    if (query.length < minLength) {
148      if (query.length === 0) {
149        // For hierarchical mode, show current level items when input is empty
150        this.searchLocalDataWithHierarchy("");
151      } else {
152        // Only close if alwaysShow is not enabled
153        if (!this.autocompleteController.alwaysShowValue) {
154          this.autocompleteController.close();
155        }
156      }
157      return;
158    }
159
160    // Handle local-only searches immediately (no delay needed)
161    if (!remoteConfig?.url && !this.autocompleteController.urlValue) {
162      this.searchLocalDataWithHierarchy(query);
163      return;
164    }
165
166    // Clear existing timeout for remote searches
167    if (this.autocompleteController.searchTimeout) clearTimeout(this.autocompleteController.searchTimeout);
168
169    // Show loading state for remote searches only if not already loading
170    if (remoteConfig && remoteConfig.url && !this.autocompleteController.isLoading) {
171      this.showLoadingWithGroupConfig();
172    } else if (this.autocompleteController.urlValue && !this.autocompleteController.isLoading) {
173      this.autocompleteController.showLoading();
174    }
175
176    // Debounce only remote searches using group-specific delay or fall back to controller delay
177    const delayValue = remoteConfig?.delay || this.autocompleteController.delayValue;
178
179    this.autocompleteController.searchTimeout = setTimeout(() => {
180      // Check if the input value has changed since this timeout was scheduled
181      const currentQuery = this.autocompleteController.inputTarget.value.trim();
182      if (currentQuery !== query) {
183        // Input has changed, don't execute this outdated search
184        // The new input value should have already triggered its own handler
185        return;
186      }
187
188      if (remoteConfig && remoteConfig.url) {
189        // Any group with remote config should use performHybridSearchWithHierarchy
190        this.performHybridSearchWithHierarchy(query);
191      } else if (this.autocompleteController.urlValue) {
192        // Fallback to global remote search if no group config
193        this.autocompleteController.fetchSuggestions(query);
194      } else {
195        // Local-only search
196        this.searchLocalDataWithHierarchy(query);
197      }
198    }, delayValue);
199  }
200
201  handleInitialFocusWithHierarchy() {
202    const currentValue = this.autocompleteController.inputTarget.value.trim();
203
204    if (currentValue) {
205      // If the query hasn't changed and results container has content, just reopen it
206      if (
207        currentValue === this.autocompleteController.lastSearchQuery &&
208        this.autocompleteController.resultsTarget.innerHTML.trim() !== ""
209      ) {
210        this.autocompleteController.open();
211      } else {
212        // Query has changed, trigger new search
213        this.handleInputWithHierarchy({ target: this.autocompleteController.inputTarget });
214      }
215    } else {
216      // Check for remote search configuration for current group
217      const remoteConfig = this.getCurrentGroupRemoteConfig();
218
219      // Determine if we should trigger remote search based on group config or global config
220      const shouldTriggerRemoteSearch =
221        remoteConfig &&
222        remoteConfig.url &&
223        (remoteConfig.searchOnFocus === true ||
224          (this.autocompleteController.searchOnFocusValue && remoteConfig.minLength === 0));
225
226      if (shouldTriggerRemoteSearch) {
227        // Show loading state first
228        this.showLoadingWithGroupConfig();
229
230        // Cancel any existing search timeout
231        if (this.autocompleteController.searchTimeout) {
232          clearTimeout(this.autocompleteController.searchTimeout);
233        }
234
235        // Trigger remote search with delay
236        const delay = remoteConfig.delay || this.autocompleteController.delayValue;
237        this.autocompleteController.searchTimeout = setTimeout(() => {
238          // Only proceed if input is still empty and dropdown is open
239          if (this.autocompleteController.inputTarget.value.trim() === "" && this.autocompleteController.isOpen) {
240            this.performHybridSearchWithHierarchy("");
241          }
242        }, delay);
243      } else {
244        // For hierarchical mode without remote search, show current level items
245        this.searchLocalDataWithHierarchy("");
246      }
247    }
248  }
249
250  searchLocalDataWithHierarchy(query) {
251    const currentData = this.getCurrentLevelData();
252
253    // For hierarchical navigation, show all current level items when query is empty
254    if (!query || query.trim() === "") {
255      const suggestions = currentData.slice(0, this.autocompleteController.maxSuggestionsValue);
256      this.displaySuggestionsWithHierarchy(suggestions, query);
257      return;
258    }
259
260    // If not hierarchical or query 
260is present, use core's matching logic
261    const suggestions = currentData
262      .filter((item) => {
263        // Handle simple strings
264        if (typeof item === "string") {
265          return this.autocompleteController.matchesQuery(item, query);
266        }
267
268        // Handle objects with search keys
269        return this.autocompleteController.searchKeysValue.some((key) => {
270          const value = item[key];
271          return value && this.autocompleteController.matchesQuery(value.toString(), query);
272        });
273      })
274      .slice(0, this.autocompleteController.maxSuggestionsValue);
275
276    this.displaySuggestionsWithHierarchy(suggestions, query);
277  }
278
279  async performHybridSearchWithHierarchy(query) {
280    // Get local results immediately using hierarchical data
281    const localResults = this.getLocalResultsWithHierarchy(query);
282
283    // Check if query meets minimum length requirement for remote search
284    const remoteConfig = this.getCurrentGroupRemoteConfig();
285    const minLength =
286      remoteConfig?.minLength !== undefined ? remoteConfig.minLength : this.autocompleteController.minLengthValue;
287    const shouldFetchRemote = query.length >= minLength;
288
289    if (!shouldFetchRemote) {
290      // Only show local results if query is too short for remote search
291      this.displaySuggestionsWithHierarchy(localResults, query);
292      return;
293    }
294
295    // If no remote config for current group, only show local results
296    if (!remoteConfig || !remoteConfig.url) {
297      this.displaySuggestionsWithHierarchy(localResults, query);
298      return;
299    }
300    // For hybrid searches, don't show local results immediately - wait for remote
301    // This keeps the loading state visible until we have all results
302
303    // Cancel previous request safely
304    if (this.autocompleteController.abortController && !this.autocompleteController.abortController.signal.aborted) {
305      try {
306        this.autocompleteController.abortController.abort();
307      } catch (error) {
308        // Ignore abort errors
309      }
310    }
311    this.autocompleteController.abortController = new AbortController();
312
313    try {
314      const url = new URL(remoteConfig.url, window.location.origin);
315      url.searchParams.set("q", query);
316
317      const response = await fetch(url, {
318        signal: this.autocompleteController.abortController.signal,
319        headers: {
320          Accept: "application/json",
321        },
322      });
323
324      if (!response.ok) throw new Error("Network response was not ok");
325
326      const data = await response.json();
327      const remoteResults = data.suggestions || data;
328
329      // Combine results: local first, then remote
330      this.displayHybridSuggestionsWithHierarchy(localResults, remoteResults, query);
331    } catch (error) {
332      // Silently handle AbortError - this is expected when requests are cancelled
333      if (error.name === "AbortError") {
334        return; // Just return, don't show any results or errors
335      }
336
337      console.error("Autocomplete fetch error:", error);
338      // Show local results even if remote fails
339      this.displaySuggestionsWithHierarchy(localResults, query);
340    }
341  }
342
343  getLocalResultsWithHierarchy(query) {
344    const currentData = this.getCurrentLevelData();
345
346    // For hierarchical navigation, show all current level items when query is empty
347    if (!query || query.trim() === "") {
348      return currentData.slice(0, this.autocompleteController.maxSuggestionsValue);
349    }
350
351    // If not hierarchical or query is present, use core's matching logic
352    return currentData
353      .filter((item) => {
354        // Handle simple strings
355        if (typeof item === "string") {
356          return this.autocompleteController.matchesQuery(item, query);
357        }
358
359        // Handle objects with search keys
360        return this.autocompleteController.searchKeysValue.some((key) => {
361          const value = item[key];
362          return value && this.autocompleteController.matchesQuery(value.toString(), query);
363        });
364      })
365      .slice(0, this.autocompleteController.maxSuggestionsValue);
366  }
367
368  displayHybridSuggestionsWithHierarchy(localResults, remoteResults, query
368) {
369    // Track the last search query
370    this.autocompleteController.lastSearchQuery = query;
371
372    if ((!localResults || localResults.length === 0) && (!remoteResults || remoteResults.length === 0)) {
373      this.showNoResultsWithGroupConfig();
374      return;
375    }
376
377    this.autocompleteController.isLoading = false; // End loading state
378    let html = "";
379    let indexOffset = 0;
380
381    // Add local results first (hierarchical)
382    if (localResults && localResults.length > 0) {
383      html += this.renderSuggestionsWithHierarchy(localResults, query, indexOffset);
384      indexOffset += localResults.length;
385    }
386
387    // Add separator if we have both local and remote results
388    if (localResults && localResults.length > 0 && remoteResults && remoteResults.length > 0) {
389      html += this.renderSeparatorWithGroupConfig();
390    }
391
392    // Add remote results (non-hierarchical but will use overridden selectSuggestion)
393    if (remoteResults && remoteResults.length > 0) {
394      html += this.autocompleteController.renderSuggestions(remoteResults, query, indexOffset);
395    }
396
397    this.autocompleteController.resultsTarget.innerHTML = html;
398    this.autocompleteController.open();
399
400    // Check if we should highlight a previously selected item
401    this.highlightPreviousSelection();
402
403    // Auto-select first suggestion if enabled, we have any results, not currently keyboard navigating, and user is actively searching
404    const totalResults = (localResults?.length || 0) + (remoteResults?.length || 0);
405    if (
406      this.autocompleteController.autoSelectFirstValue &&
407      totalResults > 0 &&
408      !this.autocompleteController.keyboardNavigating &&
409      query &&
410      query.length > 0
411    ) {
412      this.autocompleteController.selectedIndex = 0;
413      this.autocompleteController.updateSelection();
414    }
415
416    // Scroll to top for better UX in hybrid search results
417    this.scrollResultsToTop();
418  }
419
420  renderSuggestionsWithHierarchy(suggestions, query, startIndex = 0) {
421    return this.renderSuggestions(suggestions, query, startIndex, {
422      showParentIndicator: true,
423    });
424  }
425
426  getCurrentLevelData() {
427    if (this.currentPath.length === 0) {
428      return this.autocompleteController.localDataValue;
429    }
430
431    // Navigate to current path in hierarchical data
432    let currentLevel = this.originalLocalData;
433    for (const pathItem of this.currentPath) {
434      const parentItem = currentLevel.find(
435        (item) => (typeof item === "string" ? item : item.title || item.name || item.label) === pathItem,
436      );
437      if (parentItem && parentItem.children) {
438        currentLevel = parentItem.children;
439      } else {
440        return [];
441      }
442    }
443    return currentLevel;
444  }
445
446  displaySuggestionsWithHierarchy(suggestions, query) {
447    // Track the last search query
448    this.autocompleteController.lastSearchQuery = query;
449
450    if (!suggestions || suggestions.length === 0) {
451      this.showNoResultsWithGroupConfig();
452      return;
453    }
454
455    this.autocompleteController.isLoading = false; // End loading state
456    const html = this.renderSuggestions(suggestions, query, 0, {
457      showParentIndicator: true,
458    });
459
460    this.autocompleteController.resultsTarget.innerHTML = html;
461    this.autocompleteController.open();
462
463    // Check if we should highlight a previously selected item
464    this.highlightPreviousSelection();
465
466    // Auto-select first suggestion if enabled, not currently keyboard navigating, and user is actively searching
467    if (
468      this.autocompleteController.autoSelectFirstValue &&
469      suggestions.length > 0 &&
470      !this.autocompleteController.keyboardNavigating &&
471      query &&
472      query.length > 0
473    ) {
474      this.autocompleteController.selectedIndex = 0;
475      this.autocompleteController.updateSelection();
476    }
477
478    // Scroll to top for better UX when displaying new hierarchical level
479    this.scrollResultsToTop();
480  }
481
482  selectSuggestionWithHierarchy(event) {
483    const value = event.currentTarget.dataset.value;
484    const isParent = event.currentTarget.dataset.isParent === "true";
485
486    let suggestionData = {};
487    try {
488      const dataString = event.currentTarget.dataset.suggestionData;
489      if (dataString && dataString.trim() !== "") {
490        // Decode HTML entities back to quotes
491        const decodedData = dataString.replace(/&quot;/g, '"');
492        suggestionData = JSON.parse(decodedData);
493      }
494    } catch (error) {
495      console.warn("Failed to parse suggestion data:", error);
496      suggestionData = { title: value };
497    }
498
499    if (isParent) {
500      // Prevent the click from bubbling up and triggering click-outside (only for real events)
501      if (event && event.stopPropagation) {
502        event.stopPropagation();
503        event.preventDefault();
504      }
505      // Navigate into parent/folder - keep dropdown open
506      this.navigateInto(suggestionData);
507      return;
508    }
509
510    // Update input based on URL presence and preserveSearchOnSelect setting (for non-parent items)
511    const hasUrl = suggestionData.url || suggestionData.route || suggestionData.href;
512    const shouldUpdateInput = !hasUrl || (hasUrl && !this.autocompleteController.preserveSearchOnSelectValue);
513    if (shouldUpdateInput) {
514      this.autocompleteController.inputTarget.value = value;
515      this.updateClearButtonVisibilityWithHierarchy();
516    }
517
518    // Only close if closeOnSelect is enabled AND we're not selecting a URL (or preserveSearchOnSelect is enabled)
519    // For command palettes, we usually want to keep them open when selecting URLs
520    const shouldClose =
521      this.autocompleteController.closeOnSelectValue &&
522      !(hasUrl && this.autocompleteController.preserveSearchOnSelectValue);
523    if (shouldClose) this.autocompleteController.close();
524
525    // Store the selected value for later highlighting
526    this.autocompleteController.lastSelectedValue = value;
527
528    // Execute configured action if specified
529    this.autocompleteController.executeConfiguredAction(suggestionData);
530
531    // Navigate to URL if present (automatic behavior)
532    this.autocompleteController.navigateToUrl(suggestionData);
533
534    // Dispatch custom event with full data
535    this.autocompleteController.dispatch("select", {
536      detail: {
537        value,
538        data: suggestionData,
539        path: this.currentPath,
540      },
541    });
542
543    // Submit if enabled
544    if (this.autocompleteController.submitOnSelectValue) {
545      this.autocompleteController.submitSearch(value);
546    }
547  }
548
549  navigateInto(parentItem) {
550    const parentTitle =
551      typeof parentItem === "string" ? parentItem : parentItem.title || parentItem.name || parentItem.label;
552    this.currentPath.push(parentTitle);
553
554    // Track the icon for this level
555    const iconData = this.getIconDataForItem(parentItem);
556    this.currentPathIcons.push(iconData);
557
558    // Update placeholder if the group has a custom placeholder
559    if (parentItem.placeholder) {
560      this.autocompleteController.inputTarget.placeholder = parentItem.placeholder;
561    }
562
563    // Clear input and show children
564    this.autocompleteController.inputTarget.value = "";
565    this.updateClearButtonVisibilityWithHierarchy();
566
567    // Clear last query and selected value since we're changing context
568    this.autocompleteController.lastSearchQuery = null;
569    this.autocompleteController.lastSelectedValue = null;
570
571    // Reset loading state when navigating to a new group
572    this.autocompleteController.isLoading = false;
573
574    // Reset selection index
575    this.autocompleteController.selectedIndex = -1;
576
577    // Update breadcrumb
578    this.updateBreadcrumb();
579
580    // Check if we should trigger remote search for empty query
581    const remoteConfig = this.getCurrentGroupRemoteConfig();
582    const shouldTriggerRemoteSearch =
583      remoteConfig &&
584      remoteConfig.url &&
585      (remoteConfig.minLength === 0 || remoteConfig.minLength === undefined || remoteConfig.searchOnFocus === true);
586
587    if (shouldTriggerRemoteSearch) {
588      // Show loading state first for better UX
589      if (remoteConfig && remoteConfig.url) {
590        this.showLoadingWithGroupConfig();
591      }
592
593      // Cancel any existing search timeout
594      if (this.autocompleteController.searchTimeout) {
595        clearTimeout(this.autocompleteController.searchTimeout);
596      }
597
598      // Trigger remote search with empty query, respecting delay but making it cancelable
599      const delay = remoteConfig?.delay || this.autocompleteController.delayValue;
600      this.autocompleteController.searchTimeout = setTimeout(() => {
601        // Only proceed if input is still empty (user hasn't started typing)
602        // and we're still in the same hierarchical context
603        if (this.autocompleteController.inputTarget.value.trim() === "" && this.autocompleteController.isOpen) {
604          this.performHybridSearchWithHierarchy("");
605        }
606      }, delay);
607    } else {
608      // Trigger new search with empty query to show all children
609      this.searchLocalDataWithHierarchy("");
610    }
611
612    // Ensure dropdown stays open and focus stays on input
613    this.autocompleteController.inputTarget.focus();
614  }
615
616  navigateBack() {
617    if (this.currentPath.length === 0) return;
618
619    this.currentPath.pop();
620    this.currentPathIcons.pop(); // Remove the icon for the level we're leaving
621
622    // Update placeholder to the parent level's placeholder or original
623    this.updatePlaceholder();
624
625    // Clear input and search
626    this.autocompleteController.inputTarget.value = "";
627    this.updateClearButtonVisibilityWithHierarchy();
628
629    // Clear last query since we're changing context
630    this.autocompleteController.lastSearchQuery = null;
631
632    // Reset loading state when navigating back
633    this.autocompleteController.isLoading = false;
634
635    // Reset selection index
636    this.autocompleteController.selectedIndex = -1;
637
638    // Update breadcrumb
639    this.updateBreadcrumb();
640
641    // Show parent level
642    this.searchLocalDataWithHierarchy("");
643
644    // Keep dropdown open and focus on input
645    this.autocompleteController.inputTarget.focus();
646  }
647
648  updateClearButtonVisibilityWithHierarchy() {
649    if (this.autocompleteController.hasClearButtonTarget) {
650      // Show clear button if there's text in input OR if we're in a nested level
651      const hasInput = this.autocompleteController.inputTarget.value;
652      const hasNesting = this.currentPath.length > 0;
653
654      if (hasInput || hasNesting) {
655        this.autocompleteController.clearButtonTarget.classList.remove("hidden");
656        this.autocompleteController.clearButtonTarget.classList.add("flex");
657      } else {
658        this.autocompleteController.clearButtonTarget.classList.add("hidden");
659        this.autocompleteController.clearButtonTarget.classList.remove("flex");
660      }
661    }
662  }
663
664  updatePlaceholder() {
665    if (this.currentPath.length === 0) {
666      // At root level, use original placeholder
667      this.autocompleteController.inputTarget.placeholder = this.originalPlaceholder;
668      return;
669    }
670
671    // Navigate to current parent and get its placeholder
672    let currentLevel = this.originalLocalData;
673    let currentGroup = null;
674
675    for (const pathItem of this.currentPath) {
676      const parentItem = currentLevel.find(
677        (item) => (typeof item === "string" ? item : item.title || item.name || item.label) === pathItem,
678      );
679      if (parentItem && parentItem.children) {
680        currentGroup = parentItem;
681        currentLevel = parentItem.children;
682      } else {
683        break;
684      }
685    }
686
687    // Use the current group's placeholder or fall back to original
688    if (currentGroup && currentGroup.placeholder) {
689      this.autocompleteController.inputTarget.placeholder = currentGroup.placeholder;
690    } else {
691      this.autocompleteController.inputTarget.placeholder = this.originalPlaceholder;
692    }
693  }
694
695  clearInputWithHierarchy() {
696    // If we're in a nested level, reset to root and clear input
697    if (this.currentPath.length > 0) {
698      this.currentPath = [];
699      this.currentPathIcons = []; // Clear all icon data
700      this.autocompleteController.inputTarget.value = "";
701
702      // Restore original placeholder when returning to root
703      this.autocompleteController.inputTarget.placeholder = this.originalPlaceholder;
704
705      // Update breadcrumb
706      this.updateBreadcrumb();
707
708      this.autocompleteController.close();
709      this.updateClearButtonVisibilityWithHierarchy();
710      this.autocompleteController.lastSearchQuery = null;
711      this.autocompleteController.inputTarget.focus();
712    } else {
713      // If not nested, just clear the input normally
714      this.autocompleteController.inputTarget.value = "";
715      this.autocompleteController.close();
716      this.updateClearButtonVisibilityWithHierarchy();
717      this.autocompleteController.lastSearchQuery = null;
718      this.autocompleteController.inputTarget.focus();
719    }
720  }
721
722  getCurrentGroupRemoteConfig() {
723    if (this.currentPath.length === 0) {
724      // At root level, use main autocomplete controller configuration or check for root-level remote config
725      const rootConfig = this.getRootGroupRemoteConfig();
726      return (
727        rootConfig || {
728          url: this.autocompleteController.urlValue,
729          delay: this.autocompleteController.delayValue,
730          loadingText: this.autocompleteController.loadingTextValue,
731          noResultsText: this.autocompleteController.noResultsTextValue,
732          minLength: this.autocompleteController.minLengthValue,
733          separatorText: this.autocompleteController.separatorTextValue,
734        }
735      );
736    }
737
738    // Navigate to current group and get its remote configuration
739    let currentLevel = this.originalLocalData;
740    let currentGroup = null;
741
742    for (const pathItem of this.currentPath) {
743      const parentItem = currentLevel.find(
744        (item) => (typeof item === "string" ? item : item.title || item.name || item.label) === pathItem,
745      );
746      if (parentItem && parentItem.children) {
747        currentGroup = parentItem;
748        currentLevel = parentItem.children;
749      } else {
750        return null;
751      }
752    }
753
754    // Return the remote configuration for the current group
755    return currentGroup ? currentGroup.remoteConfig : null;
756  }
757
758  getRootGroupRemoteConfig() {
759    // Check if the root level data has a remoteConfig
760    if (this.originalLocalData && this.originalLocalData.length > 0) {
761      // Look for a special root configuration object
762      const rootConfig = this.originalLocalData.find(
763        (item) => item && typeof item === "object" && item._rootRemoteConfig,
764      );
765      return rootConfig ? rootConfig.remoteConfig : null;
766    }
767    return null;
768  }
769
770  showLoadingWithGroupConfig() {
771    // Don't re-render if already loading
772    if (this.autocompleteController.isLoading) {
773      return;
774    }
775
776    this.autocompleteController.isLoading = true; // Set loading state
777    const remoteConfig = this.getCurrentGroupRemoteConfig();
778    const loadingText = remoteConfig?.loadingText || this.autocompleteController.loadingTextValue;
779
780    this.autocompleteController.resultsTarget.innerHTML = `
781      <div class="px-4 py-8 text-center">
782        <div class="inline-flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400">
783          <svg class="animate-spin size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
784            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
785            <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>
786          </svg>
787          ${loadingText}
788        </div>
789      </div>
790    `;
791    this.autocompleteController.open();
792    this.scrollResultsToTop();
793  }
794
795  showNoResultsWithGroupConfig() {
796    this.autocompleteController.isLoading = false; // End loading state
797    const remoteConfig = this.getCurrentGroupRemoteConfig();
798    const noResultsText = remoteConfig?.noResultsText || this.autocompleteController.noResultsTextValue;
799
800    this.autocompleteController.resultsTarget.innerHTML = `
801      <div class="px-4 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
802        ${noResultsText}
803      </div>
804    `;
805    this.autocompleteController.open();
806    this.scrollResultsToTop();
807  }
808
809  renderSeparatorWithGroupConfig() {
810    const remoteConfig = this.getCurrentGroupRemoteConfig();
811    const separatorText = remoteConfig?.separatorText || this.autocompleteController.separatorTextValue;
812
813    return `
814      <div class="px-4 pt-3 pb-1.5 border-t border-neutral-200 dark:border-neutral-700">
815        <div class="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
816          ${separatorText}
817        </div>
818      </div>
819    `;
820  }
821
822  closeWithHierarchy() {
823    if (!this.autocompleteController.isOpen) return;
824
825    // If alwaysShow is enabled, don't actually close - just reset to current hierarchical level
826    if (this.autocompleteController.alwaysShowValue) {
827      // Reset to current hierarchical level instead of closing
828      this.searchLocalDataWithHierarchy("");
829      return;
830    }
831
832    // For non-hierarchical or when alwaysShow is disabled, use original close behavior
833    this.originalClose();
834  }
835
836  updateBreadcrumb() {
837    if (!this.breadcrumbValue || !this.hasBreadcrumbTarget) return;
838
839    const breadcrumbElement = this.breadcrumbTarget;
840
841    if (this.currentPath.length === 0) {
842      // At root level, hide breadcrumb or show just home
843      breadcrumbElement.innerHTML = this.renderHomeBreadcrumb();
844      return;
845    }
846
847    // Build breadcrumb with home + current path
848    let breadcrumbHtml = this.renderHomeBreadcrumb();
849
850    this.currentPath.forEach((pathItem, index) => {
851      const isLast = index === this.currentPath.length - 1;
852      breadcrumbHtml += this.renderBreadcrumbItem(pathItem, index, isLast);
853    });
854
855    breadcrumbElement.innerHTML = breadcrumbHtml;
856  }
857
858  renderHomeBreadcrumb() {
859    const isActive = this.currentPath.length === 0;
860
861    if (isActive) {
862      // Active state - at root level
863      return `
864        <div class="flex items-center text-sm">
865          <span class="flex gap-1.5 items-center text-neutral-900 dark:text-neutral-100 p-1.5" aria-current="page">
866            <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="M14.855 5.95L9.605 1.96C9.247 1.688 8.752 1.688 8.395 1.96L3.145 5.95C2.896 6.139 2.75 6.434 2.75 6.747V14.251C2.75 15.356 3.645 16.251 4.75 16.251H7.25V12.251C7.25 11.699 7.698 11.251 8.25 11.251H9.75C10.302 11.251 10.75 11.699 10.75 12.251V16.251H13.25C14.355 16.251 15.25 15.356 15.25 14.251V6.746C15.25 6.433 15.104 6.14 14.855 5.95Z"></path></g></svg>
867            <span>Home</span>
868          </span>
869        </div>
870      `;
871    } else {
872      // Inactive state - can navigate back to root
873      return `
874        <div class="flex items-center text-sm">
875          <button type="button"
876                  class="flex gap-1.5 items-center text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-neutral-600 dar
876k:focus-visible:outline-neutral-200"
877                  data-action="click->autocomplete-navigation#navigateToLevel"
878                  data-level="-1">
879            <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="M14.855 5.95L9.605 1.96C9.247 1.688 8.752 1.688 8.395 1.96L3.145 5.95C2.896 6.139 2.75 6.434 2.75 6.747V14.251C2.75 15.356 3.645 16.251 4.75 16.251H7.25V12.251C7.25 11.699 7.698 11.251 8.25 11.251H9.75C10.302 11.251 10.75 11.699 10.75 12.251V16.251H13.25C14.355 16.251 15.25 15.356 15.25 14.251V6.746C15.25 6.433 15.104 6.14 14.855 5.95Z"></path></g></svg>
880            <span>Home</span>
881          </button>
882        </div>
883      `;
884    }
885  }
886
887  renderBreadcrumbItem(pathItem, index, isLast) {
888    const separator = `
889      <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" viewBox="0 0 20 20" fill="currentColor">
890        <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
891      </svg>
892    `;
893
894    // Get icon for this level
895    const iconData = this.currentPathIcons[index] || { icon: "folder", customIcon: null };
896    const iconSvg = iconData.customIcon || this.autocompleteController.getIconSvg(iconData.icon);
897
898    if (isLast) {
899      return `
900        <div class="flex items-center text-sm">
901          ${separator}
902          <span class="flex items-center gap-1.5 text-neutral-900 dark:text-neutral-100 p-1.5" aria-current="page">
903            ${iconSvg}
904            ${this.escapeHtml(pathItem)}
905          </span>
906        </div>
907      `;
908    } else {
909      return `
910        <div class="flex items-center text-sm">
911          ${separator}
912          <button type="button"
913                  class="flex items-center gap-1.5 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-neutral-600 dar
913k:focus-visible:outline-neutral-200"
914                  data-action="click->autocomplete-navigation#navigateToLevel"
915                  data-level="${index}">
916            ${iconSvg}
917            ${this.escapeHtml(pathItem)}
918          </button>
919        </div>
920      `;
921    }
922  }
923
924  navigateToLevel(event) {
925    const targetLevel = parseInt(event.currentTarget.dataset.level);
926
927    if (targetLevel === -1) {
928      // Navigate to root
929      this.currentPath = [];
930      this.currentPathIcons = [];
931    } else if (targetLevel >= 0 && targetLevel < this.currentPath.length) {
932      // Navigate to specific level
933      this.currentPath = this.currentPath.slice(0, targetLevel + 1);
934      this.currentPathIcons = this.currentPathIcons.slice(0, targetLevel + 1);
935    }
936
937    // Update placeholder
938    this.updatePlaceholder();
939
940    // Clear input and search
941    this.autocompleteController.inputTarget.value = "";
942    this.updateClearButtonVisibilityWithHierarchy();
943
944    // Clear last query since we're changing context
945    this.autocompleteController.lastSearchQuery = null;
946
947    // Reset loading state when navigating to a different level
948    this.autocompleteController.isLoading = false;
949
950    // Reset selection index
951    this.autocompleteController.selectedIndex = -1;
952
953    // Update breadcrumb
954    this.updateBreadcrumb();
955
956    // Show the level
957    this.searchLocalDataWithHierarchy("");
958
959    // Keep dropdown open and focus on input
960    this.autocompleteController.inputTarget.focus();
961  }
962
963  getIconDataForItem(item) {
964    if (typeof item === "string") {
965      return { icon: "folder", customIcon: null };
966    }
967
968    return {
969      icon: item.icon || "folder",
970      customIcon: item.customIcon || null,
971    };
972  }
973
974  scrollResultsToTop() {
975    // Scroll the results container to the top for better UX when changing hierarchical levels
976    if (this.autocompleteController.resultsTarget) {
977      this.autocompleteController.resultsTarget.scrollTop = 0;
978    }
979  }
980
981  highlightPreviousSelection() {
982    // Only highlight if we have a previously selected value and input is empty
983    if (!this.autocompleteController.lastSelectedValue || this.autocompleteController.inputTarget.value.trim() !== "") {
984      return;
985    }
986
987    const suggestions = this.autocompleteController.suggestionTargets;
988    for (let i = 0; i < suggestions.length; i++) {
989      const suggestion = suggestions[i];
990      const suggestionValue = suggestion.dataset.value;
991
992      if (suggestionValue === this.autocompleteController.lastSelectedValue) {
993        // Found the previously selected item - highlight it
994        this.autocompleteController.selectedIndex = i;
995        this.autocompleteController.updateSelection();
996
997        // Scroll the highlighted item into view after DOM updates
998        setTimeout(() => {
999          suggestion.scrollIntoView({
1000            block: "nearest",
1001            behavior: "smooth",
1002          });
1003        }, 50);
1004        break;
1005      }
1006    }
1007  }
1008
1009  escapeHtml(text) {
1010    const div = document.createElement("div");
1011    div.textContent = text;
1012    return div.innerHTML;
1013  }
1014};

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.