UNPKG

6.27 kBJavaScriptView Raw
1/*! https://github.com/jquery/typesense-minibar 1.2.0 */
2globalThis.tsminibar = function tsminibar (form) {
3 const { origin, collection } = form.dataset;
4 const group = !!form.dataset.group;
5 const cache = new Map();
6 const state = { query: '', cursor: -1, open: false, hits: [] };
7 const searchParams = new URLSearchParams({
8 per_page: '5',
9 query_by: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,content',
10 include_fields: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,content,url_without_anchor,url,id',
11 highlight_full_fields: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,content',
12 group_by: 'url_without_anchor',
13 group_limit: '1',
14 sort_by: 'item_priority:desc',
15 snippet_threshold: '8',
16 highlight_affix_num_tokens: '12',
17 'x-typesense-api-key': form.dataset.key,
18 ...Object.fromEntries(new URLSearchParams(form.dataset.searchParams))
19 });
20 const noResults = form.dataset.noResults || "No results for '{}'.";
21
22 const input = form.querySelector('input[type=search]');
23 const listbox = document.createElement('div');
24 listbox.setAttribute('role', 'listbox');
25 listbox.hidden = true;
26 input.after(listbox);
27
28 let preconnect = null;
29 input.addEventListener('focus', function () {
30 if (!preconnect) {
31 preconnect = document.createElement('link');
32 preconnect.rel = 'preconnect';
33 preconnect.crossOrigin = 'anonymous'; // match fetch cors,credentials:omit
34 preconnect.href = origin;
35 document.head.append(preconnect);
36 }
37 if (!state.open && state.hits.length) {
38 state.open = true;
39 render();
40 }
41 });
42 input.addEventListener('input', async function () {
43 const query = state.query = input.value;
44 if (!query) {
45 state.hits = []; // don't leak old hits on focus
46 state.cursor = -1;
47 return close();
48 }
49 const hits = await search(query);
50 if (state.query === query) { // ignore non-current query
51 state.hits = hits;
52 state.cursor = -1;
53 state.open = true;
54 render();
55 }
56 });
57 input.addEventListener('click', function () {
58 if (!state.open && state.hits.length) {
59 state.open = true;
60 render();
61 }
62 });
63 input.addEventListener('keydown', function (e) {
64 if (!e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
65 if (e.code === 'ArrowDown') moveCursor(1);
66 if (e.code === 'ArrowUp') moveCursor(-1);
67 if (e.code === 'Escape') close();
68 if (e.code === 'Enter') {
69 const url = state.hits[state.cursor]?.url;
70 if (url) location.href = url;
71 }
72 }
73 });
74 form.addEventListener('submit', function (e) {
75 e.preventDefault(); // disable fallback
76 });
77 form.insertAdjacentHTML('beforeend', '<svg viewBox="0 0 12 12" width="20" height="20" aria-hidden="true" class="tsmb-icon-close" style="display: none;"><path d="M9 3L3 9M3 3L9 9"/></svg>');
78 form.querySelector('.tsmb-icon-close').addEventListener('click', function () {
79 input.value = '';
80 input.focus();
81 close();
82 });
83 connect();
84
85 function close () {
86 if (state.open) {
87 state.cursor = -1;
88 state.open = false;
89 render();
90 }
91 }
92
93 function connect () {
94 document.addEventListener('click', onDocClick);
95 if (form.dataset.slash !== 'false') {
96 document.addEventListener('keydown', onDocSlash);
97 form.classList.add('tsmb-form--slash');
98 }
99 }
100
101 function disconnect () {
102 document.removeEventListener('click', onDocClick);
103 document.removeEventListener('keydown', onDocSlash);
104 }
105
106 function onDocClick (e) {
107 if (!form.contains(e.target)) close();
108 }
109
110 function onDocSlash (e) {
111 if (e.key === '/' && !/^(INPUT|TEXTAREA)$/.test(document.activeElement?.tagName)) {
112 input.focus();
113 e.preventDefault();
114 }
115 }
116
117 async function search (query) {
118 let lvl0;
119 let hits = cache.get(query);
120 if (hits) {
121 cache.delete(query);
122 cache.set(query, hits); // LRU
123 return hits;
124 }
125 searchParams.set('q', query);
126 const resp = await fetch(
127 `${origin}/collections/${collection}/documents/search?` + searchParams,
128 { mode: 'cors', credentials: 'omit', method: 'GET' }
129 );
130 const data = await resp.json();
131 hits = data?.grouped_hits?.map(ghit => {
132 const hit = ghit.hits[0];
133 return {
134 lvl0: group && lvl0 !== hit.document.hierarchy.lvl0 && (lvl0 = hit.document.hierarchy.lvl0),
135 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,
136 url: hit.document.url,
137 content: hit.highlights[0]?.snippet || hit.document.content || ''
138 };
139 }) || [];
140 cache.set(query, hits);
141 if (cache.size > 100) {
142 cache.delete(cache.keys().next().value);
143 }
144 return hits;
145 }
146
147 function escape (s) {
148 return s.replace(/['"<>&]/g, c => ({ "'": '&#039;', '"': '&quot;', '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
149 }
150
151 function render () {
152 listbox.hidden = !state.open;
153 form.classList.toggle('tsmb-form--open', state.open);
154 if (state.open) {
155 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>`) + (form.dataset.foot ? '<a href="https://typesense.org" class="tsmb-foot" title="Search by Typesense"></a>' : '');
156 }
157 }
158
159 function moveCursor (offset) {
160 state.cursor += offset;
161 // -1 refers to input field
162 if (state.cursor >= state.hits.length) state.cursor = -1;
163 if (state.cursor < -1) state.cursor = state.hits.length - 1;
164 render();
165 }
166
167 return { form, connect, disconnect };
168};
169document.querySelectorAll('.tsmb-form[data-origin]').forEach(form => tsminibar(form));