diff --git a/hlx_statics/blocks/header/header.js b/hlx_statics/blocks/header/header.js index 0fb4842e..8c22d6d2 100644 --- a/hlx_statics/blocks/header/header.js +++ b/hlx_statics/blocks/header/header.js @@ -70,11 +70,25 @@ async function initSearch() { const { connectAutocomplete } = instantsearch.connectors; - const searchClient = window.algoliasearch.algoliasearch(ALGOLIA_CONFIG.APP_KEY, ALGOLIA_CONFIG.API_KEY); + const algoliaClient = window.algoliasearch.algoliasearch(ALGOLIA_CONFIG.APP_KEY, ALGOLIA_CONFIG.API_KEY); const SUGGESTION_MAX_RESULTS = 50; const SEARCH_MAX_RESULTS = 100; - const SEARCH_MIN_QUERY_LENGTH = 3; - const isSearchableQuery = (q) => q.trim().length >= SEARCH_MIN_QUERY_LENGTH; + const isSearchableQuery = (q) => q.trim() !== ''; + const searchClient = { // "To prevent the initial empty query, you must wrap a custom search client around..." source: https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/js + ...algoliaClient, + search(requests) { + if (requests.every(({ params }) => !isSearchableQuery(params.query ?? ''))) { + return Promise.resolve({ + results: requests.map(() => ({ + hits: [], nbHits: 0, nbPages: 0, page: 0, + processingTimeMS: 0, hitsPerPage: 0, + exhaustiveNbHits: false, query: '', params: '', + })), + }); + } + return algoliaClient.search(requests); + }, + }; const indices = window.adp_search.indices const indexToProduct = window.adp_search.index_to_product; @@ -125,9 +139,17 @@ async function initSearch() { let results = new Map(); search.start(); + + let currentDynamicWidgets = []; // Widgets that change on each call + let staticWidgetsAdded = false; // One-time widgets // Function to initialize or update the search function updateSearch() { + // Remove widgets from the previous call before adding new ones + if (currentDynamicWidgets.length) { + search.removeWidgets(currentDynamicWidgets); + currentDynamicWidgets = []; + } // Get indices corresponding to selected products const selectedIndices = indices.filter((indexName) => { const product = indexToProduct[indexName]; @@ -143,14 +165,14 @@ async function initSearch() { // Calculate hits dynamically based number of selected indices const hits = Math.min(15, Math.max(4, Math.floor(SUGGESTION_MAX_RESULTS / selectedIndices.length))); - // Add common widgets like hits per index and how long results are (content) - search.addWidgets([ - instantsearch.widgets.configure({ - hitsPerPage: hits, - attributesToHighlight: ['title', 'content'], - attributesToSnippet: ['content:50'], - }), - ]); + // Add common widgets like hits per index and how long results are (content) - and save reference so it can be removed on the next call + const configureWidget = instantsearch.widgets.configure({ + hitsPerPage: hits, + attributesToHighlight: ['title', 'content'], + attributesToSnippet: ['content:50'], + }); + currentDynamicWidgets.push(configureWidget); + search.addWidgets([configureWidget]); // Custom InstantSearch search box to deal with suggestions and full results which depends on user input function customSearchBox() { return { init({ helper }) { @@ -230,8 +252,12 @@ async function initSearch() { if (event.key === 'Enter') { searchCleared = false; // Reset cleared flag when user presses Enter const trimmed = searchInput.value.trim(); - if (trimmed !== '' && !isSearchableQuery(trimmed)) { - event.preventDefault(); + if (trimmed === '') { // If users presses enter with an empty query while in a full search, clear results and show suggestions again + searchResults.classList.remove('has-results'); + searchResults.style.visibility = 'hidden'; + outerSearchSuggestions.style.display = 'flex'; + suggestionsFlag = true; + searchExecuted = false; return; } helper.setQuery(searchInput.value).search(); @@ -277,7 +303,8 @@ async function initSearch() { // Process each hit // results.set(instantsearch.highlight({ hit, attribute: "title" }), { - url: hit.url, + // Add anchor link if it exists to URL + url: hit.fragment ? `${hit.url}${hit.fragment}` : hit.url, product: hit.product, content: instantsearch.snippet({ hit, attribute: 'content' }), }); @@ -298,21 +325,25 @@ async function initSearch() { } const customMergedHits = connectAutocomplete(mergedHits); - search.addWidgets([ - customSearchBox(), - customMergedHits({ - container: document.querySelector(searchBoxContainer) - }), - ]); - - // Loop through rest of indices - selectedIndices.slice(1).forEach((indexName) => { + // Only add the search box and hits renderer once — fixes event listeners duplicates + if (!staticWidgetsAdded) { search.addWidgets([ - instantsearch.widgets.index({ - indexName: indexName, + customSearchBox(), + customMergedHits({ + container: document.querySelector(searchBoxContainer) }), ]); - }); + staticWidgetsAdded = true; + } + + // Instead of looping through other indices - add a child index widget for rest of indices (the main index is always searched, so it doesn't need a widget) + const indexWidgets = selectedIndices + .filter((indexName) => indexName !== initialIndex) + .map((indexName) => instantsearch.widgets.index({ indexName })); + if (indexWidgets.length) { + currentDynamicWidgets.push(...indexWidgets); + search.addWidgets(indexWidgets); + } search.refresh(); } @@ -405,24 +436,7 @@ async function initSearch() { }); } - // Function that is called after each search render to hide/show product checkboxes - function updateCheckboxVisibility(productsWithResults) { - const allSelected = selectedProducts.length === allProducts.length; - - // loop over each product‐wrapper - document.querySelectorAll('.search-checkbox-div[data-product]').forEach((div) => { - const product = div.dataset.product; - if (allSelected) { - // only show those with results - div.style.display = productsWithResults.has(product) ? '' : 'none'; - } else { - // specific‐product mode: show all product checkboxes - div.style.display = ''; - } - }); - } - - // Function that attaches event listeners to each checkbox +// Function that attaches event listeners to each checkbox function attachCheckboxEventListeners() { const allProductsCheckbox = document.getElementById('checkbox-all-products'); const productCheckboxes = document.querySelectorAll('.filters input[type="checkbox"]:not(#checkbox-all-products)'); @@ -447,6 +461,9 @@ async function initSearch() { selectedProducts = Array.from(productCheckboxes) .filter((cb) => cb.checked) // Get checked product checkboxes .map((cb) => cb.value); + if (checkbox.checked) { // Add new selected product to the beginning of the list to prioritize it in results + selectedProducts = [checkbox.value, ...selectedProducts.filter((p) => p !== checkbox.value)]; + } if (selectedProducts.length === 0) { // If no products selected, revert to "All Products" @@ -476,18 +493,20 @@ async function initSearch() { // figure out which products have at least one hit const productsWithResults = new Set(productGroupedResults.keys()); - // hide/show checkboxes based on current results + mode - updateCheckboxVisibility(productsWithResults); - // determine display order const allProductsCheckbox = document.getElementById('checkbox-all-products'); const productsToShow = allProductsCheckbox.checked ? allProducts : selectedProducts; - const productsWith = [], productsWithout = []; - productsToShow.forEach((p) => - productsWithResults.has(p) ? productsWith.push(p) : productsWithout.push(p) - ); - const sorted = [...productsWith, ...productsWithout]; + let sorted; + if (allProductsCheckbox.checked) { + const productsWith = [], productsWithout = []; + productsToShow.forEach((p) => + productsWithResults.has(p) ? productsWith.push(p) : productsWithout.push(p) + ); + sorted = [...productsWith, ...productsWithout]; + } else { + sorted = productsToShow; // preserve selected order regardless of results + } // render each group sorted.forEach((product) => { @@ -536,18 +555,22 @@ async function initSearch() { // compute who has any suggestions const productsWithResults = new Set(productGroupedResults.keys()); - updateCheckboxVisibility(productsWithResults); const allProductsCheckbox = document.getElementById('checkbox-all-products'); const productsToShow = allProductsCheckbox.checked ? allProducts : selectedProducts; - const withHits = [], withoutHits = []; - productsToShow.forEach((p) => - productsWithResults.has(p) ? withHits.push(p) : withoutHits.push(p) - ); - const sorted = [...withHits, ...withoutHits]; + let sorted; + if (allProductsCheckbox.checked) { + const withHits = [], withoutHits = []; + productsToShow.forEach((p) => + productsWithResults.has(p) ? withHits.push(p) : withoutHits.push(p) + ); + sorted = [...withHits, ...withoutHits]; + } else { + sorted = productsToShow; // preserve selected order regardless of results + } // render each section sorted.forEach((product) => { @@ -1118,33 +1141,33 @@ export default async function decorate(block) { // check if documentation template then retrieve from config otherwise default back to google drive path let navPath; - if (IS_DEV_DOCS) { - const topNavHtml = await fetchTopNavHtml(); - if (topNavHtml) { - navigationLinks.innerHTML += topNavHtml; - } - } else { - navPath = cfg.nav || getClosestFranklinSubfolder(window.location.origin,'nav'); - let fragment = await loadFragment(navPath); - if (fragment == null) { - // load the default nav in franklin_assets folder nav - fragment = await loadFragment(getClosestFranklinSubfolder(window.location.origin, 'nav', true)); - } - const ul = fragment.querySelector("ul"); - ul.classList.add("menu"); - ul.setAttribute("id", "navigation-links"); - fragment.querySelectorAll("li").forEach((li, index) => { - if (index == 0) { - if (isTopLevelNav(window.location.pathname)) { - const homeLink = ul.querySelector('li:nth-child(1)'); - homeLink.className = 'navigation-home'; - } else { - li.classList.add("navigation-products"); - } - } - }); - navigationLinks = ul; - } + // if (IS_DEV_DOCS) { + // const topNavHtml = await fetchTopNavHtml(); + // if (topNavHtml) { + // navigationLinks.innerHTML += topNavHtml; + // } + // } else { + // navPath = cfg.nav || getClosestFranklinSubfolder(window.location.origin,'nav'); + // let fragment = await loadFragment(navPath); + // if (fragment == null) { + // // load the default nav in franklin_assets folder nav + // fragment = await loadFragment(getClosestFranklinSubfolder(window.location.origin, 'nav', true)); + // } + // const ul = fragment.querySelector("ul"); + // ul.classList.add("menu"); + // ul.setAttribute("id", "navigation-links"); + // fragment.querySelectorAll("li").forEach((li, index) => { + // if (index == 0) { + // if (isTopLevelNav(window.location.pathname)) { + // const homeLink = ul.querySelector('li:nth-child(1)'); + // homeLink.className = 'navigation-home'; + // } else { + // li.classList.add("navigation-products"); + // } + // } + // }); + // navigationLinks = ul; + // } navigationLinks.querySelectorAll('li > ul').forEach((dropDownList, index) => { let dropdownLinkDropdownHTML = '';