typesense-minibar
Version:
Fast 2kB autocomplete search bar
175 lines (162 loc) • 6.37 kB
JavaScript
/*! https://github.com/jquery/typesense-minibar 1.3.4 */
globalThis.tsminibar = function tsminibar (form, dataset = form.dataset) {
const cache = new Map();
const state = { query: '', cursor: -1, open: false, hits: [] };
const searchParams = new URLSearchParams({
per_page: '5',
query_by: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,content',
include_fields: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,content,url_without_anchor,url,id',
highlight_full_fields: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,content',
group_by: 'url_without_anchor',
group_limit: '1',
sort_by: 'item_priority:desc',
snippet_threshold: '8',
highlight_affix_num_tokens: '12',
'x-typesense-api-key': dataset.key,
...Object.fromEntries(new URLSearchParams(dataset.searchParams))
});
const noResults = dataset.noResults || "No results for '{}'.";
const input = form.querySelector('input[type=search]');
const listbox = document.createElement('div');
listbox.setAttribute('role', 'listbox');
listbox.hidden = true;
input.after(listbox);
let preconnect = null;
input.addEventListener('focus', function () {
if (!preconnect) {
preconnect = document.createElement('link');
preconnect.rel = 'preconnect';
preconnect.crossOrigin = 'anonymous'; // match fetch cors,credentials:omit
preconnect.href = dataset.origin;
document.head.append(preconnect);
}
if (!state.open && state.hits.length) {
state.open = true;
render();
}
});
input.addEventListener('input', async function () {
const query = state.query = input.value;
if (!query) {
state.hits = [];
return close();
}
const hits = await search(query);
if (state.query === query) { // ignore non-current query
state.hits = hits;
state.cursor = -1;
state.open = true;
render();
}
});
input.addEventListener('click', function () {
if (!state.open && state.hits.length) {
state.open = true;
render();
}
});
input.addEventListener('keydown', function (e) {
if (!e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
if (e.code === 'ArrowDown') moveCursor(1);
if (e.code === 'ArrowUp') moveCursor(-1);
if (e.code === 'Escape') {
close();
input.blur();
}
if (e.code === 'Enter') {
const url = state.hits[state.cursor]?.url;
if (url) location.href = url;
}
}
});
form.addEventListener('submit', function (e) {
e.preventDefault();
});
form.insertAdjacentHTML('beforeend', '<svg viewBox="0 0 12 12" width="20" height="20" aria-hidden="true" tabindex="-1" class="tsmb-icon-close"><path d="M9 3L3 9M3 3L9 9"/></svg>');
form.querySelector('.tsmb-icon-close').addEventListener('click', function () {
input.value = '';
state.hits = [];
close();
input.focus();
});
connect();
function close () {
if (state.open) {
state.cursor = -1;
state.open = false;
render();
}
}
function connect () {
document.addEventListener('click', onDocClick);
if (dataset.slash !== 'false') {
document.addEventListener('keydown', onDocSlash);
form.classList.add('tsmb-form--slash');
}
}
function disconnect () {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onDocSlash);
}
function onDocClick (e) {
if (!form.contains(e.target)) close();
}
function onDocSlash (e) {
if (e.key === '/' && !/^(INPUT|TEXTAREA)$/.test(document.activeElement?.tagName)) {
input.focus();
e.preventDefault();
}
}
async function search (query) {
let lvl0;
let hits = cache.get(query);
if (hits) {
cache.delete(query);
cache.set(query, hits);
return hits;
}
searchParams.set('q', query);
const resp = await fetch(
`${dataset.origin}/collections/${dataset.collection}/documents/search?` + searchParams,
{ mode: 'cors', credentials: 'omit', method: 'GET' }
);
const group = !!dataset.group;
const data = await resp.json();
hits = data?.grouped_hits?.map(ghit => {
const hit = ghit.hits[0];
return {
lvl0: group && lvl0 !== hit.document.hierarchy.lvl0 && (lvl0 = hit.document.hierarchy.lvl0),
title: [!group && hit.document.hierarchy.lvl0, hit.document.hierarchy.lvl1, hit.document.hierarchy.lvl2, hit.document.hierarchy.lvl3, hit.document.hierarchy.lvl4, hit.document.hierarchy.lvl5].filter(lvl => !!lvl).join(' › ') || hit.document.hierarchy.lvl0,
url: hit.document.url,
content: hit.highlights[0]?.snippet || hit.document.content || ''
};
}) || [];
cache.set(query, hits);
if (cache.size > 100) {
cache.delete(cache.keys().next().value);
}
return hits;
}
function escape (s) {
return s.replace(/['"<>&]/g, c => ({ "'": ''', '"': '"', '<': '<', '>': '>', '&': '&' }[c]));
}
function render () {
listbox.hidden = !state.open;
form.classList.toggle('tsmb-form--open', state.open);
listbox.innerHTML = (state.hits.map((hit, i) => `<div role="option"${i === state.cursor ? ' aria-selected="true"' : ''}>${hit.lvl0 ? `<div class="tsmb-suggestion_group">${hit.lvl0}</div>` : ''}<a href="${hit.url}" tabindex="-1"><div class="tsmb-suggestion_title">${hit.title}</div><div class="tsmb-suggestion_content">${hit.content}</div></a></div>`).join('') || `<div class="tsmb-empty">${noResults.replace('{}', escape(state.query))}</div>`) + (dataset.foot ? '<a href="https://typesense.org" class="tsmb-foot" title="Search by Typesense"></a>' : '');
}
function moveCursor (offset) {
state.cursor += offset;
if (state.cursor >= state.hits.length) state.cursor = -1;
if (state.cursor < -1) state.cursor = state.hits.length - 1;
render();
}
return { form, connect, disconnect };
};
window.customElements.define('typesense-minibar', class extends HTMLElement {
connectedCallback () {
const form = this.querySelector('form');
if (form && this.dataset.origin) tsminibar(form, this.dataset);
}
});
document.querySelectorAll('.tsmb-form[data-origin]').forEach(form => tsminibar(form));