1(function (window) { 2 'use strict'; 3 4 const { decodeHtml, escapeHtml, isMobile } = window.ammwebProductNavUtils; 5 6 function create(api) { 7 const { 8 navData, 9 searchIndex, 10 searchConfig, 11 state, 12 dom, 13 isActiveNavUrl, 14 } = api; 15 16 const { 17 desktopColumns, 18 mobileMenu, 19 mobileSearchResults, 20 desktopSearchFooter, 21 searchFields, 22 searchClearButtons, 23 mobileSearchContainer, 24 desktopSearchContainer, 25 } = dom; 26 27 const searchMinChars = Math.max(1, Number(searchConfig.minChars) || 2); 28 const searchMaxResults = Math.max(1, Number(searchConfig.maxResults) || 40); 29 const searchDebounceMs = Math.max(0, Number(searchConfig.debounceMs) || 300); 30 const searchPlaceholder = typeof searchConfig.placeholder === 'string' ? searchConfig.placeholder : 'Search productsâ¦'; 31 const searchEmptyLabel = typeof searchConfig.emptyLabel === 'string' ? searchConfig.emptyLabel : 'No products found'; 32 const searchLoadMoreLabel = typeof searchConfig.loadMoreLabel === 'string' ? searchConfig.loadMoreLabel : 'Load more'; 33 34 const searchEnabled = searchConfig.enabled !== false; 35 36 let searchDebounceTimer = null; 37 38 const isSearchMode = () => { 39 if (!searchEnabled) return false; 40 41 return state.searchQuery.trim().length >= searchMinChars; 42 }; 43 44 const normalizeSearchText = (value) => decodeHtml(String(value ?? '')).toLowerCase().trim(); 45 46 const compareNavNames = (left, right) => normalizeSearchText(left).localeCompare( 47 normalizeSearchText(right), 48 undefined, 49 { sensitivity: 'base' } 50 ); 51 52 const scoreSearchItem = (item, query) => { 53 const name = normalizeSearchText(item.name); 54 if (!query) { 55 return 0; 56 } 57 58 if (name) { 59 if (name === query) { 60 return 1000; 61 } 62 63 if (name.startsWith(query)) { 64 return 800; 65 } 66 67 if (name.includes(query)) { 68 return 600; 69 } 70 } 71 72 const breadcrumb = normalizeSearchText(item.breadcrumb); 73 if (breadcrumb && breadcrumb.includes(query)) { 74 return 500; 75 } 76 77 return 0; 78 }; 79 80 const filterSearchIndex = (query) => { 81 const normalizedQuery = normalizeSearchText(query); 82 if (!normalizedQuery) { 83 return []; 84 } 85 86 return searchIndex 87 .map((item) => ({ 88 item, 89 score: scoreSearchItem(item, normalizedQuery), 90 })) 91 .filter((entry) => entry.score > 0) 92 .sort((left, right) => { 93 const scoreDiff = right.score - left.score; 94 if (scoreDiff !== 0) { 95 return scoreDiff; 96 } 97 98 return compareNavNames(left.item.name, right.item.name); 99 }) 100 .map((entry) => entry.item); 101 }; 102 103 const buildSearchResultRowHtml = (item) => { 104 const breadcrumb = typeof item.breadcrumb === 'string' ? item.breadcrumb.trim() : ''; 105 const rowClass = item.type === 'category' || item.type === 'archive' 106 ? 'products-nav-row products-nav-row--search-result products-nav-row--search-category' 107 : 'products-nav-row products-nav-row--search-result products-nav-row--search-product'; 108 const activeClass = isActiveNavUrl(item.url) ? ' is-active' : ''; 109 110 return `<a class="${rowClass} ax-track-link ax-header-product-menu-search-result-click${activeClass}" data-ax-track="header-product-menu-search-result-click" href="${escapeHtml(item.url)}"> 111 <span class="products-nav-row__label">${escapeHtml(item.name)}</span> 112 ${breadcrumb ? `<span class="products-nav-row__path">${escapeHtml(breadcrumb)}</span>` : ''} 113 </a>`; 114 }; 115 116 const getSearchColumnCount = () => Math.max(1, Number(navData.columnCount) || 1); 117 118 const buildSearchResultEmptyCellHtml = () => ( 119 '<span class="products-nav-search__cell products-nav-search__cell--empty" aria-hidden="true"></span>' 120 ); 121 122 const buildSearchResultRowWrapperHtml = (rowItems) => { 123 const columnCount = getSearchColumnCount(); 124 const cells = rowItems.map((item) => buildSearchResultRowHtml(item)).join(''); 125 const emptyCells = Array.from( 126 { length: Math.max(0, columnCount - rowItems.length) }, 127 () => buildSearchResultEmptyCellHtml() 128 ).join(''); 129 130 return `<div class="products-nav-search__row">${cells}${emptyCells}</div>`; 131 }; 132 133 const buildDesktopSearchResultsRowsHtml = (matches) => { 134 const columnCount = getSearchColumnCount(); 135 const rows = []; 136 137 for (let index = 0; index < matches.length; index += columnCount) { 138 rows.push(buildSearchResultRowWrapperHtml(matches.slice(index, index + columnCount))); 139 } 140 141 return rows.join(''); 142 }; 143 144 const appendDesktopSearchResultBatch = (batch) => { 145 const columnCount = getSearchColumnCount(); 146 const items = batch.slice(); 147 const lastRow = desktopColumns.querySelector('.products-nav-search__row:last-child'); 148 149 if (lastRow) { 150 lastRow.querySelectorAll('.products-nav-search__cell--empty').forEach((cell) => { 151 cell.remove(); 152 }); 153 154 const currentCount = lastRow.querySelectorAll('.products-nav-row--search-result').length; 155 const openSlots = currentCount % columnCount === 0 ? 0 : columnCount - (currentCount % columnCount); 156 157 if (openSlots > 0 && items.length > 0) { 158 const fillItems = items.splice(0, openSlots);
159 fillItems.forEach((item) => { 160 lastRow.insertAdjacentHTML('beforeend', buildSearchResultRowHtml(item)); 161 }); 162 163 const updatedCount = lastRow.querySelectorAll('.products-nav-row--search-result').length; 164 const remainingSlots = updatedCount % columnCount === 0 ? 0 : columnCount - (updatedCount % columnCount); 165 for (let index = 0; index < remainingSlots; index += 1) { 166 lastRow.insertAdjacentHTML('beforeend', buildSearchResultEmptyCellHtml()); 167 } 168 } 169 } 170 171 if (items.length > 0) { 172 desktopColumns.insertAdjacentHTML('beforeend', buildDesktopSearchResultsRowsHtml(items)); 173 } 174 }; 175 176 const buildSearchFooterHtml = (query, totalMatches, displayedCount) => { 177 if (!totalMatches) { 178 return `<p class="products-nav-search__empty">${escapeHtml(searchEmptyLabel)}</p>`; 179 } 180 181 if (totalMatches > displayedCount) { 182 const remaining = totalMatches - displayedCount; 183 const nextCount = Math.min(searchMaxResults, remaining); 184 return `<button class="products-nav-search__load-more ax-track-btn ax-header-product-menu-search-load-more-click" type="button" data-products-nav-search-load-more data-ax-track="header-product-menu-search-load-more-click">${escapeHtml(searchLoadMoreLabel)} <span class="products-nav-search__load-more-count">(${escapeHtml(String(nextCount))})</span></button>`; 185 } 186 187 return ''; 188 }; 189 190 const getFilteredSearchMatches = () => filterSearchIndex(state.searchQuery.trim()); 191 192 const updateSearchFooters = (totalMatches, displayedCount) => { 193 const query = state.searchQuery.trim(); 194 const footerHtml = buildSearchFooterHtml(query, totalMatches, displayedCount); 195 196 if (desktopSearchFooter) { 197 desktopSearchFooter.innerHTML = footerHtml; 198 desktopSearchFooter.hidden = '' === footerHtml; 199 } 200 201 if (!mobileSearchResults) { 202 return; 203 } 204 205 const inner = mobileSearchResults.querySelector('.products-nav-mobile__search-results-inner'); 206 if (!inner) { 207 return; 208 } 209 210 let footer = inner.querySelector('.products-nav-search__footer--mobile'); 211 if (!footerHtml) { 212 footer?.remove(); 213 return; 214 } 215 216 if (!footer) { 217 footer = document.createElement('div'); 218 footer.className = 'products-nav-search__footer products-nav-search__footer--mobile'; 219 inner.appendChild(footer); 220 } 221 222 footer.innerHTML = footerHtml; 223 }; 224 225 const resetSearchDisplayedCount = () => { 226 state.searchDisplayedCount = 0; 227 }; 228
229 const loadMoreSearchResults = () => { 230 const matches = getFilteredSearchMatches(); 231 const start = state.searchDisplayedCount; 232 const end = Math.min(start + searchMaxResults, matches.length); 233 const batch = matches.slice(start, end); 234 235 if (!batch.length) { 236 updateSearchFooters(matches.length, start); 237 return; 238 } 239 240 state.searchDisplayedCount = end; 241 242 if (!isMobile()) { 243 appendDesktopSearchResultBatch(batch); 244 } 245 246 const mobileBatchHtml = batch.map((item) => buildSearchResultRowHtml(item)).join(''); 247 248 if (!mobileMenu.hidden && mobileSearchResults) { 249 const inner = mobileSearchResults.querySelector('.products-nav-mobile__search-results-inner'); 250 const footer = inner?.querySelector('.products-nav-search__footer--mobile'); 251 if (inner) { 252 if (footer) { 253 footer.insertAdjacentHTML('beforebegin', mobileBatchHtml); 254 } else { 255 inner.insertAdjacentHTML('beforeend', mobileBatchHtml); 256 } 257 } 258 } 259 260 window.requestAnimationFrame(() => { 261 updateSearchFooters(matches.length, end); 262 }); 263 }; 264 265 const syncSearchFieldValues = (value) => { 266 const activeField = searchFields.find((field) => field === document.activeElement) || null; 267 const selectionStart = typeof activeField?.selectionStart === 'number' ? activeField.selectionStart : null; 268 const selectionEnd = typeof activeField?.selectionEnd === 'number' ? activeField.selectionEnd : null; 269 270 searchFields.forEach((field) => { 271 if (field.value !== value) { 272 field.value = value; 273 } 274 }); 275 276 if (!activeField || !isMobile()) { 277 return; 278 } 279 280 if (document.activeElement !== activeField) { 281 activeField.focus({ preventScroll: true }); 282 } 283 284 if (selectionStart !== null && selectionEnd !== null) { 285 try { 286 activeField.setSelectionRange(selectionStart, selectionEnd); 287 } catch (error) { 288 // Some input types do not support selection ranges. 289 } 290 } 291 }; 292 293 const updateSearchClearButtons = () => { 294 const hasValue = state.searchQuery.trim().length > 0; 295 searchClearButtons.forEach((button) => { 296 button.hidden = false; 297 button.classList.toggle('is-visible', hasValue); 298 button.setAttribute('aria-hidden', hasValue ? 'false' : 'true'); 299 }); 300 }; 301 302 const setDesktopSearchVisible = (visible) => { 303 if (!searchEnabled || !desktopSearchContainer) { 304 return; 305 } 306 307 desktopSearchContainer.hidden = !visible; 308 }; 309 310 const setMobileSearchVisible = (visible) => { 311 if (!searchEnabled || !mobileSearchContainer) { 312 return; 313 } 314 315 mobileSearchContainer.hidden = !visible; 316 }; 317 318 const syncMobileSearchLayout = () => { 319 if (!searchEnabled || !isMobile() || mobileMenu.hidden) { 320 return; 321 } 322 323 const panel = api.mobile.getActiveMobilePanel(); 324 if (!panel || !mobileSearchContainer) { 325 return; 326 } 327 328 const slot = panel.querySelector('.products-nav-mobile__search-slot'); 329 const content = panel.querySelector('.products-nav-mobile__content'); 330 if (!slot) { 331 return; 332 } 333 334 if (mobileSearchContainer.parentElement !== slot) { 335 slot.appendChild(mobileSearchContainer); 336 } 337 setMobileSearchVisible(true); 338 339 if (isSearchMode() && mobileSearchResults) { 340 if (mobileSearchResults.parentElement !== panel) { 341 slot.insertAdjacentElement('afterend', mobileSearchResults); 342 } 343 mobileSearchResults.hidden = false; 344 if (content) { 345 content.hidden = true; 346 } 347 mobileMenu.classList.add('is-search-active'); 348 return; 349 } 350 351 if (mobileSearchResults) { 352 mobileSearchResults.hidden = true; 353 if (mobileSearchResults.parentElement === panel) { 354 mobileMenu.insertBefore(mobileSearchResults, dom.mobilePanels); 355 } 356 } 357 358 if (content) { 359 content.hidden = false; 360 } 361 mobileMenu.classList.remove('is-search-active'); 362 }; 363 364 const renderMobileSearchResultsContent = () => { 365 if (!mobileSearchResults) { 366 return; 367 } 368 369 const query = state.searchQuery.trim(); 370 const matches = getFilteredSearchMatches(); 371 const totalMatches = matches.length; 372 const displayedMatches = matches.slice(0, searchMaxResults); 373 state.searchDisplayedCount = displayedMatches.length; 374 const previousScrollTop = mobileSearchResults.scrollTop; 375 const rowsHtml = displayedMatches.map((item) => buildSearchResultRowHtml(item)).join(''); 376 const footerHtml = buildSearchFooterHtml(query, totalMatches, displayedMatches.length); 377 378 mobileSearchResults.innerHTML = ` 379 <div class="products-nav-mobile__search-results-inner"> 380 ${rowsHtml} 381 ${footerHtml ? `<div class="products-nav-search__footer products-nav-search__footer--mobile">${footerHtml}</div>` : ''} 382 </div> 383 `; 384 mobileSearchResults.scrollTop = previousScrollTop; 385 }; 386 387 const renderSearchResults = () => { 388 api.browse.clearDesktopHoverTimer(); 389 const query = state.searchQuery.trim(); 390 const matches = getFilteredSearchMatches(); 391 const totalMatches = matches.length; 392 const displayedMatches = matches.slice(0, searchMaxResults); 393 state.searchDisplayedCount = displayedMatches.length; 394 const columnCount = getSearchColumnCount(); 395 const resultsHtml = buildDesktopSearchResultsRowsHtml(displayedMatches); 396 397 desktopColumns.classList.add('is-search-mode'); 398 desktopColumns.style.setProperty('--products-nav-column-count', Str
398ing(columnCount)); 399 desktopColumns.innerHTML = resultsHtml; 400 401 if (desktopSearchFooter) { 402 const footerHtml = buildSearchFooterHtml(query, totalMatches, displayedMatches.length); 403 desktopSearchFooter.innerHTML = footerHtml; 404 desktopSearchFooter.hidden = '' === footerHtml; 405 } 406 407 api.browse.applyDesktopMenuLayout(); 408 }; 409 410 const renderMobileSearchResults = () => { 411 if (!mobileSearchResults) { 412 return; 413 } 414 415 const activePanel = api.mobile.getActiveMobilePanel(); 416 const slot = activePanel?.querySelector('.products-nav-mobile__search-slot'); 417 const isLayoutReady = Boolean( 418 slot 419 && mobileSearchContainer 420 && mobileSearchContainer.parentElement === slot 421 && mobileMenu.classList.contains('is-search-active') 422 && mobileSearchResults.parentElement === activePanel 423 ); 424 425 if (!isLayoutReady) { 426 syncMobileSearchLayout(); 427 } 428 429 renderMobileSearchResultsContent(); 430 }; 431 432 const renderBrowseMode = () => { 433 desktopColumns.classList.remove('is-search-mode'); 434 if (desktopSearchFooter) { 435 desktopSearchFooter.hidden = true; 436 desktopSearchFooter.innerHTML = ''; 437 } 438 if (mobileSearchResults) { 439 mobileSearchResults.hidden = true; 440 mobileSearchResults.innerHTML = ''; 441 } 442 if (!isMobile()) { 443 api.browse.renderDesktopColumns(); 444 } 445 syncMobileSearchLayout(); 446 }; 447 448 const syncMenuRender = () => { 449 if (!searchEnabled) return; 450 451 updateSearchClearButtons(); 452 if (isSearchMode()) { 453 if (!isMobile()) { 454 renderSearchResults(); 455 } 456 if (!mobileMenu.hidden) { 457 renderMobileSearchResults(); 458 } 459 return; 460 } 461 462 renderBrowseMode(); 463 }; 464 465 const resetSearch = () => { 466 if (!searchEnabled) return; 467 468 if (searchDebounceTimer) { 469 window.clearTimeout(searchDebounceTimer); 470 searchDebounceTimer = null; 471 } 472 473 state.searchQuery = ''; 474 resetSearchDisplayedCount(); 475 syncSearchFieldValues(''); 476 updateSearchClearButtons(); 477 state.desktopHighlightPath = api.browse.getInitialDesktopPath(); 478 state.desktopPath = state.desktopHighlightPath.slice(); 479 state.desktopPendingTapKey = ''; 480 syncMenuRender(); 481 }; 482 483 const setSearchQuery = (value, immediate = false) => { 484 if (!searchEnabled) return; 485 486 const nextQuery = String(value ?? ''); 487 if (nextQuery.trim() !== state.searchQuery.trim()) { 488 resetSearchDisplayedCount(); 489 } 490 state.searchQuery = nextQuery; 491 syncSearchFieldValues(nextQuery); 492 updateSearchClearButtons(); 493 494 const run = () => { 495 if (!isSearchMode()) { 496 state.desktopHighlightPath = api.browse.getInitialDesktopPath(); 497 state.desktopPath = state.desktopHighlightPath.slice(); 498 } 499 syncMenuRender(); 500 }; 501 502 if (immediate || searchDebounceMs === 0) { 503 if (searchDebounceTimer) { 504 window.clearTimeout(searchDebounceTimer); 505 searchDebounceTimer = null; 506 } 507 run(); 508 return; 509 } 510 511 if (searchDebounceTimer) { 512 window.clearTimeout(searchDebounceTimer); 513 } 514 searchDebounceTimer = window.setTimeout(() => { 515 searchDebounceTimer = null; 516 run(); 517 }, searchDebounceMs); 518 }; 519 520 const bindEvents = () => { 521 if (!searchEnabled) return; 522 523 const { shell } = dom; 524 525 searchFields.forEach((field) => { 526 field.setAttribute('placeholder', searchPlaceholder); 527 field.addEventListener('input', (event) => { 528 setSearchQuery(event.target.value); 529 }); 530 field.addEventListener('keydown', (event) => { 531 if (event.key === 'Escape') { 532 event.preventDefault(); 533 resetSearch(); 534 } 535 }); 536 }); 537 538 searchClearButtons.forEach((button) => { 539 button.addEventListener('click', (event) => { 540 event.preventDefault(); 541 resetSearch(); 542 const activeField = searchFields.find((field) => { 543 const container = field.closest('[data-products-nav-search]'); 544 return container && !container.hidden; 545 }) || searchFields[0]; 546 activeField?.focus(); 547 }); 548 }); 549 550 shell.addEventListener('click', (event) => {
551 const loadMoreButton = event.target.closest('[data-products-nav-search-load-more]'); 552 if (loadMoreButton) { 553 event.preventDefault(); 554 loadMoreSearchResults(); 555 } 556 557 event.stopPropagation(); 558 }); 559 }; 560 561 return { 562 isSearchEnabled: () => searchEnabled, 563 isSearchMode, 564 setDesktopSearchVisible, 565 setMobileSearchVisible, 566 syncMobileSearchLayout, 567 resetSearch, 568 syncMenuRender, 569 bindEvents, 570 }; 571 } 572 573 window.ammwebProductNavSearch = { create }; 574}(window));
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.