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(/"/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.