diff --git a/blocks/header/header.js b/blocks/header/header.js index c373678..bdff3ee 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -175,6 +175,27 @@ function decorateNav(section) { }); } +/** + * Resolve the locale prefix from the current URL path. + * @returns {string} Locale path segment (e.g. en, fr) + */ +function getLocaleFromPath() { + const segment = window.location.pathname.split('/').filter(Boolean)[0]; + if (segment && /^[a-z]{2}(-[a-z]{2})?$/i.test(segment)) { + return segment.split('-')[0].toLowerCase(); + } + return 'en'; +} + +/** + * Whether the page includes a search results widget. + * @returns {boolean} + */ +function pageHasSearchWidget() { + return !!window.hlx?.searchWidgetOnPage + || !!document.querySelector('.widget.search, .search.widget, .widget a[href*="/widgets/search/"]'); +} + /** * Builds a search form with an input and submit button inside the section. * @param {Element} section - The search section element @@ -205,14 +226,35 @@ function decorateSearch(section) { section.textContent = ''; section.append(form); + const locale = getLocaleFromPath(); + const searchResultsPath = `/${locale}/search`; + + const params = new URLSearchParams(window.location.search); + if (params.get('search')) { + input.value = params.get('search'); + } + form.addEventListener('submit', (e) => { e.preventDefault(); const query = input.value.trim(); - if (query) { - const q = encodeURIComponent(query); - const path = encodeURIComponent(window.location.pathname); - window.location.href = `/en/search/search-results.html?search=${q}&pagePath=${path}`; + if (!query) return; + if (pageHasSearchWidget()) { + input.dispatchEvent(new Event('input', { bubbles: true })); + return; } + window.location.href = `${searchResultsPath}?search=${encodeURIComponent(query)}`; + }); + + document.dispatchEvent(new CustomEvent('header-search-ready', { detail: { input } })); + window.hlx = window.hlx || {}; + window.hlx.headerSearch = input; + + const searchModulePath = `${window.hlx?.codeBasePath || ''}/widgets/search/search.js`; + import(searchModulePath).then((mod) => { + mod.initHeaderSearch(input, { + anchor: section, + resultsPath: searchResultsPath, + }); }); } diff --git a/widgets/search/search.css b/widgets/search/search.css new file mode 100644 index 0000000..e8cc8b6 --- /dev/null +++ b/widgets/search/search.css @@ -0,0 +1,133 @@ +/* container */ +.search { + max-width: 1200px; + margin: 0 auto; + scroll-margin-top: calc(var(--nav-height) + 1em); +} + +.search ol, +.search ul { + list-style: none; + margin: 0; + padding: 0; +} + +/* results count */ +.search .info { + margin: 0 0 1em; + padding-bottom: 1em; + border-bottom: 1px solid var(--color-border); + color: var(--color-text); +} + +.search .info[hidden], +.search .search-prompt[hidden], +.search .no-results[hidden] { + display: none; +} + +.search .search-prompt { + color: var(--color-muted); +} + +/* results list */ +.search .results { + display: flex; + flex-direction: column; + gap: 1em; + margin-bottom: 1.5em; +} + +.search .result { + display: flex; + gap: 1em; + align-items: start; + position: relative; + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 1.25em; + background: var(--color-bg); + transition: box-shadow 0.2s; +} + +.search .result:hover { + box-shadow: var(--shadow-s); +} + +.search .result .media-wrapper { + flex-shrink: 0; + width: 64px; + height: 64px; + overflow: hidden; + border-radius: var(--radius-s); +} + +.search .result .media-wrapper picture, +.search .result .media-wrapper picture > img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.search .result .body-wrapper { + min-width: 0; +} + +.search .result .title { + margin: 0 0 0.4em; + font-family: var(--heading-font-family); + font-size: var(--heading-s); + font-weight: 600; + line-height: 1.3; +} + +.search .result .desc { + margin: 0; + color: var(--color-muted); + font-size: var(--text-s); + line-height: 1.5; +} + +.search .result .link::before { + content: ''; + position: absolute; + inset: 0; +} + +/* highlight */ +.search .results mark { + outline: 1px solid var(--color-brand); + background-color: var(--color-brand); +} + +/* pagination */ +.search .pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5em 1em; +} + +.search .pagination[hidden] { + display: none; +} + +.search .pagination .ellipsis { + display: flex; + align-items: center; + justify-content: center; +} + +.search .pagination ol { + display: flex; + flex-wrap: wrap; + gap: 0.5em; +} + +.search .pagination ol button[aria-current] { + border-color: var(--color-text); + background-color: var(--color-text); + color: var(--color-bg); +} diff --git a/widgets/search/search.html b/widgets/search/search.html new file mode 100644 index 0000000..5cdd8ff --- /dev/null +++ b/widgets/search/search.html @@ -0,0 +1,16 @@ + +

Use the search box in the header to find content across the site.

+ + + diff --git a/widgets/search/search.js b/widgets/search/search.js new file mode 100644 index 0000000..014f8e4 --- /dev/null +++ b/widgets/search/search.js @@ -0,0 +1,874 @@ +import { createOptimizedPicture, loadCSS } from '../../scripts/aem.js'; + +/** + * Load widget copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (e.g. en) + * @returns {Promise} Copy for that language (flat key-value) + */ +async function loadWidgetCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + try { + const resp = await fetch(url); + if (!resp.ok) return {}; + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key] || {}; + } catch (_) { + return {}; + } +} + +/** + * Resolve the current site locale from the URL path or document language. + * @returns {string} Locale code (e.g. en, fr) + */ +function getLocale() { + const segment = window.location.pathname.split('/').filter(Boolean)[0]; + if (segment && /^[a-z]{2}(-[a-z]{2})?$/i.test(segment)) { + return segment.split('-')[0].toLowerCase(); + } + return (document.documentElement.lang || 'en').split('-')[0].toLowerCase(); +} + +/** + * Normalize a single item from the query index. + * @param {Object} row - Raw row from query-index.json + * @returns {Object} Normalized search item + */ +function normalizeItem(row) { + const path = row.path || row.url || ''; + return { + path, + title: (row.title || '').trim(), + description: (row.description || '').trim(), + image: row.image || '', + }; +} + +/** + * Fetch and cache the locale-specific query index. + * @param {string} [locale] - Locale code (defaults to current page locale) + * @returns {Promise>} Normalized items + */ +async function loadSearchIndex(locale = getLocale()) { + window.searchResultsIndexByLocale = window.searchResultsIndexByLocale || {}; + if (window.searchResultsIndexByLocale[locale]) { + return window.searchResultsIndexByLocale[locale]; + } + + window.searchResultsIndexPromises = window.searchResultsIndexPromises || {}; + if (!window.searchResultsIndexPromises[locale]) { + window.searchResultsIndexPromises[locale] = (async () => { + const base = window.hlx?.codeBasePath || ''; + try { + const resp = await fetch(`${base}/${locale}/query-index.json`); + if (!resp.ok) { + window.searchResultsIndexByLocale[locale] = []; + return []; + } + const json = await resp.json(); + const rows = Array.isArray(json.data) ? json.data : []; + const items = rows.map(normalizeItem).filter((item) => item.path); + window.searchResultsIndexByLocale[locale] = items; + return items; + } catch (_) { + window.searchResultsIndexByLocale[locale] = []; + return []; + } + })(); + } + + return window.searchResultsIndexPromises[locale]; +} + +/** + * Remove diacritical marks for accent-insensitive matching. + * @param {string} str - Input string + * @returns {string} + */ +function removeAccents(str) { + if (!str) return ''; + return str.normalize('NFD').replace(/\p{Diacritic}/gu, ''); +} + +/** + * Normalize string for search: lowercase and remove accents. + * @param {string} str - Input string + * @returns {string} + */ +function normalizeForSearch(str) { + return removeAccents((str || '').toLowerCase()); +} + +/** + * Split a search string into normalized terms. + * @param {string} searchTerm - Raw user input + * @returns {string[]} + */ +function parseSearchTerms(searchTerm) { + if (!searchTerm || !searchTerm.trim()) return []; + return searchTerm.trim().split(/\s+/).map((term) => normalizeForSearch(term)).filter(Boolean); +} + +/** + * Original (display) terms from a search string. + * @param {string} searchTerm - Raw user input + * @returns {string[]} + */ +function parseDisplayTerms(searchTerm) { + if (!searchTerm || !searchTerm.trim()) return []; + return searchTerm.trim().split(/\s+/).filter(Boolean); +} + +/** + * Whether a single normalized term matches any searchable field on an item. + * @param {Object} item - Normalized search item + * @param {string} termNorm - Normalized search term + * @returns {boolean} + */ +function termMatchesItem(item, termNorm) { + const fields = [item.title, item.description]; + return fields.some((field) => normalizeForSearch(field || '').includes(termNorm)); +} + +/** + * Filter index by search term (accent-insensitive). + * @param {Array} index - Normalized items + * @param {string} searchTerm - Search string + * @returns {Array} Filtered items with match info + */ +function filterBySearch(index, searchTerm) { + if (!searchTerm || !searchTerm.trim()) { + return index.map((item) => ({ ...item, searchTerm: '', searchTerms: [] })); + } + + const terms = parseSearchTerms(searchTerm); + const displayTerms = parseDisplayTerms(searchTerm); + + return index.filter((item) => terms.every((term) => termMatchesItem(item, term))) + .map((item) => ({ + ...item, + searchTerm: searchTerm.trim().toLowerCase(), + searchTerms: displayTerms, + })); +} + +/** + * Whether an item has a usable image. + * @param {Object} item - Normalized search item + * @returns {boolean} + */ +function hasImage(item) { + const image = item?.image?.trim(); + if (!image || !image.startsWith('https://')) return false; + if (image.toLowerCase().startsWith('data:')) return false; + return !image.includes('default-meta-image'); +} + +/** + * Sort key for a single term against an item. + * @param {Object} item - Normalized search item + * @param {string} termNorm - Normalized search term + * @returns {number[]} + */ +function getTermSortKey(item, termNorm) { + const titleNorm = normalizeForSearch(item.title || ''); + const descNorm = normalizeForSearch(item.description || ''); + const titleIdx = titleNorm.indexOf(termNorm); + const descIdx = descNorm.indexOf(termNorm); + if (titleIdx !== -1) return [0, titleIdx]; + if (descIdx !== -1) return [1, descIdx]; + return [2, Number.MAX_SAFE_INTEGER]; +} + +/** + * Sort by relevance: title match before description match. + * Items with an image rank before those without. + * @param {Array} results - Filtered results + * @param {string} searchTerm - Search string + */ +function sortByRelevance(results, searchTerm) { + const imageRank = (item) => (hasImage(item) ? 0 : 1); + + if (!searchTerm || !searchTerm.trim()) { + results.sort((a, b) => imageRank(a) - imageRank(b)); + return; + } + + const terms = parseSearchTerms(searchTerm); + results.sort((a, b) => { + const keyA = terms.flatMap((term) => getTermSortKey(a, term)); + const keyB = terms.flatMap((term) => getTermSortKey(b, term)); + const len = Math.max(keyA.length, keyB.length); + for (let i = 0; i < len; i += 1) { + const diff = (keyA[i] ?? 0) - (keyB[i] ?? 0); + if (diff !== 0) return diff; + } + return imageRank(a) - imageRank(b); + }); +} + +/** + * Build a map from normalized index to original string index. + * @param {string} original - Original text + * @returns {number[]} normalizedIndex → originalIndex + */ +function getNormalizedToOriginalMap(original) { + const map = []; + for (let i = 0; i < original.length; i += 1) { + const norm = removeAccents(original[i]); + for (let j = 0; j < norm.length; j += 1) map.push(i); + } + return map; +} + +/** + * Escape a plain-text string for safe insertion into HTML. + * @param {string} str - Raw string + * @returns {string} HTML-safe string + */ +function escapeHTML(str) { + const el = document.createElement('span'); + el.textContent = str; + return el.innerHTML; +} + +/** + * Highlight matching substrings in text for multiple search terms. + * @param {string} text - Full text + * @param {string[]} terms - Terms to highlight + * @returns {string} HTML with `mark` elements wrapping matched substrings + */ +function highlightTerms(text, terms) { + if (!text || !terms?.length) return escapeHTML(text); + + const intervals = []; + terms.forEach((term) => { + const termNorm = normalizeForSearch(term); + if (!termNorm) return; + const textNorm = normalizeForSearch(text); + const map = getNormalizedToOriginalMap(text); + let start = 0; + while (start < textNorm.length) { + const idx = textNorm.indexOf(termNorm, start); + if (idx === -1) break; + const origStart = map[idx]; + const endIdx = idx + termNorm.length - 1; + const origEnd = endIdx < map.length ? map[endIdx] + 1 : text.length; + intervals.push([origStart, origEnd]); + start = idx + termNorm.length; + } + }); + + if (!intervals.length) return escapeHTML(text); + + intervals.sort((a, b) => a[0] - b[0]); + const merged = [intervals[0]]; + for (let i = 1; i < intervals.length; i += 1) { + const last = merged[merged.length - 1]; + if (intervals[i][0] <= last[1]) { + last[1] = Math.max(last[1], intervals[i][1]); + } else { + merged.push(intervals[i]); + } + } + + let result = ''; + let pos = 0; + merged.forEach(([start, end]) => { + result += escapeHTML(text.substring(pos, start)); + result += `${escapeHTML(text.substring(start, end))}`; + pos = end; + }); + result += escapeHTML(text.substring(pos)); + return result; +} + +/** + * Build a media wrapper with an optimized picture when available. + * @param {Object} item - Normalized search item + * @param {number} [width=120] - Pixel width hint + * @returns {HTMLElement|null} + */ +function createMediaWrapper(item, width = 120) { + if (!hasImage(item)) return null; + const wrapper = document.createElement('div'); + wrapper.className = 'media-wrapper'; + wrapper.appendChild(createOptimizedPicture(item.image, '', false, [{ width }])); + return wrapper; +} + +/** + * Create a result card for the search results list. + * @param {Object} item - Normalized search item + * @returns {HTMLElement} + */ +function createResultCard(item) { + const li = document.createElement('li'); + li.className = 'result'; + + const media = createMediaWrapper(item); + if (media) li.appendChild(media); + + const body = document.createElement('div'); + body.className = 'body-wrapper'; + + const titleText = item.title || ''; + if (titleText) { + const heading = document.createElement('p'); + heading.className = 'title'; + const link = document.createElement('a'); + link.href = item.path || '#'; + link.className = 'link'; + link.innerHTML = item.searchTerms?.length + ? highlightTerms(titleText, item.searchTerms) + : escapeHTML(titleText); + heading.appendChild(link); + body.appendChild(heading); + } + + const descText = (item.description || '').trim(); + if (descText) { + const desc = document.createElement('p'); + desc.className = 'desc'; + desc.innerHTML = item.searchTerms?.length + ? highlightTerms(descText, item.searchTerms) + : escapeHTML(descText); + body.appendChild(desc); + } + + li.appendChild(body); + return li; +} + +/** + * Create a compact suggestions result row. + * @param {Object} item - Normalized search item + * @returns {HTMLElement} + */ +function createSuggestionsItem(item) { + const li = document.createElement('li'); + li.className = 'result'; + + const media = createMediaWrapper(item, 88); + if (media) li.appendChild(media); + + const bodyWrapper = document.createElement('div'); + bodyWrapper.className = 'body-wrapper'; + + const titleEl = document.createElement('p'); + titleEl.className = 'title'; + const link = document.createElement('a'); + link.href = item.path || '#'; + link.className = 'link'; + const titleText = item.title || ''; + link.innerHTML = item.searchTerms?.length + ? highlightTerms(titleText, item.searchTerms) + : escapeHTML(titleText); + titleEl.appendChild(link); + bodyWrapper.appendChild(titleEl); + + const descText = (item.description || '').trim(); + if (descText) { + const meta = document.createElement('p'); + meta.className = 'meta'; + const excerpt = descText.length > 100 ? `${descText.slice(0, 100)}…` : descText; + meta.innerHTML = item.searchTerms?.length + ? highlightTerms(excerpt, item.searchTerms) + : escapeHTML(excerpt); + bodyWrapper.appendChild(meta); + } + + li.appendChild(bodyWrapper); + return li; +} + +/** + * Read filter config from URL query params. + * @returns {Object} + */ +function getConfigFromURL() { + const params = new URLSearchParams(window.location.search); + const config = {}; + params.forEach((value, key) => { config[key] = value; }); + return config; +} + +/** + * Update URL with current filter state. + * @param {Object} filterConfig - Current filter values + */ +function updateURL(filterConfig) { + const params = new URLSearchParams(); + Object.keys(filterConfig).forEach((key) => { + if (key === 'page' && filterConfig[key] === 1) return; + const val = filterConfig[key]; + if (val && (typeof val !== 'string' || val.trim())) { + if (key !== 'page' || val !== 1) params.set(key, val); + } + }); + const newURL = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.pushState({ filterConfig }, '', newURL); +} + +/** + * Run a filtered, sorted search against the index. + * @param {string} searchTerm - Raw user input + * @param {number} [limit] - Optional maximum number of results + * @returns {Promise>} + */ +async function searchItems(searchTerm, limit) { + const index = await loadSearchIndex(); + const results = filterBySearch(index, searchTerm); + sortByRelevance(results, searchTerm); + return limit ? results.slice(0, limit) : results; +} + +/** + * Hydrate all [data-copy] elements from widget copy. + * @param {HTMLElement} container - .search root element + * @param {Object} copy - Widget copy for the current language + */ +function hydrateCopy(container, copy) { + container.querySelectorAll('[data-copy]').forEach((el) => { + const value = copy[el.dataset.copy]; + if (!value) return; + const target = el.dataset.copyTarget; + if (target) { + target.split(',').forEach((attr) => el.setAttribute(attr.trim(), value)); + } else el.textContent = value; + }); +} + +const ITEMS_PER_PAGE = 16; +const SUGGESTIONS_DEBOUNCE_MS = 150; + +let searchResultsContainer = null; +let headerSearchInput = document.getElementById('header-search') + || window.hlx?.headerSearch; +let suggestionsDestroy = null; + +/** + * Whether the page includes a search results widget. + * @returns {boolean} + */ +function pageHasSearchWidget() { + if (searchResultsContainer) return true; + return !!document.querySelector( + '.widget.search, .search.widget, .widget a[href*="/widgets/search/"]', + ); +} + +/** + * Tear down the header suggestions overlay if active. + */ +function destroySuggestions() { + if (suggestionsDestroy) { + suggestionsDestroy(); + suggestionsDestroy = null; + } +} + +/** + * Render one page of results into the list element. + * @param {HTMLElement} element - .results list element + * @param {Array} results - Full filtered result set + * @param {number} page - Page number (1-based) + */ +function displayResults(element, results, page) { + element.innerHTML = ''; + const start = (page - 1) * ITEMS_PER_PAGE; + results.slice(start, start + ITEMS_PER_PAGE) + .forEach((item) => element.append(createResultCard(item))); +} + +/** + * Render pagination controls into the nav element. + * @param {HTMLElement} element - .pagination nav element + * @param {number} totalResults - Total number of results + * @param {number} page - Current page number (1-based) + */ +function displayPagination(element, totalResults, page) { + if (!element) return; + const pageNum = parseInt(page, 10) || 1; + const totalPages = Math.ceil(totalResults / ITEMS_PER_PAGE); + const prevBtn = element.querySelector('button:first-child'); + const nextBtn = element.querySelector('button:last-child'); + const pagesList = element.querySelector('ol'); + + pagesList.innerHTML = ''; + + if (totalPages <= 1) { + element.hidden = true; + return; + } + + element.hidden = false; + + prevBtn.disabled = pageNum <= 1; + if (pageNum > 1) prevBtn.dataset.page = pageNum - 1; + else delete prevBtn.dataset.page; + + nextBtn.disabled = pageNum >= totalPages; + if (pageNum < totalPages) nextBtn.dataset.page = pageNum + 1; + else delete nextBtn.dataset.page; + + const ellipsis = () => { + const li = document.createElement('li'); + li.classList.add('ellipsis'); + li.setAttribute('aria-hidden', true); + li.textContent = '…'; + return li; + }; + + const pageItem = (num, current = false) => { + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button secondary'; + btn.textContent = num; + btn.dataset.page = num; + if (current) btn.setAttribute('aria-current', 'page'); + li.appendChild(btn); + return li; + }; + + if (pageNum > 3) { + pagesList.appendChild(pageItem(1)); + if (pageNum > 4) pagesList.appendChild(ellipsis()); + } + for (let i = Math.max(1, pageNum - 2); i <= Math.min(totalPages, pageNum + 2); i += 1) { + pagesList.appendChild(pageItem(i, i === pageNum)); + } + if (pageNum < totalPages - 2) { + if (pageNum < totalPages - 3) pagesList.appendChild(ellipsis()); + pagesList.appendChild(pageItem(totalPages)); + } +} + +/** + * Wire search, pagination, and URL state to the container. + * Uses the header search input instead of an in-widget search box. + * @param {HTMLElement} container - .search root + * @param {HTMLInputElement} searchElement - Header search input + * @param {Object} config - Initial config + */ +function buildSearchFiltering(container, searchElement, config = {}) { + if (!searchElement) return; + + let currentPage = 1; + const resultsElement = container.querySelector('.results'); + const infoElement = container.querySelector('.info'); + const queryElement = container.querySelector('#results-query'); + const paginationElement = container.querySelector('.pagination'); + const promptElement = container.querySelector('.search-prompt'); + const noResultsElement = container.querySelector('.no-results'); + + const showEmptyState = () => { + resultsElement.innerHTML = ''; + if (paginationElement) { + paginationElement.querySelector('ol').innerHTML = ''; + paginationElement.hidden = true; + } + if (infoElement) infoElement.hidden = true; + if (noResultsElement) noResultsElement.hidden = true; + if (promptElement) promptElement.hidden = false; + }; + + const createFilterConfig = (resetPage = true) => { + const filterConfig = { ...config }; + filterConfig.search = searchElement.value; + filterConfig.page = resetPage ? 1 : currentPage; + if (resetPage) currentPage = 1; + return filterConfig; + }; + + const runSearch = async (filterConfig = config, updateURLState = true) => { + const query = (filterConfig.search || '').trim(); + if (!query) { + showEmptyState(); + if (updateURLState) updateURL({ search: '', page: 1 }); + return; + } + + if (promptElement) promptElement.hidden = true; + const results = await searchItems(query); + + const page = parseInt(filterConfig.page, 10) || 1; + currentPage = page; + + const totalResults = results.length; + const startNum = totalResults > 0 ? (page - 1) * ITEMS_PER_PAGE + 1 : 0; + const endNum = Math.min(page * ITEMS_PER_PAGE, totalResults); + + const hasResults = totalResults > 0; + if (infoElement) infoElement.hidden = !hasResults; + if (noResultsElement) noResultsElement.hidden = hasResults; + if (queryElement) queryElement.textContent = query; + container.querySelector('#results-start').textContent = startNum; + container.querySelector('#results-end').textContent = endNum; + + displayResults(resultsElement, results, page); + if (page > 1) container.scrollIntoView({ behavior: 'smooth', block: 'start' }); + displayPagination(paginationElement, totalResults, page); + + if (updateURLState) updateURL(filterConfig); + }; + + searchElement.addEventListener('input', () => runSearch(createFilterConfig(true))); + + if (paginationElement) { + paginationElement.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-page]'); + if (!btn || btn.disabled) return; + currentPage = parseInt(btn.dataset.page, 10); + runSearch(createFilterConfig(false)); + }); + } + + const urlConfig = getConfigFromURL(); + const initialConfig = { ...config, ...urlConfig }; + if (urlConfig.page) currentPage = parseInt(urlConfig.page, 10); + if (urlConfig.search) searchElement.value = urlConfig.search; + + loadSearchIndex(); + + if (initialConfig.search?.trim()) { + runSearch(initialConfig); + } else { + showEmptyState(); + } + + window.addEventListener('popstate', (e) => { + if (e.state?.filterConfig) { + const saved = e.state.filterConfig; + if (saved.search !== undefined) searchElement.value = saved.search || ''; + if (saved.page) currentPage = parseInt(saved.page, 10); + runSearch(saved, false); + } + }); +} + +/** + * Wire the search results widget once both the widget and header input exist. + */ +function tryWireSearchResults() { + if (!searchResultsContainer || !headerSearchInput) return; + if (searchResultsContainer.dataset.searchWired === 'true') return; + searchResultsContainer.dataset.searchWired = 'true'; + destroySuggestions(); + buildSearchFiltering(searchResultsContainer, headerSearchInput, {}); +} + +document.addEventListener('header-search-ready', (e) => { + headerSearchInput = e.detail.input; + if (pageHasSearchWidget()) { + tryWireSearchResults(); + } +}); + +/** + * Attach suggestions search to an input, loading on first interaction. + * @param {HTMLInputElement} input - Search input element + * @param {Object} [options] + * @param {HTMLElement} [options.anchor] - Element to align overlay with + * @param {string} [options.resultsPath='/search'] - Full results page path + * @param {number} [options.maxResults=8] - Max suggestions shown + * @returns {Promise<{ destroy: () => void }>} + */ +export async function attachSearchSuggestions(input, opts = {}) { + if (input.dataset.searchSuggestions === 'true') { + return { destroy: () => {} }; + } + input.dataset.searchSuggestions = true; + + const { + anchor = input.closest('form') || input.parentElement, + resultsPath = '/search', + maxResults = 8, + } = opts; + + if (!anchor) { + return { destroy: () => {} }; + } + + await loadCSS(`${window.hlx?.codeBasePath || ''}/widgets/search/suggestions.css`); + + const lang = (document.documentElement.lang || 'en').split('-')[0]; + const copy = await loadWidgetCopy(lang); + + const overlay = document.createElement('div'); + overlay.id = 'search-suggestions'; + overlay.className = 'search suggestions'; + overlay.hidden = true; + + const list = document.createElement('ul'); + list.className = 'results'; + overlay.appendChild(list); + + const footer = document.createElement('footer'); + const viewAll = document.createElement('a'); + viewAll.classList.add('button', 'primary'); + viewAll.textContent = copy.viewAllResults || 'View all results'; + footer.appendChild(viewAll); + overlay.appendChild(footer); + + anchor.style.position = 'relative'; + anchor.appendChild(overlay); + + let debounceTimer; + + const getFocusableLinks = () => [ + ...list.querySelectorAll('.link'), + ...(viewAll.href ? [viewAll] : []), + ]; + + const updateViewAllHref = (query) => { + viewAll.href = query + ? `${resultsPath}?search=${encodeURIComponent(query)}` + : resultsPath; + }; + + const hideOverlay = () => { + overlay.hidden = true; + }; + + const showOverlay = () => { + overlay.hidden = false; + }; + + const renderResults = async (query) => { + const trimmed = query.trim(); + if (!trimmed) { + list.innerHTML = ''; + hideOverlay(); + return; + } + + const results = await searchItems(trimmed, maxResults); + list.innerHTML = ''; + + if (!results.length) { + const empty = document.createElement('li'); + empty.className = 'no-results'; + empty.textContent = copy.noResults || 'No results found'; + list.appendChild(empty); + } else { + results.forEach((item) => list.appendChild(createSuggestionsItem(item))); + } + + updateViewAllHref(trimmed); + showOverlay(); + }; + + const scheduleSearch = () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => renderResults(input.value), SUGGESTIONS_DEBOUNCE_MS); + }; + + const onDocumentClick = (e) => { + if (anchor.contains(e.target)) return; + hideOverlay(); + }; + + const navigate = (e, links) => { + const current = links.indexOf(document.activeElement); + if (e.key === 'ArrowDown') { + e.preventDefault(); + links[current < links.length - 1 ? current + 1 : 0]?.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (current <= 0) input.focus(); + else links[current - 1]?.focus(); + } else if (e.key === 'Escape') { + e.preventDefault(); + hideOverlay(); + input.focus(); + } else if (e.key === 'Tab') { + hideOverlay(); + } + }; + + const onInputKeydown = (e) => { + if (overlay.hidden) return; + const links = getFocusableLinks(); + if (!links.length) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + links[0].focus(); + } else if (e.key === 'Escape') { + e.preventDefault(); + hideOverlay(); + } else if (e.key === 'Tab') { + hideOverlay(); + } + }; + + const onOverlayKeydown = (e) => navigate(e, getFocusableLinks()); + + const onInputFocus = () => { + if (list.children.length) showOverlay(); + }; + + input.addEventListener('focus', onInputFocus); + input.addEventListener('input', scheduleSearch); + input.addEventListener('keydown', onInputKeydown); + overlay.addEventListener('keydown', onOverlayKeydown); + document.addEventListener('click', onDocumentClick); + + if (input.value.trim()) renderResults(input.value); + + const destroy = () => { + clearTimeout(debounceTimer); + input.removeEventListener('focus', onInputFocus); + input.removeEventListener('input', scheduleSearch); + input.removeEventListener('keydown', onInputKeydown); + overlay.removeEventListener('keydown', onOverlayKeydown); + document.removeEventListener('click', onDocumentClick); + overlay.remove(); + delete input.dataset.searchSuggestions; + }; + + return { destroy }; +} + +/** + * Initialize header search: live results when a search widget is on the page, + * otherwise typeahead suggestions. + * @param {HTMLInputElement} input - Header search input + * @param {Object} [opts] + * @param {HTMLElement} [opts.anchor] - Element to align suggestions overlay with + * @param {string} [opts.resultsPath] - Full search results page path + */ +export async function initHeaderSearch(input, opts = {}) { + headerSearchInput = input; + + if (pageHasSearchWidget()) { + tryWireSearchResults(); + return; + } + + const { destroy } = await attachSearchSuggestions(input, opts); + suggestionsDestroy = destroy; +} + +/** + * Decorates the search results widget. + * @param {HTMLElement} widget - Widget container element + */ +export default async function decorate(widget) { + const lang = (document.documentElement.lang || 'en').split('-')[0]; + const copy = await loadWidgetCopy(lang); + + hydrateCopy(widget, copy); + searchResultsContainer = widget; + window.hlx = window.hlx || {}; + window.hlx.searchWidgetOnPage = true; + tryWireSearchResults(); +} + +export { loadSearchIndex, filterBySearch, searchItems }; diff --git a/widgets/search/search.json b/widgets/search/search.json new file mode 100644 index 0000000..00a29d0 --- /dev/null +++ b/widgets/search/search.json @@ -0,0 +1,15 @@ +{ + "en": { + "search": "Search", + "searchFormLabel": "Search", + "resultsFor": "Results For", + "previous": "Previous", + "next": "Next", + "paginationLabel": "Pagination", + "viewAllResults": "View all results", + "noResults": "No results found", + "noResultsTitle": "No results found", + "noResultsText": "Try different keywords or check your spelling.", + "emptyStateIntro": "Use the search box in the header to find content across the site. Results appear here as you type." + } +} diff --git a/widgets/search/suggestions.css b/widgets/search/suggestions.css new file mode 100644 index 0000000..d51ed3f --- /dev/null +++ b/widgets/search/suggestions.css @@ -0,0 +1,112 @@ +.search.suggestions { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 2; + max-height: calc(100dvh - var(--nav-height)); + overflow: hidden auto; + border: 1px solid var(--color-border); + border-radius: 4px; + background-color: var(--color-bg); + color: var(--color-text); + box-shadow: var(--shadow-s); +} + +.search.suggestions[hidden] { + display: none; +} + +.search.suggestions .results { + list-style: none; + margin: 0; + padding: 0; +} + +.search.suggestions .result { + display: flex; + gap: 0.75em; + align-items: start; + position: relative; + padding: 0.75em 1em; + transition: background-color 0.2s; +} + +.search.suggestions .result + .result { + border-top: 1px solid var(--color-border); +} + +.search.suggestions .result:hover, +.search.suggestions .result:focus-within { + background-color: var(--color-bg-light); +} + +.search.suggestions .media-wrapper { + flex-shrink: 0; + width: 44px; + height: 44px; + overflow: hidden; + border-radius: var(--radius-s); +} + +.search.suggestions .media-wrapper picture, +.search.suggestions .media-wrapper picture > img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.search.suggestions .body-wrapper { + min-width: 0; + line-height: 1.3; +} + +.search.suggestions .title { + margin: 0 0 0.2em; + font-weight: 600; +} + +.search.suggestions .title .link { + color: var(--color-text); + text-decoration: none; +} + +.search.suggestions .title .link:hover, +.search.suggestions .title .link:focus-visible { + color: var(--color-text); + text-decoration: underline; +} + +.search.suggestions .link::before { + content: ''; + position: absolute; + inset: 0; +} + +.search.suggestions .meta { + overflow: hidden; + margin: 0; + color: var(--color-muted); + font-size: var(--text-s); + text-overflow: ellipsis; + white-space: nowrap; +} + +.search.suggestions footer a { + display: block; + border-radius: 0; + padding: 0.75em 1em; + text-align: center; +} + +/* highlight */ +.search.suggestions .results mark { + outline: 1px solid var(--color-brand); + background-color: var(--color-brand); +} + +.search.suggestions .no-results { + padding: 1em; + text-align: center; +}