Merge pull request #303 from AMoo-Miki/add-custom-search
Add custom search
This commit is contained in:
commit
b8a6d2e796
11
_config.yml
11
_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
|
||||
|
|
|
@ -105,13 +105,20 @@ layout: table_wrappers
|
|||
<div class="copy-banner">
|
||||
<div class="container">
|
||||
<h1><a href="#">Documentation</a></h1>
|
||||
{% if site.search_enabled != false %}
|
||||
{% if site.search_enabled != false or site.use_custom_search == true %}
|
||||
<div class="search">
|
||||
<div class="search-input-wrap">
|
||||
<input type="text" id="search-input" class="search-input" tabindex="0" placeholder="Search..." aria-label="Search {{ site.title }}" autocomplete="off">
|
||||
<input type="text" id="search-input" class="search-input"
|
||||
tabindex="0" placeholder="Search..." aria-label="Search {{ site.title }}"
|
||||
data-docs-version="{{ site.data.versions.current }}" autocomplete="off">
|
||||
<div class="search-spinner"><i></i></div>
|
||||
<label for="search-input" class="search-label"><svg viewBox="0 0 24 24" class="search-icon"><use xlink:href="#svg-search"></use></svg></label>
|
||||
</div>
|
||||
{% if site.search_enabled != false %}
|
||||
<div id="search-results" class="search-results"></div>
|
||||
{% elsif site.use_custom_search == true %}
|
||||
<div id="search-results" class="search-results custom-search-results"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -213,5 +220,8 @@ layout: table_wrappers
|
|||
</script>
|
||||
{% endif %}
|
||||
<script src="{{ '/assets/js/header-nav.js' | relative_url }}"></script>
|
||||
{% if site.search_enabled == false and site.use_custom_search == true %}
|
||||
<script src="{{ '/assets/js/search.js' | relative_url }}"></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
? `
|
||||
<div class="custom-search-result">
|
||||
<a href="${sanitizeAttribute(result.url)}">
|
||||
<cite>
|
||||
${result.type === 'DOCS' ? `OpenSearch ${sanitizeText(result.version)} › ` : ''}
|
||||
${sanitizeText(result.ancestors?.join?.(' › '))}
|
||||
</cite>
|
||||
${sanitizeText(result.title)}
|
||||
</a>
|
||||
<span>${sanitizeText(result.content?.replace?.(/\n/g, '… '))}</span>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
);
|
||||
|
||||
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('<span>No results found!</span>'));
|
||||
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?.(/</g, '<');
|
||||
};
|
||||
|
||||
const sanitizeAttribute = text => {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
})();
|
Loading…
Reference in New Issue