PageSourceSearch

https://railsblocks.com/assets/controllers/autocomplete_recent_sea…4116256d8227e895cf0b27ff6241d14e8f994795.js

js railsblocks.com collected 2026-09-25 20:22:46 UTC 18,909 bytes, 508 lines download raw bytes

1import { BaseAutocompleteExtension } from "controllers/autocomplete_controller";
2
3export default class extends BaseAutocompleteExtension {
4  static targets = ["input", "results", "clearButton"];
5  static values = {
6    // This controller is specifically for recent searches functionality
7    // If you don't need recent searches, don't add this controller
8    recentStorageKey: { type: String, default: "autocomplete-recent" }, // localStorage key for storing recent searches
9    maxRecentItems: { type: Number, default: 5 }, // Maximum number of recent searches to store
10    showRecentWhileTyping: { type: Boolean, default: false }, // Keep recent searches visible while typing
11    deduplicate: { type: Boolean, default: true }, // Remove duplicates between recent and regular suggestions
12    recentMatchStrategy: { type: String, default: "" }, // "prefix", "contains", "exact" - how to match recent searches while typing. "" Empty string uses core matchStrategy. "prefix" will match the beginning of the recent search. "contains" will match any part of the recent search. "exact" will match the exact recent search.
13    preserveIcons: { type: Boolean, default: false }, // Whether to preserve original icons in recent searches (true) or always use recent icon (false)
14  };
15
16  initialize() {
17    // Initialize recent searches
18    this.recentSearches = this.loadRecentSearches();
19
20    // Check if autocomplete controller is available before proceeding
21    if (!this.autocompleteController) {
22      return;
23    }
24
25    // Override methods to handle recent searches
26    this.overrideMethod("handleInput", this.handleInputWithRecent);
27    this.overrideMethod("selectSuggestion", this.selectSuggestionWithRecent);
28    this.overrideMethod("submitSearch", this.submitSearchWithRecent);
29    this.overrideMethod("displaySuggestions", this.displaySuggestionsWithRecent);
30    this.overrideMethod("searchLocalData", this.searchLocalDataWithRecent);
31    this.overrideMethod("fetchSuggestions", this.fetchSuggestionsWithRecent);
32
33    // Handle focus event listener replacement
34    this.boundHandleInitialFocusWithRecent = this.handleInitialFocusWithRecent.bind(this);
35    this.replaceEventListener(
36      this.autocompleteController.inputTarget,
37      "focus",
38      this.autocompleteController.boundHandleInitialFocus,
39      this.boundHandleInitialFocusWithRecent,
40    );
41  }
42
43  disconnect() {
44    // Restore focus event listener
45    this.replaceEventListener(
46      this.autocompleteController?.inputTarget,
47      "focus",
48      this.boundHandleInitialFocusWithRecent,
49      this.autocompleteController?.boundHandleInitialFocus,
50    );
51
52    super.disconnect();
53  }
54
55  handleInputWithRecent(event) {
56    const query = event.target.value.trim();
57
58    this.autocompleteController.updateClearButtonVisibility();
59
60    // Clear last query if current query is different
61    if (query !== this.autocompleteController.lastSearchQuery) {
62      this.autocompleteController.lastSearchQuery = null;
63    }
64
65    // Reset keyboard navigation state when new input triggers search
66    this.autocompleteController.keyboardNavigating = false;
67    this.autocompleteController.updateKeyboardNavigationClass();
68
69    if (query.length < this.autocompleteController.minLengthValue) {
70      if (query.length === 0 && this.recentSearches.length > 0) {
71        this.showRecentSearches();
72      } else {
73        this.autocompleteController.close();
74      }
75      return;
76    }
77
78    // Handle local-only searches immediately (no delay needed)
79    if (!this.autocompleteController.urlValue) {
80      this.autocompleteController.searchLocalData(query);
81      return;
82    }
83
84    // Clear existing timeout for remote searches
85    if (this.autocompleteController.searchTimeout) clearTimeout(this.autocompleteController.searchTimeout);
86
87    // Show loading state for remote searches only if not already loading
88    if (!this.autocompleteController.isLoading) {
89      this.autocompleteController.showLoading();
90    }
91
92    // Debounce only remote searches
93    this.autocompleteController.searchTimeout = setTimeout(() => {
94      // Check if the input value has changed since this timeout was scheduled
95      const currentQuery = this.autocompleteController.inputTarget.value.trim();
96      if (currentQuery !== query) {
97        // Input has changed, don't execute this outdated search
98        // The new input value should have already triggered its own handler
99        return;
100      }
101
102      this.autocompleteController.fetchSuggestions(query);
103    }, this.autocompleteController.delayValue);
104  }
105
106  handleInitialFocusWithRecent() {
107    const currentValue = this.autocompleteController.inputTarget.value.trim();
108
109    if (currentValue) {
110      // If the query hasn't changed and results container has content, just reopen it
111      if (
112        currentValue === this.autocompleteController.lastSearchQuery &&
113        this.autocompleteController.resultsTarget.innerHTML.trim() !== ""
114      ) {
115        this.autocompleteController.open();
116      } else {
117        // Query has changed, trigger new search
118        this.handleInputWithRecent({ target: this.autocompleteController.inputTarget });
119      }
120    } else {
121      if (this.autocompleteController.searchOnFocusValue || this.autocompleteController.alwaysShowValue) {
122        // If search on focus or always show is enabled, trigger a search with empty query
123        // This will now include recent searches merged with regular results
124        if (this.autocompleteController.urlValue) {
125          this.autocompleteController.fetchSuggestions("");
126        } else {
127          this.autocompleteController.searchLocalData("");
128        }
129      } else if (this.recentSearches.length > 0) {
130        this.showRecentSearches();
131      }
132    }
133  }
134
135  selectSuggestionWithRecent(event) {
136    const value = event.currentTarget.dataset.value;
137
138    let suggestionData = {};
139    try {
140      const dataString = event.currentTarget.dataset.suggestionData;
141      if (dataString && dataString.trim() !== "") {
142        // Decode HTML entities back to quotes
143        const decodedData = dataString.replace(/&quot;/g, '"');
144        suggestionData = JSON.parse(decodedData);
145      }
146    } catch (error) {
147      console.warn("Failed to parse suggestion data:", error);
148      suggestionData = { title: value };
149    }
150
151    // Update input based on URL presence and preserveSearchOnSelect setting
152    const hasUrl = suggestionData.url || suggestionData.route || suggestionData.href;
153    const shouldUpdateInput = !hasUrl || (hasUrl && !this.autocompleteController.preserveSearchOnSelectValue);
154    if (shouldUpdateInput) {
155      this.autocompleteController.inputTarget.value = value;
156      this.autocompleteController.updateClearButtonVisibility();
157    }
158
159    // Determine if we should close - respect preserveSearchOnSelect for URL items
160    const shouldClose =
161      this.autocompleteController.closeOnSelectValue &&
162      !(hasUrl && this.autocompleteController.preserveSearchOnSelectValue);
163    if (shouldClose) this.autocompleteController.close();
164
165    // Execute configured action if specified
166    this.autocompleteController.executeConfiguredAction(suggestionData);
167
168    // Add to recent searches with full suggestion data
169    this.addToRecentSearches(suggestionData);
170
171    // Dispatch custom event with full data
172    this.autocompleteController.dispatch("select", {
173      detail: {
174        value,
175        data: suggestionData,
176      },
177    });
178
179    // Submit if enabled
180    if (this.autocompleteController.submitOnSelectValue) {
181      this.submitSearchWithRecent(value);
182    }
183  }
184
185  submitSearchWithRecent(value) {
186    // Add to recent searches when submitting
187    // For manual submissions, create basic suggestion data
188    this.addToRecentSearches({ title: value });
189
190    // Call original submit
191    this.callOriginal("submitSearch", value);
192  }
193
194  showRecentSearches() {
195    if (this.recentSearches.length === 0) {
196      this.autocompleteController.close();
197      return;
198    }
199
200    const html = `
201      <div class="px-4 pt-3 pb-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Recent Searches</div>
202      ${this.recentSearches
203        .map((search, index) => {
204          // Handle both legacy string format and new object format
205          const suggestion =
206            typeof search === "string" ? { title: search, isRecent: true } : { ...search, isRecent: true };
207
208          // Handle icon based on preserveIcons setting
209          const recentSuggestion = { ...suggestion };
210          if (!this.preserveIconsValue) {
211            recentSuggestion.icon = "recent";
212          }
213
214          // Use base class rendering for consistent icon handling
215          return this.renderSuggestion(recentSuggestion, "", index, {
216            showRemoveButton: true,
217          });
218        })
219        .join("")}
220    `;
221
222    this.autocompleteController.resultsTarget.innerHTML = html;
223    this.autocompleteController.open();
224  }
225
226  removeRecentSearch(event) {
227    event.stopPropagation();
228    const search = event.currentTarget.dataset.search;
229    this.recentSearches = this.recentSearches.filter((s) => {
230      const searchText = typeof s === "string" ? s : s.title || s.name || s.label || "";
231      return searchText !== search;
232    });
233    this.saveRecentSearches();
234
235    // Refresh the display
236    const currentValue = this.autocompleteController.inputTarget.value.trim();
237
238    if (currentValue === "") {
239      // If input is empty, check if we should show merged results or just recent searches
240      if (this.autocompleteController.searchOnFocusValue || this.autocompleteController.alwaysShowValue) {
241        // Trigger search to show merged results
242        if (this.autocompleteController.urlValue) {
243          this.autocompleteController.fetchSuggestions("");
244        } else {
245          this.autocompleteController.searchLocalData("");
246        }
247      } else {
248        // Show just recent searches
249        this.showRecentSearches();
250      }
251    } else {
252      // If there's text, trigger a new search to refresh the merged results
253      this.handleInputWithRecent({ target: this.autocompleteController.inputTarget });
254    }
255  }
256
257  addToRecentSearches(suggestionData) {
258    // Ensure we have a proper suggestion object
259    const suggestion = typeof suggestionData === "string" ? { title: suggestionData } : { ...suggestionData };
260
261    // Add metadata for recent searches and handle icons based on preserveIcons setting
262    suggestion.isRecent = true;
263
264    if (!this.preserveIconsValue) {
265      // Remove original icon and customIcon to ensure recent icon is always used
266      delete suggestion.icon;
267      delete suggestion.customIcon;
268    }
269    // If preserveIcons is true, keep original icon/customIcon data
270
271    // Get the display text for comparison
272    const searchText = suggestion.title || suggestion.name || suggestion.label || "";
273
274    // Remove if already exists (compare by text)
275    this.recentSearches = this.recentSearches.filter((s) => {
276      const existingText = typeof s === "string" ? s : s.title || s.name || s.label || "";
277      return existingText !== searchText;
278    });
279
280    // Add to beginning
281    this.recentSearches.unshift(suggestion);
282
283    // Limit the number of recent searches
284    this.recentSearches = this.recentSearches.slice(0, this.maxRecentItemsValue);
285
286    this.saveRecentSearches();
287  }
288
289  loadRecentSearches() {
290    try {
291      const stored = localStorage.getItem(this.recentStorageKeyValue);
292      return stored ? JSON.parse(stored) : [];
293    } catch {
294      return [];
295    }
296  }
297
298  saveRecentSearches() {
299    try {
300      localStorage.setItem(this.recentStorageKeyValue, JSON.stringify(this.recentSearches));
301    } catch {
302      // Ignore localStorage errors
303    }
304  }
305
306  clearInput() {
307    this.autocompleteController.inputTarget.value = "";
308    this.autocompleteController.close();
309    this.autocompleteController.updateClearButtonVisibility();
310    this.autocompleteController.inputTarget.focus();
311
312    // Clear last query
313    this.autocompleteController.lastSearchQuery = null;
314  }
315
316  // New methods to merge recent searches with regular results
317
318  searchLocalDataWithRecent(query) {
319    // Get regular suggestions using core's getLocalResults method
320    const regularSuggestions = this.autocompleteController.getLocalResults(query);
321
322    // Merge with recent searches if conditions are met
323    const mergedSuggestions = this.getMergedSuggestions(regularSuggestions, query);
324    this.autocompleteController.displaySuggestions(mergedSuggestions, query);
325  }
326
327  async fetchSuggestionsWithRecent(query) {
328    // Cancel previous request
329    if (this.autocompleteController.abortController) this.autocompleteController.abortController.abort();
330    this.autocompleteController.abortController = new AbortController();
331
332    try {
333      const url = new URL(this.autocompleteController.urlValue, window.location.origin);
334      url.searchParams.set("q", query);
335
336      const response = await fetch(url, {
337        signal: this.autocompleteController.abortController.signal,
338        headers: {
339          Accept: "application/json",
340        },
341      });
342
343      if (!response.ok) throw new Error("Network response was not ok");
344
345      const data = await response.json();
346      const regularSuggestions = data.suggestions || data;
347
348      // Merge with recent searches if conditions are met
349      const mergedSuggestions = this.getMergedSuggestions(regularSuggestions, query);
350      this.autocompleteController.displaySuggestions(mergedSuggestions, query);
351    } catch (error) {
352      if (error.name !== "AbortError") {
353        console.error("Autocomplete fetch error:", error);
354        this.autocompleteController.showError();
355      }
356    }
357  }
358
359  displaySuggestionsWithRecent(suggestions, query) {
360    // Track the last search query
361    this.autocompleteController.lastSearchQuery = query;
362
363    if (!suggestions || suggestions.length === 0) {
364      this.autocompleteController.showNoResults();
365      return;
366    }
367
368    const html = this.renderSuggestions(suggestions, query, 0, {
369      showRemoveButton: true,
370    });
371
372    this.autocompleteController.resultsTarget.innerHTML = html;
373    this.autocompleteController.open();
374
375    // Auto-select first suggestion if enabled, not currently keyboard navigating, and user is actively searching
376    if (
377      this.autocompleteController.autoSelectFirstValue &&
378      suggestions.length > 0 &&
379      !this.autocompleteController.keyboardNavigating &&
380      query &&
381      query.length > 0
382    ) {
383      this.autocompleteController.selectedIndex = 0;
384      this.autocompleteController.updateSelection();
385    }
386  }
387
388  getMergedSuggestions(regularSuggestions, query) {
389    // Determine if we should show recent searches
390    const shouldShowRecent = this.shouldShowRecentSearches(query);
391
392    if (!shouldShowRecent) {
393      return regularSuggestions;
394    }
395
396    // Get filtered recent searches based on query and settings
397    const filteredRecentSearches = this.getFilteredRecentSearches(query);
398
399    if (filteredRecentSearches.length === 0) {
400      return regularSuggestions;
401    }
402
403    // Convert recent searches to suggestion format
404    const recentSuggestions = filteredRecentSearches.map((search) => {
405      // Handle both legacy string format and new object format
406      if (typeof search === "string") {
407        return {
408          title: search,
409          icon: "recent",
410          isRecent: true,
411        };
412      } else {
413        // Ensure object has isRecent flag and handle icon based on preserveIcons setting
414        const recentSuggestion = {
415          ...search,
416          isRecent: true,
417        };
418
419        // Force recent icon only if preserveIcons is disabled
420        if (!this.preserveIconsValue) {
421          recentSuggestion.icon = "recent";
422        }
423
424        return recentSuggestion;
425      }
426    });
427
428    // Apply deduplication if enabled
429    let finalRegularSuggestions = regularSuggestions;
430    if (this.deduplicateValue) {
431      finalRegularSuggestions = this.deduplicateSuggestions(regularSuggestions, recentSuggestions);
432    }
433
434    // Combine recent searches with regular suggestions
435    // Put recent searches first, then regular suggestions
436    const combined = [...recentSuggestions, ...finalRegularSuggestions];
437
438    // Limit total results to maxSuggestions
439    return combined.slice(0, this.autocompleteController.maxSuggestionsValue);
440  }
441
442  // Helper method to determine if recent searches should be shown
443  shouldShowRecentSearches(query) {
444    if (this.recentSearches.length === 0) {
445      return false;
446    }
447
448    // Always show recent searches if showRecentWhileTyping is enabled
449    if (this.showRecentWhileTypingValue) {
450      return true;
451    }
452
453    // Original behavior: only show recent searches for empty queries when searchOnFocus or alwaysShow is enabled
454    return (
455      (this.autocompleteController.searchOnFocusValue || this.autocompleteController.alwaysShowValue) &&
456      query.length === 0
457    );
458  }
459
460  // Helper method to filter recent searches based on query and match strategy
461  getFilteredRecentSearches(query) {
462    if (query.length === 0) {
463      // No filtering needed for empty queries
464      return this.recentSearches.slice(0, this.maxRecentItemsValue);
465    }
466
467    if (!this.showRecentWhileTypingValue) {
468      // If showRecentWhileTyping is disabled, don't show any recent searches for non-empty queries
469      return [];
470    }
471
472    // Determine which matching strategy to use
473    const strategy = this.recentMatchStrategyValue || this.autocompleteController.matchStrategyValue;
474
475    // Filter recent searches using the core's matching logic
476    const filteredRecents = this.recentSearches.filter((search) => {
477      // Get the search text from either string or object format
478      const searchText = typeof search === "string" ? search : search.title || search.name || search.label || "";
479      return this.autocompleteController.matchesQuery(searchText, query, strategy);
480    });
481
482    return filteredRecents.slice(0, this.maxRecentItemsValue);
483  }
484
485  // Helper method to deduplicate regular suggestions against recent searches
486  deduplicateSuggestions(regularSuggestions, recentSuggestions) {
487    if (!regularSuggestions || regularSuggestions.length === 0) {
488      return regularSuggestions;
489    }
490
491    // Create a set of recent search titles for fast lookup
492    const recentTitles = new Set(
493      recentSuggestions
494        .map((recent) => {
495          const title = typeof recent === "string" ? recent : recent.title || recent.name || recent.label;
496          return title?.toLowerCase();
497        })
498        .filter(Boolean),
499    );
500
501    // Filter out regular suggestions that match recent searches
502    return regularSuggestions.filter((suggestion) => {
503      const title =
504        typeof suggestion === "string" ? suggestion : suggestion.title || suggestion.name || suggestion.label;
505      return title && !recentTitles.has(title.toLowerCase());
506    });
507  }
508};

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.