PluginProbe
ElasticPress / 4.3.0
ElasticPress v4.3.0
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.3.0, at assets/js/autosuggest.js

800 lines 19.9 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 if (typeof window.epAutosuggestListItemsHTMLFilter !== 'undefined') {
279 itemString = window.epAutosuggestListItemsHTMLFilter(itemString, options, input);
280 }
281
282 // append list items to the list
283 suggestList.innerHTML = itemString;
284
285 const autosuggestItems = Array.from(document.querySelectorAll('.autosuggest-link'));
286
287 suggestList.addEventListener('click', (event) => {
288 event.preventDefault();
289 const target =
290 event.target.tagName === epas.highlightingTag?.toUpperCase()
291 ? event.target.parentElement
292 : event.target;
293
294 if (autosuggestItems.includes(target)) {
295 selectItem(input, target);
296 }
297 });
298
299 return true;
300 }
301
302 /**
303 * Hide the auto suggest box
304 *
305 * @returns {boolean} returns true
306 */
307 function hideAutosuggestBox() {
308 const lists = document.querySelectorAll('.autosuggest-list');
309 const containers = document.querySelectorAll('.ep-autosuggest');
310
311 // empty all EP results lists
312 lists.forEach((list) => {
313 while (list.firstChild) {
314 list.removeChild(list.firstChild);
315 }
316 });
317
318 // hide all EP results containers
319 containers.forEach((container) => {
320 // eslint-disable-next-line
321 container.style = 'display: none;';
322 });
323
324 return true;
325 }
326
327 /**
328 * Checks for any manually ordered posts and puts them in the correct place
329 *
330 * @param {Array} hits - ES results
331 * @param {string} searchTerm - user search term
332 * @returns {object} formatted hits
333 */
334 function checkForOrderedPosts(hits, searchTerm) {
335 const toInsert = {};
336 const taxName = 'ep_custom_result';
337 const lowerCaseSearchTerm = searchTerm.toLowerCase();
338
339 const filteredHits = hits.filter((hit) => {
340 // Should we retain this hit in its current position?
341 let retain = true;
342
343 if (undefined !== hit._source.terms && undefined !== hit._source.terms[taxName]) {
344 hit._source.terms[taxName].forEach((currentTerm) => {
345 if (currentTerm.name.toLowerCase() === lowerCaseSearchTerm) {
346 toInsert[currentTerm.term_order] = hit;
347
348 retain = false;
349 }
350 });
351 }
352
353 return retain;
354 });
355
356 const orderedInserts = {};
357
358 Object.keys(toInsert)
359 .sort()
360 .forEach((key) => {
361 orderedInserts[key] = toInsert[key];
362 });
363
364 if (Object.keys(orderedInserts).length > 0) {
365 Object.keys(orderedInserts).forEach((key) => {
366 const insertItem = orderedInserts[key];
367
368 filteredHits.splice(key - 1, 0, insertItem);
369 });
370 }
371
372 return filteredHits;
373 }
374
375 /**
376 * Add class to the form element while suggestions are being loaded
377 *
378 * @param {boolean} isLoading - whether suggestions are loading
379 * @param {Node} input - search input field
380 */
381 function setFormIsLoading(isLoading, input) {
382 const form = input.closest('form');
383
384 if (isLoading) {
385 form.classList.add('is-loading');
386 } else {
387 form.classList.remove('is-loading');
388 }
389 }
390
391 /**
392 * init method called if the epas endpoint is defined
393 */
394 function init() {
395 const selectors = [epas.defaultSelectors, epas.selector].filter(Boolean).join(',');
396
397 if (!selectors) {
398 return;
399 }
400
401 // For the Autosuggest element that will be cloned.
402 let autosuggestElement;
403
404 // to be used by the handleUpDown function
405 // to keep track of the currently selected result
406 let currentIndex;
407
408 // these are the keycodes we listen for in handleUpDown,
409 // and in handleKeyup
410 const keyCodes = [
411 38, // up
412 40, // down
413 13, // enter
414 ];
415
416 /**
417 * Handles keyup event on the search input
418 *
419 * @param {event} event - keyup event
420 */
421 const handleUpDown = (event) => {
422 if (!keyCodes.includes(event.keyCode)) {
423 return;
424 }
425
426 const input = event.target;
427 const container = findAncestorByClass(input, 'ep-autosuggest-container');
428 const suggestList = container.querySelector('.autosuggest-list');
429 const results = suggestList.children;
430
431 /**
432 * helper function to get the currently selected result
433 *
434 * @returns {number} index of the selected search result
435 */
436 const getSelectedResultIndex = () => {
437 const resultsArr = Array.from(results);
438 return resultsArr.findIndex((result) => result.classList.contains('selected'));
439 };
440
441 /**
442 * helper function to deselect results
443 */
444 const deSelectResults = () => {
445 Array.from(results).forEach((result) => {
446 result.classList.remove('selected');
447 result.setAttribute('aria-selected', 'false');
448 });
449 };
450
451 /**
452 * helper function to selected the next result
453 */
454 const selectNextResult = () => {
455 if (currentIndex >= 0) {
456 const el = results[currentIndex];
457 el.classList.add('selected');
458 el.setAttribute('aria-selected', 'true');
459 setInputActiveDescendant(el.id, input);
460 }
461 };
462
463 // select next or previous based on keyCode
464 // if enter, navigate to that element
465 switch (event.keyCode) {
466 case 38: // Up
467 // don't go less than the 0th index
468 currentIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : 0;
469 deSelectResults();
470 break;
471 case 40: // Down
472 if (typeof currentIndex === 'undefined') {
473 // index is not yet defined, so let's
474 // start with the first one
475 currentIndex = 0;
476 } else {
477 const current = getSelectedResultIndex();
478
479 // check for existence of next result
480 if (results[current + 1]) {
481 currentIndex = current + 1;
482 deSelectResults();
483 }
484 }
485 break;
486 case 13: // Enter
487 if (results[currentIndex]?.classList.contains('selected')) {
488 // navigate to the item defined in the span's data-url attribute
489 selectItem(input, results[currentIndex].querySelector('.autosuggest-link'));
490 }
491 break;
492 default:
493 // No item selected
494 break;
495 }
496
497 // only check next element if up and down key pressed
498 if (results[currentIndex] && results[currentIndex].classList.contains('autosuggest-item')) {
499 selectNextResult();
500 } else {
501 deSelectResults();
502 }
503
504 // keep cursor from heading back to the beginning in the input
505 if (event.keyCode === 38) {
506 // return false;
507 event.preventDefault();
508 }
509 };
510
511 /**
512 * Get the searched post types from the search form.
513 *
514 * @param {HTMLFormElement} form - form containing the search input field
515 * @returns {Array} - post types
516 * @since 3.6.0
517 */
518 function getPostTypesFromForm(form) {
519 const data = new FormData(form);
520
521 if (data.has('post_type')) {
522 return data.getAll('post_type').slice(-1);
523 }
524
525 if (data.has('post_type[]')) {
526 return data.getAll('post_type[]');
527 }
528
529 return [];
530 }
531
532 /**
533 * Calls the ajax request, and outputs the results.
534 * Called by the handleKeyup callback, debounced.
535 *
536 * @param {Node} input - search input field
537 */
538 const fetchResults = async (input) => {
539 // retrieves the PHP-genereated query to pass to ElasticSearch
540 const queryJSON = getJsonQuery();
541
542 if (queryJSON.error) {
543 return;
544 }
545
546 const searchText = input.value;
547 const placeholder = 'ep_autosuggest_placeholder';
548 const postTypes = getPostTypesFromForm(input.form);
549
550 if (searchText.length >= 2) {
551 setFormIsLoading(true, input);
552
553 let query = buildSearchQuery(searchText, placeholder, queryJSON);
554
555 if (postTypes.length > 0) {
556 query = JSON.parse(query);
557
558 if (typeof query.post_filter.bool.must !== 'undefined') {
559 query.post_filter.bool.must.push({
560 terms: {
561 'post_type.raw': postTypes,
562 },
563 });
564 }
565
566 query = JSON.stringify(query);
567 }
568
569 // Allow filtering the search query based on the input.
570 if (typeof window.epAutosuggestQueryFilter !== 'undefined') {
571 query = JSON.stringify(
572 window.epAutosuggestQueryFilter(JSON.parse(query), searchText, input),
573 );
574 }
575
576 // fetch the results
577 const response = await esSearch(query, searchText);
578
579 if (response && response._shards && response._shards.successful > 0) {
580 const hits = checkForOrderedPosts(response.hits.hits, searchText);
581
582 if (hits.length === 0) {
583 hideAutosuggestBox();
584 } else {
585 updateAutosuggestBox(hits, input);
586 }
587 } else {
588 hideAutosuggestBox();
589 }
590
591 setFormIsLoading(false, input);
592 } else if (searchText.length === 0) {
593 hideAutosuggestBox();
594 }
595 };
596
597 const debounceFetchResults = debounce(fetchResults, 200);
598
599 /**
600 * Callback for keyup in Autosuggest container.
601 *
602 * Calls a debounced function to get the search results via
603 * ajax request.
604 *
605 * @param {event} event - keyup event
606 */
607 const handleKeyup = (event) => {
608 event.preventDefault();
609 const { target, key, keyCode } = event;
610
611 if (key === 'Escape' || key === 'Esc' || keyCode === 27) {
612 hideAutosuggestBox();
613 toggleInputAria(false, target);
614 setInputActiveDescendant('', target);
615 return;
616 }
617
618 if (keyCodes.includes(keyCode) && target.value !== '') {
619 handleUpDown(event);
620 return;
621 }
622
623 const input = event.target;
624 debounceFetchResults(input);
625 };
626
627 /**
628 * Wrap an element with an autosuggest container.
629 *
630 * @param {Element} element Element to wrap.
631 * @returns {void}
632 */
633 const wrapInAutosuggestContainer = (element) => {
634 const epContainer = document.createElement('div');
635
636 epContainer.classList.add('ep-autosuggest-container');
637
638 element.insertAdjacentElement('afterend', epContainer);
639
640 epContainer.appendChild(element);
641 };
642
643 /**
644 * Insert an autosuggest list after an element.
645 *
646 * @param {Element} element Element to add the autosuggest list after.
647 * @returns {void}
648 */
649 const insertAutosuggestElement = (element) => {
650 if (!autosuggestElement) {
651 autosuggestElement = document.createElement('div');
652 autosuggestElement.classList.add('ep-autosuggest');
653
654 const autosuggestList = document.createElement('ul');
655
656 autosuggestList.classList.add('autosuggest-list');
657 autosuggestList.setAttribute('role', 'listbox');
658
659 autosuggestElement.appendChild(autosuggestList);
660 }
661
662 let clonedElement = autosuggestElement.cloneNode(true);
663
664 if (typeof window.epAutosuggestElementFilter !== 'undefined') {
665 clonedElement = window.epAutosuggestElementFilter(clonedElement, element);
666 }
667
668 element.insertAdjacentElement('afterend', clonedElement);
669 };
670
671 /**
672 * Prepare an input for Autosuggest.
673 *
674 * @param {Element} input Input to prepare.
675 * @returns {void}
676 */
677 const prepareInputForAutosuggest = (input) => {
678 /**
679 * Skip facet widget search fields.
680 */
681 if (input.classList.contains('facet-search')) {
682 return;
683 }
684
685 /**
686 * Disable autocomplete.
687 */
688 input.setAttribute('autocomplete', 'off');
689
690 /**
691 * We know the markup of the Search block, so we don't need to add a
692 * wrapper.
693 */
694 if (input.classList.contains('wp-block-search__input')) {
695 input.form.classList.add('ep-autosuggest-container');
696 insertAutosuggestElement(input.parentElement);
697 } else {
698 wrapInAutosuggestContainer(input);
699 insertAutosuggestElement(input);
700 }
701
702 /**
703 * Dispatch an event announcing the input has moved.
704 */
705 const event = new CustomEvent('elasticpress.input.moved');
706
707 input.dispatchEvent(event);
708
709 /**
710 * Listen for any events:
711 *
712 * keyup
713 * send them for a query to the Elasticsearch server
714 * handle up and down keys to move between results
715 *
716 * blur
717 * hide the autosuggest box
718 */
719 input.addEventListener('keyup', handleKeyup);
720 input.addEventListener('blur', function () {
721 window.setTimeout(hideAutosuggestBox, 200);
722 });
723 };
724
725 /**
726 * Find inputs within an element and prepare them for Autosuggest.
727 *
728 * @param {Element} element Element to find inputs within.
729 * @returns {void}
730 */
731 const findAndPrepareInputsForAutosuggest = (element) => {
732 const inputs = element.querySelectorAll(selectors);
733
734 if (inputs) {
735 Array.from(inputs).forEach(prepareInputForAutosuggest);
736 }
737 };
738
739 /**
740 * Observe the document for new potential Autosuggest inputs, and add
741 * Autosuggest to any found inputs.
742 *
743 * @returns {void}
744 */
745 const observeDocumentForInputs = () => {
746 const target = document.body;
747 const config = {
748 subtree: true,
749 childList: true,
750 };
751
752 const observer = new MutationObserver((mutations, observer) => {
753 mutations.forEach((mutation) => {
754 Array.from(mutation.addedNodes).forEach((node) => {
755 if (node.nodeType !== Node.ELEMENT_NODE) {
756 return;
757 }
758
759 /**
760 * Adding autosuggest to an input moves it in the DOM,
761 * which would trigger our observer, so we need to
762 * stop observing until it's been prepared.
763 */
764 observer.disconnect();
765
766 /**
767 * If the node is an input, prepare it for Autosuggest if
768 * it matches the selectors, otherwise search the node for
769 * inputs.
770 */
771 if (node.tagName === 'INPUT') {
772 if (node.matches(selectors)) {
773 prepareInputForAutosuggest(node);
774 }
775 } else {
776 findAndPrepareInputsForAutosuggest(node);
777 }
778
779 /**
780 * Resume observing.
781 */
782 observer.observe(target, config);
783 });
784 });
785 });
786
787 observer.observe(target, config);
788 };
789
790 /**
791 * Add autosuggest to any inputs in the document.
792 */
793 findAndPrepareInputsForAutosuggest(document.body);
794
795 /**
796 * When the DOM is ready start observing for new inputs.
797 */
798 domReady(observeDocumentForInputs);
799 }
800