From e60bbafd916a35cad31c9eaad3cf503cae4ab646 Mon Sep 17 00:00:00 2001 From: Miki Date: Mon, 29 Nov 2021 18:49:04 -0800 Subject: [PATCH] Add custom search Signed-off-by: Miki --- _config.yml | 11 +- _layouts/default.html | 14 ++- _sass/custom/custom.scss | 102 ++++++++++++++++ assets/js/search.js | 251 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 374 insertions(+), 4 deletions(-) create mode 100644 assets/js/search.js diff --git a/_config.yml b/_config.yml index 092739c2..89e8d8e5 100644 --- a/_config.yml +++ b/_config.yml @@ -107,8 +107,15 @@ just_the_docs: # Enable or disable the site search -# Supports true (default) or false -search_enabled: true +# By default, just-the-docs enables its JSON file-based search. We also have an OpenSearch-driven search functionality. +# To disable any search from appearing, both `search_enabled` and `use_custom_search` need to be false. +# To use the OpenSearch-driven search, `search_enabled` has to be false and `use_custom_search` needs to be true. +# If `search_enabled` is true, irrespective of the value of `use_custom_search`, the JSON file-based search appears. +# +# `search_enabled` defaults to true +# `use_custom_search` defaults to false +search_enabled: false +use_custom_search: true search: # Split pages into sections that can be searched individually diff --git a/_layouts/default.html b/_layouts/default.html index c5408662..2e4ae661 100755 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -105,13 +105,20 @@ layout: table_wrappers

Documentation

- {% if site.search_enabled != false %} + {% if site.search_enabled != false or site.use_custom_search == true %} {% endif %}
@@ -213,5 +220,8 @@ layout: table_wrappers {% endif %} + {% if site.search_enabled == false and site.use_custom_search == true %} + + {% endif %} diff --git a/_sass/custom/custom.scss b/_sass/custom/custom.scss index 8bc613c1..961668b0 100755 --- a/_sass/custom/custom.scss +++ b/_sass/custom/custom.scss @@ -1130,3 +1130,105 @@ version-selector { --hover-bg: linear-gradient(#{lighten($blue-300, 2%)}, #{darken($blue-300, 4%)}); --link-color: #{$blue-300}; } + +.custom-search-results { + & > div { + padding: 1rem; + } + + cite { + @include font-size(12); + @include sans-serif; + color: $grey-dk-300; + text-decoration: none; + font-style: normal; + display: block; + line-height: 1; + font-weight: normal; + } + + a { + @include font-size(20); + @include heading-sans-serif; + line-height: 1.6; + font-weight: bold; + outline: none; + } + + span { + @include font-size(14); + color: $grey-dk-200; + line-height: 1.4; + display: block; + overflow-wrap: break-word; + + &:only-child { + text-align: center; + padding: 1rem; + } + } + + .highlighted { + background: #EAF4F9; + } +} + +.banner-alert ~ main .custom-search-results { + max-height: calc(100vh - 200% - 60px - 3.6rem) !important; +} + +.search-spinner { + display: none; + font-weight: 700; + outline: 0; + user-select: none; + + position: absolute; + padding-left: 0.6rem; + height: 100%; + + &.spinning { + display: flex; + + & ~ .search-label { + display: none; + } + } +} + +.search-spinner > i { + border-color: rgba($grey-dk-000, 0.2); + position: relative; + animation: spin 0.6s infinite linear; + border-width: 3px; + border-style: solid; + border-radius: 100%; + display: inline-block; + width: 18px; + height: 18px; + vertical-align: middle; + align-self: center; + + &:before { + content: ""; + border: 3px solid rgba($grey-dk-000, 0); + border-top-color: rgba($grey-dk-000, 0.8); + border-radius: 100%; + display: block; + left: -3px; + position: absolute; + top: -3px; + height: 100%; + width: 100%; + box-sizing: content-box; + } +} + +@keyframes spin { + from { + transform: rotate(0deg) + } + to { + transform: rotate(359deg) + } +} \ No newline at end of file diff --git a/assets/js/search.js b/assets/js/search.js new file mode 100644 index 00000000..b6a811ff --- /dev/null +++ b/assets/js/search.js @@ -0,0 +1,251 @@ +(() => { + const elInput = document.getElementById('search-input'); + const elResults = document.getElementById('search-results'); + const elOverlay = document.querySelector('.search-overlay'); + const elSpinner = document.querySelector('.search-spinner'); + if (!elInput || !elResults || !elOverlay) return; + + const CLASSNAME_SPINNING = 'spinning'; + const CLASSNAME_HIGHLIGHTED = 'highlighted'; + + const canSmoothScroll = 'scrollBehavior' in document.documentElement.style; + + const docsVersion = elInput.getAttribute('data-docs-version'); + + let _showingResults = false, + animationFrame, + debounceTimer, + lastQuery; + + const abortControllers = []; + + elInput.addEventListener('input', e => { + debounceInput(); + }); + + elInput.addEventListener('keydown', e => { + switch (e.key) { + case 'Esc': + case 'Escape': + hideResults(true); + elInput.value = ''; + break; + + case 'ArrowUp': + e.preventDefault(); + highlightNextResult(false); + break; + + case 'ArrowDown': + e.preventDefault(); + highlightNextResult(); + break; + + case 'Enter': + e.preventDefault(); + navToHighlightedResult(); + break; + + case 'Tab': + e.preventDefault(); + highlightNextResult(!e.shiftKey); + break; + + } + }); + + elInput.addEventListener('focus', e => { + if (!_showingResults && elResults.textContent) showResults(); + }); + + elResults.addEventListener('pointerenter', e => { + cancelAnimationFrame(animationFrame); + animationFrame = requestAnimationFrame(() => { + highlightResult(e.target?.closest('.custom-search-result')); + }); + }, true); + + elResults.addEventListener('focus', e => { + highlightResult(e.target?.closest('.custom-search-result')); + }, true); + + const debounceInput = () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(doSearch, 300); + }; + + function abortPreviousCalls() { + while (abortControllers.length) abortControllers.pop()?.abort?.(); + } + + const doSearch = async () => { + const query = elInput.value.replace(/[^a-z0-9-_. ]+/ig, ' '); + if (query.length < 3) return hideResults(true); + if (query === lastQuery) return; + + recordEvent('search', { + search_term: query, + docs_version: docsVersion + }); + + lastQuery = query; + + abortPreviousCalls(); + + elSpinner?.classList.add(CLASSNAME_SPINNING); + if (!_showingResults) document.documentElement.classList.add('search-active'); + + try { + const controller = new AbortController(); + abortControllers.unshift(abortControllers); + const startTime = Date.now(); + const response = await fetch(`https://search-api.opensearch.org/search?q=${query}&v=${docsVersion}`, {signal: controller.signal}); + const data = await response.json(); + + recordEvent('view_search_results', { + search_term: query, + docs_version: docsVersion, + duration: Date.now() - startTime, + results_num: data?.results?.length || 0 + }); + + if (!Array.isArray(data?.results) || data.results.length === 0) { + return showNoResults(); + } + const chunks = data.results.map(result => result + ? ` + + ` + : '' + ); + + emptyResults(); + elResults.appendChild(document.createRange().createContextualFragment(chunks.join(''))); + showResults(); + } catch (ex) { + showNoResults(); + } + + elSpinner?.classList.remove(CLASSNAME_SPINNING); + } + + const hideResults = destroy => { + _showingResults = false; + + elSpinner?.classList.remove(CLASSNAME_SPINNING); + document.documentElement.classList.remove('search-active'); + elResults.setAttribute('aria-expanded', 'false'); + document.body.removeEventListener('pointerdown', handlePointerDown, false); + + if (destroy) { + abortPreviousCalls(); + emptyResults(); + lastQuery = ''; + } + }; + + const showResults = () => { + if (!_showingResults) { + _showingResults = true; + document.documentElement.classList.add('search-active'); + elResults.setAttribute('aria-expanded', 'true'); + document.body.addEventListener('pointerdown', handlePointerDown, false); + } + + elResults.scrollTo(0, 0); + }; + + const showNoResults = () => { + emptyResults(); + elResults.appendChild(document.createRange().createContextualFragment('No results found!')); + showResults(); + elSpinner?.classList.remove(CLASSNAME_SPINNING); + }; + + const emptyResults = () => { + //ToDo: Replace with `elResults.replaceChildren();` when https://caniuse.com/?search=replaceChildren shows above 90% can use it + while (elResults.firstChild) elResults.firstChild.remove(); + }; + + const sanitizeText = text => { + return text?.replace?.(/ { + return text?.replace?.(/[>"]+/g, ''); + }; + + const handlePointerDown = e => { + if (e.target.matches('.search-input-wrap, .search-input-wrap *, .search-results, .search-results *')) return; + + e.preventDefault(); + + elInput.blur(); + hideResults(); + }; + + const highlightResult = node => { + if (!node || !_showingResults || node.classList.contains(CLASSNAME_HIGHLIGHTED)) return; + + elResults.querySelectorAll('.custom-search-result.highlighted').forEach(el => { + el.classList.remove(CLASSNAME_HIGHLIGHTED); + }); + node.classList.add(CLASSNAME_HIGHLIGHTED); + elInput.focus(); + }; + + const highlightNextResult = (down = true) => { + const highlighted = elResults.querySelector('.custom-search-result.highlighted'); + let nextResult; + if (highlighted) { + highlighted.classList.remove(CLASSNAME_HIGHLIGHTED); + nextResult = highlighted[down ? 'nextElementSibling' : 'previousElementSibling'] + } else { + nextResult = elResults.querySelector(`.custom-search-result:${down ? 'first' : 'last'}-child`); + } + + if (nextResult) { + nextResult.classList.add(CLASSNAME_HIGHLIGHTED); + if (down) { + if (canSmoothScroll) { + nextResult.scrollIntoView({behavior: "smooth", block: "end"}); + } else { + nextResult.scrollIntoView(false) + } + } else if ( + nextResult.offsetTop < elResults.scrollTop || + nextResult.offsetTop + nextResult.clientHeight > elResults.scrollTop + elResults.clientHeight + ) { + if (canSmoothScroll) { + elResults.scrollTo({behavior: "smooth", top: nextResult.offsetTop, left: 0}); + } else { + elResults.scrollTo(0, nextResult.offsetTop); + } + } + } else { + elResults.scrollTo(0, 0); + } + }; + + const navToHighlightedResult = () => { + elResults.querySelector('.custom-search-result.highlighted a[href]')?.click?.(); + }; + + const recordEvent = (name, data) => { + try { + gtag?.('event', name, data); + } catch (e) { + // Do nothing + } + + } +})(); \ No newline at end of file