From 7e1484a4460ea935b53a9b8c04ea6f30b10c32b4 Mon Sep 17 00:00:00 2001 From: Bryan Deffley Date: Thu, 9 May 2024 13:35:23 -0400 Subject: [PATCH 1/9] add search files --- best-cigars-guide/blocks/search/search.css | 122 ++++++++++ best-cigars-guide/blocks/search/search.js | 269 +++++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100644 best-cigars-guide/blocks/search/search.css create mode 100644 best-cigars-guide/blocks/search/search.js diff --git a/best-cigars-guide/blocks/search/search.css b/best-cigars-guide/blocks/search/search.css new file mode 100644 index 0000000..04351b1 --- /dev/null +++ b/best-cigars-guide/blocks/search/search.css @@ -0,0 +1,122 @@ +/* search box */ +.search .search-box { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + align-items: center; +} + +.search .search-box input { + width: 100%; + border: 1px solid var(--dark-color); + padding: 5px 15px; + font: inherit; +} + +/* search results */ +.search ul.search-results { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(278px, 1fr)); + gap: 16px; + padding-left: 0; + list-style: none; +} + +.search ul.search-results > li { + border: 1px solid var(--dark-color); +} + +.search ul.search-results > li > a { + display: block; + background-color: var(--background-color); + color: currentcolor; + cursor: pointer; + height: 100%; +} + +.search ul.search-results > li > a:hover, +.search ul.search-results > li > a:focus { + text-decoration: none; +} + +.search ul.search-results > li .search-result-title, +.search ul.search-results > li p { + padding: 0 16px; +} + +.search ul.search-results > li .search-result-title { + font-size: var(--body-font-size-m); + font-weight: normal; +} + +.search ul.search-results > li .search-result-title a { + color: currentcolor; + text-decoration: none; +} + +.search ul.search-results > li p { + font-size: var(--body-font-size-s); +} + +.search ul.search-results > li .search-result-image { + aspect-ratio: 4 / 3; +} + +.search ul.search-results > li picture img { + display: block; + width: 100%; + object-fit: cover; +} + +/* no results */ +.search ul.search-results.no-results { + display: block; + padding-left: 32px; +} + +.search ul.search-results.no-results > li { + border: none; +} + +/* minimal variant */ +.search.minimal ul.search-results { + display: block; + padding-left: 32px; +} + +.search.minimal ul.search-results > li { + position: relative; + border: none; +} + +.search.minimal ul.search-results > li .search-result-title, +.search.minimal ul.search-results > li p { + padding: unset; +} + +.search.minimal ul.search-results > li .search-result-title a { + color: var(--link-color); +} + +/* stylelint-disable no-descending-specificity */ +.search.minimal ul.search-results > li > a { + background-color: unset; +} + +.search.minimal ul.search-results > li > a:hover a, +.search.minimal ul.search-results > li > a:focus a { + text-decoration: underline; + color: var(--link-hover-color); +} + +.search.minimal ul.search-results > li .search-result-image { + position: absolute; + top: 2px; + left: -32px; +} + +.search.minimal ul.search-results > li picture img { + height: 24px; + width: 24px; + border-radius: 50%; +} diff --git a/best-cigars-guide/blocks/search/search.js b/best-cigars-guide/blocks/search/search.js new file mode 100644 index 0000000..43d96d5 --- /dev/null +++ b/best-cigars-guide/blocks/search/search.js @@ -0,0 +1,269 @@ +import { + createOptimizedPicture, + decorateIcons, + fetchPlaceholders, +} from '../../scripts/aem.js'; + +const searchParams = new URLSearchParams(window.location.search); + +function findNextHeading(el) { + let preceedingEl = el.parentElement.previousElement || el.parentElement.parentElement; + let h = 'H2'; + while (preceedingEl) { + const lastHeading = [...preceedingEl.querySelectorAll('h1, h2, h3, h4, h5, h6')].pop(); + if (lastHeading) { + const level = parseInt(lastHeading.nodeName[1], 10); + h = level < 6 ? `H${level + 1}` : 'H6'; + preceedingEl = false; + } else { + preceedingEl = preceedingEl.previousElement || preceedingEl.parentElement; + } + } + return h; +} + +function highlightTextElements(terms, elements) { + elements.forEach((element) => { + if (!element || !element.textContent) return; + + const matches = []; + const { textContent } = element; + terms.forEach((term) => { + let start = 0; + let offset = textContent.toLowerCase().indexOf(term.toLowerCase(), start); + while (offset >= 0) { + matches.push({ offset, term: textContent.substring(offset, offset + term.length) }); + start = offset + term.length; + offset = textContent.toLowerCase().indexOf(term.toLowerCase(), start); + } + }); + + if (!matches.length) { + return; + } + + matches.sort((a, b) => a.offset - b.offset); + let currentIndex = 0; + const fragment = matches.reduce((acc, { offset, term }) => { + if (offset < currentIndex) return acc; + const textBefore = textContent.substring(currentIndex, offset); + if (textBefore) { + acc.appendChild(document.createTextNode(textBefore)); + } + const markedTerm = document.createElement('mark'); + markedTerm.textContent = term; + acc.appendChild(markedTerm); + currentIndex = offset + term.length; + return acc; + }, document.createDocumentFragment()); + const textAfter = textContent.substring(currentIndex); + if (textAfter) { + fragment.appendChild(document.createTextNode(textAfter)); + } + element.innerHTML = ''; + element.appendChild(fragment); + }); +} + +export async function fetchData(source) { + const response = await fetch(source); + if (!response.ok) { + // eslint-disable-next-line no-console + console.error('error loading API response', response); + return null; + } + + const json = await response.json(); + if (!json) { + // eslint-disable-next-line no-console + console.error('empty API response', source); + return null; + } + + return json.data; +} + +function renderResult(result, searchTerms, titleTag) { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = result.path; + if (result.image) { + const wrapper = document.createElement('div'); + wrapper.className = 'search-result-image'; + const pic = createOptimizedPicture(result.image, '', false, [{ width: '375' }]); + wrapper.append(pic); + a.append(wrapper); + } + if (result.title) { + const title = document.createElement(titleTag); + title.className = 'search-result-title'; + const link = document.createElement('a'); + link.href = result.path; + link.textContent = result.title; + highlightTextElements(searchTerms, [link]); + title.append(link); + a.append(title); + } + if (result.description) { + const description = document.createElement('p'); + description.textContent = result.description; + highlightTextElements(searchTerms, [description]); + a.append(description); + } + li.append(a); + return li; +} + +function clearSearchResults(block) { + const searchResults = block.querySelector('.search-results'); + searchResults.innerHTML = ''; +} + +function clearSearch(block) { + clearSearchResults(block); + if (window.history.replaceState) { + const url = new URL(window.location.href); + url.search = ''; + searchParams.delete('q'); + window.history.replaceState({}, '', url.toString()); + } +} + +async function renderResults(block, config, filteredData, searchTerms) { + clearSearchResults(block); + const searchResults = block.querySelector('.search-results'); + const headingTag = searchResults.dataset.h; + + if (filteredData.length) { + searchResults.classList.remove('no-results'); + filteredData.forEach((result) => { + const li = renderResult(result, searchTerms, headingTag); + searchResults.append(li); + }); + } else { + const noResultsMessage = document.createElement('li'); + searchResults.classList.add('no-results'); + noResultsMessage.textContent = config.placeholders.searchNoResults || 'No results found.'; + searchResults.append(noResultsMessage); + } +} + +function compareFound(hit1, hit2) { + return hit1.minIdx - hit2.minIdx; +} + +function filterData(searchTerms, data) { + const foundInHeader = []; + const foundInMeta = []; + + data.forEach((result) => { + let minIdx = -1; + + searchTerms.forEach((term) => { + const idx = (result.header || result.title).toLowerCase().indexOf(term); + if (idx < 0) return; + if (minIdx < idx) minIdx = idx; + }); + + if (minIdx >= 0) { + foundInHeader.push({ minIdx, result }); + return; + } + + const metaContents = `${result.title} ${result.description} ${result.path.split('/').pop()}`.toLowerCase(); + searchTerms.forEach((term) => { + const idx = metaContents.indexOf(term); + if (idx < 0) return; + if (minIdx < idx) minIdx = idx; + }); + + if (minIdx >= 0) { + foundInMeta.push({ minIdx, result }); + } + }); + + return [ + ...foundInHeader.sort(compareFound), + ...foundInMeta.sort(compareFound), + ].map((item) => item.result); +} + +async function handleSearch(e, block, config) { + const searchValue = e.target.value; + searchParams.set('q', searchValue); + if (window.history.replaceState) { + const url = new URL(window.location.href); + url.search = searchParams.toString(); + window.history.replaceState({}, '', url.toString()); + } + + if (searchValue.length < 3) { + clearSearch(block); + return; + } + const searchTerms = searchValue.toLowerCase().split(/\s+/).filter((term) => !!term); + + const data = await fetchData(config.source); + const filteredData = filterData(searchTerms, data); + await renderResults(block, config, filteredData, searchTerms); +} + +function searchResultsContainer(block) { + const results = document.createElement('ul'); + results.className = 'search-results'; + results.dataset.h = findNextHeading(block); + return results; +} + +function searchInput(block, config) { + const input = document.createElement('input'); + input.setAttribute('type', 'search'); + input.className = 'search-input'; + + const searchPlaceholder = config.placeholders.searchPlaceholder || 'Search...'; + input.placeholder = searchPlaceholder; + input.setAttribute('aria-label', searchPlaceholder); + + input.addEventListener('input', (e) => { + handleSearch(e, block, config); + }); + + input.addEventListener('keyup', (e) => { if (e.code === 'Escape') { clearSearch(block); } }); + + return input; +} + +function searchIcon() { + const icon = document.createElement('span'); + icon.classList.add('icon', 'icon-search'); + return icon; +} + +function searchBox(block, config) { + const box = document.createElement('div'); + box.classList.add('search-box'); + box.append( + searchIcon(), + searchInput(block, config), + ); + + return box; +} + +export default async function decorate(block) { + const placeholders = await fetchPlaceholders(); + const source = block.querySelector('a[href]') ? block.querySelector('a[href]').href : '/query-index.json'; + block.innerHTML = ''; + block.append( + searchBox(block, { source, placeholders }), + searchResultsContainer(block), + ); + + if (searchParams.get('q')) { + const input = block.querySelector('input'); + input.value = searchParams.get('q'); + input.dispatchEvent(new Event('input')); + } + + decorateIcons(block); +} From c7277d5fe90e65f1681d3ef848b17b15b501a14c Mon Sep 17 00:00:00 2001 From: Bryan Deffley Date: Thu, 9 May 2024 15:04:54 -0400 Subject: [PATCH 2/9] switch query param from s to q --- best-cigars-guide/blocks/header/header.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/best-cigars-guide/blocks/header/header.js b/best-cigars-guide/blocks/header/header.js index 0a9b00e..07b633c 100644 --- a/best-cigars-guide/blocks/header/header.js +++ b/best-cigars-guide/blocks/header/header.js @@ -153,7 +153,7 @@ export default async function decorate(block) { searchBox.innerHTML = `