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