PluginProbe
ElasticPress / 4.2.2
ElasticPress v4.2.2
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / assets / js / autosuggest.js

autosuggest.js in ElasticPress 4.2.2, at assets/js/autosuggest.js

785 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /* eslint-disable camelcase, no-underscore-dangle, no-use-before-define */
2
3 /**
4 * Internal dependencies.
5 */
6 import {
7 findAncestorByClass,
8 escapeDoubleQuotes,
9 replaceGlobally,
10 debounce,
11 domReady,
12 } from './utils/helpers';
13
14 const { epas } = window;
15
16 // Ensure we have an endpoint URL, or
17 // else this shouldn't happen
18 if (epas.endpointUrl && epas.endpointUrl !== '') {
19 init();
20
21 // Publically expose API
22 window.epasAPI = {
23 hideAutosuggestBox,
24 updateAutosuggestBox,
25 esSearch,
26 buildSearchQuery,
27 };
28 }
29
30 /**
31 * Submit the search form
32 *
33 * @param {Node} input - input element
34 */
35 function submitSearchForm(input) {
36 input.closest('form').submit();
37 }
38
39 /**
40 * Set the expanded aria state on the input
41 *
42 * @param {boolean} haveOptions - whether or not the autosuggest list contains results
43 * @param {Node} input - search input
44 */
45 function toggleInputAria(haveOptions, input) {
46 input.setAttribute('aria-expanded', haveOptions);
47 }
48
49 /**
50 * Set the active descendant aria attribute input
51 *
52 * @param {string} id - id of the currently selected element
53 * @param {Node} input - search input
54 */
55 function setInputActiveDescendant(id, input) {
56 input.setAttribute('aria-activedescendant', id);
57 }
58
59 /**
60 * Take selected item and fill the search input
61 *
62 * @param {Node} input - input element
63 * @param {string} text - new input value
64 */
65 function selectAutosuggestItem(input, text) {
66 input.value = text; // eslint-disable-line no-param-reassign
67 }
68
69 /**
70 * Fires events when autosuggest results are clicked,
71 * and if GA tracking is activated
72 *
73 * @param {object} detail - value to pass on to the Custom Event
74 */
75 function triggerAutosuggestEvent(detail) {
76 const event = new CustomEvent('ep-autosuggest-click', { detail });
77 window.dispatchEvent(event);
78
79 if (
80 detail.searchTerm &&
81 parseInt(epas.triggerAnalytics, 10) === 1 &&
82 typeof gtag === 'function'
83 ) {
84 const action = `click - ${detail.searchTerm}`;
85 // eslint-disable-next-line no-undef
86 gtag('event', action, {
87 event_category: 'EP :: Autosuggest',
88 event_label: detail.url,
89 transport_type: 'beacon',
90 });
91 }
92 }
93
94 /**
95 * Navigate to the selected item, and provides
96 * event hook for JS customizations, like GA
97 *
98 * @param {string} searchTerm - user defined search term
99 * @param {string} url - post url from dataset in search result
100 */
101 function goToAutosuggestItem(searchTerm, url) {
102 const detail = {
103 searchTerm,
104 url,
105 };
106
107 triggerAutosuggestEvent(detail);
108 window.location.href = url;
109 }
110
111 /**
112 * Respond to an item selection based on the predefined behavior.
113 * If epas.action is set to "navigate" (the default), redirects the browser to the URL of the selected item
114 * If epas.action is set to any other value (such as "search"), fill in the value and perform the search
115 *
116 * @param {Node} input - search input
117 * @param {Node} element - search term result item
118 * @returns {Function} calls the submitSearchForm function
119 */
120 function selectItem(input, element) {
121 if (epas.action === 'navigate') {
122 return goToAutosuggestItem(input.value, element.dataset.url);
123 }
124
125 selectAutosuggestItem(input, element.innerText);
126 return submitSearchForm(input);
127 }
128
129 /**
130 * Build the search query from the search text - the query is generated in PHP
131 * and passed into the front end as window.epas = { "query...
132 *
133 * @returns {string} json string
134 */
135 function getJsonQuery() {
136 if (typeof window.epas === 'undefined') {
137 const error = 'No epas object defined';
138
139 // eslint-disable-next-line no-console
140 console.warn(error);
141 return { error };
142 }
143
144 return window.epas;
145 }
146
147 /**
148 * Build the search query from the search text
149 *
150 * @param {string} searchText - user search string
151 * @param {string} placeholder - placeholder text to replace
152 * @param {object} options - Autosuggest settings
153 * @param {string} options.query - JSON query string to pass to ElasticSearch
154 * @returns {string} json representation of search query
155 */
156 function buildSearchQuery(searchText, placeholder, { query }) {
157 const newQuery = replaceGlobally(query, placeholder, searchText);
158 return newQuery;
159 }
160
161 /**
162 * Build the ajax request
163 *
164 * @param {string} query - json string
165 * @param {string} searchTerm - user search term
166 * @returns {object} AJAX object request
167 */
168 async function esSearch(query, searchTerm) {
169 const fetchConfig = {
170 body: query,
171 method: 'POST',
172 mode: 'cors',
173 headers: {
174 'Content-Type': 'application/json; charset=utf-8',
175 },
176 };
177
178 if (epas?.http_headers && typeof epas.http_headers === 'object') {
179 Object.keys(epas.http_headers).forEach((name) => {
180 fetchConfig.headers[name] = epas.http_headers[name];
181 });
182 }
183
184 // only applies headers if using ep.io endpoint
185 if (epas.addSearchTermHeader) {
186 fetchConfig.headers['EP-Search-Term'] = encodeURI(searchTerm);
187 }
188
189 try {
190 const response = await fetch(epas.endpointUrl, fetchConfig);
191
192 if (!response.ok) {
193 throw Error(response.statusText);
194 }
195
196 const data = await response.json();
197
198 // allow for filtered data before returning it to
199 // be output on the front end
200 if (typeof window.epDataFilter !== 'undefined') {
201 return window.epDataFilter(data, searchTerm);
202 }
203
204 return data;
205 } catch (error) {
206 // eslint-disable-next-line no-console
207 console.error(error);
208 return error;
209 }
210 }
211
212 /**
213 * Update the auto suggest box with new options or hide if none
214 *
215 * @param {Array} options - search results
216 * @param {string} input - search string
217 * @returns {boolean} return true
218 */
219 function updateAutosuggestBox(options, input) {
220 let i;
221 let itemString = '';
222
223 // get the search term for use later on
224 const { value } = input;
225 const container = findAncestorByClass(input, 'ep-autosuggest-container');
226 const resultsContainer = container.querySelector('.ep-autosuggest');
227 const suggestList = resultsContainer.querySelector('.autosuggest-list');
228
229 // empty the the list of all child nodes
230 while (suggestList.firstChild) {
231 suggestList.removeChild(suggestList.firstChild);
232 }
233
234 if (options.length > 0) {
235 resultsContainer.style = 'display: block;';
236 } else {
237 resultsContainer.style = 'display: none;';
238 }
239
240 // anticipating the future... a setting where we configure
241 // a limit of results to show, and optionally append a
242 // link to "all results" or something of that nature
243 const resultsLimit = options.length;
244
245 // create markup for list items
246 // eslint-disable-next-line
247 for ( i = 0; resultsLimit > i; ++i ) {
248 const text = options[i]._source.post_title;
249 const url = options[i]._source.permalink;
250 const escapedText = escapeDoubleQuotes(text);
251
252 const searchParts = value.trim().split(' ');
253 let resultsText = escapedText;
254
255 if (epas.highlightingEnabled) {
256 // uses some regex magic to match upper/lower/capital case
257 const regex = new RegExp(`\\b(${searchParts.join('|')})`, 'gi');
258 resultsText = resultsText.replace(
259 regex,
260 (word) =>
261 `<${epas.highlightingTag} class="${epas.highlightingClass} ep-autosuggest-highlight">${word}</${epas.highlightingTag}>`,
262 );
263 }
264
265 let itemHTML = `<li class="autosuggest-item" role="option" aria-selected="false" id="autosuggest-option-${i}">
266 <a href="${url}" class="autosuggest-link" data-search="${escapedText}" data-url="${url}" tabindex="-1">
267 ${resultsText}
268 </a>
269 </li>`;
270
271 if (typeof window.epAutosuggestItemHTMLFilter !== 'undefined') {
272 itemHTML = window.epAutosuggestItemHTMLFilter(itemHTML, options[i], i, value);
273 }
274
275 itemString += itemHTML;
276 }
277
278 // append list items to the list
279 suggestList.innerHTML = itemString;
280
281 const autosuggestItems = Array.from(document.querySelectorAll('.autosuggest-link'));
282
283 suggestList.addEventListener('click', (event) => {
284 event.preventDefault();
285 const target =
286 event.target.tagName === epas.highlightingTag?.toUpperCase()
287 ? event.target.parentElement
288 : event.target;
289
290 if (autosuggestItems.includes(target)) {
291 selectItem(input, target);
292 }
293 });
294
295 return true;
296 }
297
298 /**
299 * Hide the auto suggest box
300 *
301 * @returns {boolean} returns true
302 */
303 function hideAutosuggestBox() {
304 const lists = document.querySelectorAll('.autosuggest-list');
305 const containers = document.querySelectorAll('.ep-autosuggest');
306
307 // empty all EP results lists
308 lists.forEach((list) => {
309 while (list.firstChild) {
310 list.removeChild(list.firstChild);
311 }
312 });
313
314 // hide all EP results containers
315 containers.forEach((container) => {
316 // eslint-disable-next-line
317 container.style = 'display: none;';
318 });
319
320 return true;
321 }
322
323 /**
324 * Checks for any manually ordered posts and puts them in the correct place
325 *
326 * @param {Array} hits - ES results
327 * @param {string} searchTerm - user search term
328 * @returns {object} formatted hits
329 */
330 function checkForOrderedPosts(hits, searchTerm) {
331 const toInsert = {};
332 const taxName = 'ep_custom_result';
333 const lowerCaseSearchTerm = searchTerm.toLowerCase();
334
335 const filteredHits = hits.filter((hit) => {
336 // Should we retain this hit in its current position?
337 let retain = true;
338
339 if (undefined !== hit._source.terms && undefined !== hit._source.terms[taxName]) {
340 hit._source.terms[taxName].forEach((currentTerm) => {
341 if (currentTerm.name.toLowerCase() === lowerCaseSearchTerm) {
342 toInsert[currentTerm.term_order] = hit;
343
344 retain = false;
345 }
346 });
347 }
348
349 return retain;
350 });
351
352 const orderedInserts = {};
353
354 Object.keys(toInsert)
355 .sort()
356 .forEach((key) => {
357 orderedInserts[key] = toInsert[key];
358 });
359
360 if (Object.keys(orderedInserts).length > 0) {
361 Object.keys(orderedInserts).forEach((key) => {
362 const insertItem = orderedInserts[key];
363
364 filteredHits.splice(key - 1, 0, insertItem);
365 });
366 }
367
368 return filteredHits;
369 }
370
371 /**
372 * Add class to the form element while suggestions are being loaded
373 *
374 * @param {boolean} isLoading - whether suggestions are loading
375 * @param {Node} input - search input field
376 */
377 function setFormIsLoading(isLoading, input) {
378 const form = input.closest('form');
379
380 if (isLoading) {
381 form.classList.add('is-loading');
382 } else {
383 form.classList.remove('is-loading');
384 }
385 }
386
387 /**
388 * init method called if the epas endpoint is defined
389 */
390 function init() {
391 const selectors = [epas.defaultSelectors, epas.selector].filter(Boolean).join(',');
392
393 if (!selectors) {
394 return;
395 }
396
397 // For the Autosuggest element that will be cloned.
398 let autosuggestElement;
399
400 // to be used by the handleUpDown function
401 // to keep track of the currently selected result
402 let currentIndex;
403
404 // these are the keycodes we listen for in handleUpDown,
405 // and in handleKeyup
406 const keyCodes = [
407 38, // up
408 40, // down
409 13, // enter
410 ];
411
412 /**
413 * Handles keyup event on the search input
414 *
415 * @param {event} event - keyup event
416 */
417 const handleUpDown = (event) => {
418 if (!keyCodes.includes(event.keyCode)) {
419 return;
420 }
421
422 const input = event.target;
423 const container = findAncestorByClass(input, 'ep-autosuggest-container');
424 const suggestList = container.querySelector('.autosuggest-list');
425 const results = suggestList.children;
426
427 /**
428 * helper function to get the currently selected result
429 *
430 * @returns {number} index of the selected search result
431 */
432 const getSelectedResultIndex = () => {
433 const resultsArr = Array.from(results);
434 return resultsArr.findIndex((result) => result.classList.contains('selected'));
435 };
436
437 /**
438 * helper function to deselect results
439 */
440 const deSelectResults = () => {
441 Array.from(results).forEach((result) => {
442 result.classList.remove('selected');
443 result.setAttribute('aria-selected', 'false');
444 });
445 };
446
447 /**
448 * helper function to selected the next result
449 */
450 const selectNextResult = () => {
451 if (currentIndex >= 0) {
452 const el = results[currentIndex];
453 el.classList.add('selected');
454 el.setAttribute('aria-selected', 'true');
455 setInputActiveDescendant(el.id, input);
456 }
457 };
458
459 // select next or previous based on keyCode
460 // if enter, navigate to that element
461 switch (event.keyCode) {
462 case 38: // Up
463 // don't go less than the 0th index
464 currentIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : 0;
465 deSelectResults();
466 break;
467 case 40: // Down
468 if (typeof currentIndex === 'undefined') {
469 // index is not yet defined, so let's
470 // start with the first one
471 currentIndex = 0;
472 } else {
473 const current = getSelectedResultIndex();
474
475 // check for existence of next result
476 if (results[current + 1]) {
477 currentIndex = current + 1;
478 deSelectResults();
479 }
480 }
481 break;
482 case 13: // Enter
483 if (results[currentIndex]?.classList.contains('selected')) {
484 // navigate to the item defined in the span's data-url attribute
485 selectItem(input, results[currentIndex].querySelector('.autosuggest-link'));
486 }
487 break;
488 default:
489 // No item selected
490 break;
491 }
492
493 // only check next element if up and down key pressed
494 if (results[currentIndex] && results[currentIndex].classList.contains('autosuggest-item')) {
495 selectNextResult();
496 } else {
497 deSelectResults();
498 }
499
500 // keep cursor from heading back to the beginning in the input
501 if (event.keyCode === 38) {
502 // return false;
503 event.preventDefault();
504 }
505 };
506
507 /**
508 * Get the searched post types from the search form.
509 *
510 * @param {HTMLFormElement} form - form containing the search input field
511 * @returns {Array} - post types
512 * @since 3.6.0
513 */
514 function getPostTypesFromForm(form) {
515 const data = new FormData(form);
516
517 if (data.has('post_type')) {
518 return data.getAll('post_type').slice(-1);
519 }
520
521 if (data.has('post_type[]')) {
522 return data.getAll('post_type[]');
523 }
524
525 return [];
526 }
527
528 /**
529 * Calls the ajax request, and outputs the results.
530 * Called by the handleKeyup callback, debounced.
531 *
532 * @param {Node} input - search input field
533 */
534 const fetchResults = async (input) => {
535 // retrieves the PHP-genereated query to pass to ElasticSearch
536 const queryJSON = getJsonQuery();
537
538 if (queryJSON.error) {
539 return;
540 }
541
542 const searchText = input.value;
543 const placeholder = 'ep_autosuggest_placeholder';
544 const postTypes = getPostTypesFromForm(input.form);
545
546 if (searchText.length >= 2) {
547 setFormIsLoading(true, input);
548
549 let query = buildSearchQuery(searchText, placeholder, queryJSON);
550
551 if (postTypes.length > 0) {
552 query = JSON.parse(query);
553
554 if (typeof query.post_filter.bool.must !== 'undefined') {
555 query.post_filter.bool.must.push({
556 terms: {
557 'post_type.raw': postTypes,
558 },
559 });
560 }
561
562 query = JSON.stringify(query);
563 }
564
565 // fetch the results
566 const response = await esSearch(query, searchText);
567
568 if (response && response._shards && response._shards.successful > 0) {
569 const hits = checkForOrderedPosts(response.hits.hits, searchText);
570
571 if (hits.length === 0) {
572 hideAutosuggestBox();
573 } else {
574 updateAutosuggestBox(hits, input);
575 }
576 } else {
577 hideAutosuggestBox();
578 }
579
580 setFormIsLoading(false, input);
581 } else if (searchText.length === 0) {
582 hideAutosuggestBox();
583 }
584 };
585
586 const debounceFetchResults = debounce(fetchResults, 200);
587
588 /**
589 * Callback for keyup in Autosuggest container.
590 *
591 * Calls a debounced function to get the search results via
592 * ajax request.
593 *
594 * @param {event} event - keyup event
595 */
596 const handleKeyup = (event) => {
597 event.preventDefault();
598 const { target, key, keyCode } = event;
599
600 if (key === 'Escape' || key === 'Esc' || keyCode === 27) {
601 hideAutosuggestBox();
602 toggleInputAria(false, target);
603 setInputActiveDescendant('', target);
604 return;
605 }
606
607 if (keyCodes.includes(keyCode) && target.value !== '') {
608 handleUpDown(event);
609 return;
610 }
611
612 const input = event.target;
613 debounceFetchResults(input);
614 };
615
616 /**
617 * Wrap an element with an autosuggest container.
618 *
619 * @param {Element} element Element to wrap.
620 * @returns {void}
621 */
622 const wrapInAutosuggestContainer = (element) => {
623 const epContainer = document.createElement('div');
624
625 epContainer.classList.add('ep-autosuggest-container');
626
627 element.insertAdjacentElement('afterend', epContainer);
628
629 epContainer.appendChild(element);
630 };
631
632 /**
633 * Insert an autosuggest list after an element.
634 *
635 * @param {Element} element Element to add the autosuggest list after.
636 * @returns {void}
637 */
638 const insertAutosuggestElement = (element) => {
639 if (!autosuggestElement) {
640 autosuggestElement = document.createElement('div');
641 autosuggestElement.classList.add('ep-autosuggest');
642
643 const autosuggestList = document.createElement('ul');
644
645 autosuggestList.classList.add('autosuggest-list');
646 autosuggestList.setAttribute('role', 'listbox');
647
648 autosuggestElement.appendChild(autosuggestList);
649 }
650
651 const clonedElement = autosuggestElement.cloneNode(true);
652
653 element.insertAdjacentElement('afterend', clonedElement);
654 };
655
656 /**
657 * Prepare an input for Autosuggest.
658 *
659 * @param {Element} input Input to prepare.
660 * @returns {void}
661 */
662 const prepareInputForAutosuggest = (input) => {
663 /**
664 * Skip facet widget search fields.
665 */
666 if (input.classList.contains('facet-search')) {
667 return;
668 }
669
670 /**
671 * Disable autocomplete.
672 */
673 input.setAttribute('autocomplete', 'off');
674
675 /**
676 * We know the markup of the Search block, so we don't need to add a
677 * wrapper.
678 */
679 if (input.classList.contains('wp-block-search__input')) {
680 input.form.classList.add('ep-autosuggest-container');
681 insertAutosuggestElement(input.parentElement);
682 } else {
683 wrapInAutosuggestContainer(input);
684 insertAutosuggestElement(input);
685 }
686
687 /**
688 * Dispatch an event announcing the input has moved.
689 */
690 const event = new CustomEvent('elasticpress.input.moved');
691
692 input.dispatchEvent(event);
693
694 /**
695 * Listen for any events:
696 *
697 * keyup
698 * send them for a query to the Elasticsearch server
699 * handle up and down keys to move between results
700 *
701 * blur
702 * hide the autosuggest box
703 */
704 input.addEventListener('keyup', handleKeyup);
705 input.addEventListener('blur', function () {
706 window.setTimeout(hideAutosuggestBox, 200);
707 });
708 };
709
710 /**
711 * Find inputs within an element and prepare them for Autosuggest.
712 *
713 * @param {Element} element Element to find inputs within.
714 * @returns {void}
715 */
716 const findAndPrepareInputsForAutosuggest = (element) => {
717 const inputs = element.querySelectorAll(selectors);
718
719 if (inputs) {
720 Array.from(inputs).forEach(prepareInputForAutosuggest);
721 }
722 };
723
724 /**
725 * Observe the document for new potential Autosuggest inputs, and add
726 * Autosuggest to any found inputs.
727 *
728 * @returns {void}
729 */
730 const observeDocumentForInputs = () => {
731 const target = document.body;
732 const config = {
733 subtree: true,
734 childList: true,
735 };
736
737 const observer = new MutationObserver((mutations, observer) => {
738 mutations.forEach((mutation) => {
739 Array.from(mutation.addedNodes).forEach((node) => {
740 if (node.nodeType !== Node.ELEMENT_NODE) {
741 return;
742 }
743
744 /**
745 * Adding autosuggest to an input moves it in the DOM,
746 * which would trigger our observer, so we need to
747 * stop observing until it's been prepared.
748 */
749 observer.disconnect();
750
751 /**
752 * If the node is an input, prepare it for Autosuggest if
753 * it matches the selectors, otherwise search the node for
754 * inputs.
755 */
756 if (node.tagName === 'INPUT') {
757 if (node.matches(selectors)) {
758 prepareInputForAutosuggest(node);
759 }
760 } else {
761 findAndPrepareInputsForAutosuggest(node);
762 }
763
764 /**
765 * Resume observing.
766 */
767 observer.observe(target, config);
768 });
769 });
770 });
771
772 observer.observe(target, config);
773 };
774
775 /**
776 * Add autosuggest to any inputs in the document.
777 */
778 findAndPrepareInputsForAutosuggest(document.body);
779
780 /**
781 * When the DOM is ready start observing for new inputs.
782 */
783 domReady(observeDocumentForInputs);
784 }
785