Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Autosuggest on dynamic elements. Addresses #2387. #2404

Merged
merged 4 commits into from
Oct 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 164 additions & 85 deletions assets/js/autosuggest.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
escapeDoubleQuotes,
replaceGlobally,
debounce,
domReady,
} from './utils/helpers';
import 'element-closest';
import 'promise-polyfill/src/polyfill';
Expand Down Expand Up @@ -391,82 +392,8 @@ function init() {
return;
}

const epInputNodes = document.querySelectorAll(selectors);

// build the container into which we place the search results.
// These will be cloned later for each instance
// of autosuggest inputs
const epAutosuggest = document.createElement('div');
epAutosuggest.classList.add('ep-autosuggest');
const autosuggestList = document.createElement('ul');
autosuggestList.classList.add('autosuggest-list');
autosuggestList.setAttribute('role', 'listbox');
epAutosuggest.appendChild(autosuggestList);

// Build the auto-suggest containers
// excluding the facet search field and search block field
const epInputs = Array.from(epInputNodes).filter(
(node) =>
!node.classList.contains('facet-search') &&
!node.classList.contains('wp-block-search__input'),
);

// Handle search blocks separately
const epBlockInputs = Array.from(epInputNodes).filter((node) =>
node.classList.contains('wp-block-search__input'),
);

epInputs.forEach((input) => {
const epContainer = document.createElement('div');
epContainer.classList.add('ep-autosuggest-container');

// Disable autocomplete
input.setAttribute('autocomplete', 'off');

// insert the container - later we will place
// the input inside this container
input.insertAdjacentElement('afterend', epContainer);

// move the input inside the container
const form = input.closest('form');
const container = form.querySelector('.ep-autosuggest-container');
container.appendChild(input);

const clonedContainer = epAutosuggest.cloneNode(true);
input.insertAdjacentElement('afterend', clonedContainer);

// announce that this is has been done
const event = new CustomEvent('elasticpress.input.moved');
input.dispatchEvent(event);
});

/**
* For search blocks, because we know the output mark up, we reuse it
* for autosuggest.
*/
epBlockInputs.forEach((input) => {
// Disable autocomplete
input.setAttribute('autocomplete', 'off');

input.form.classList.add('ep-autosuggest-container');

const clonedContainer = epAutosuggest.cloneNode(true);
input.parentElement.insertAdjacentElement('afterend', clonedContainer);

// announce that this is has been done
const event = new CustomEvent('elasticpress.input.moved');
input.dispatchEvent(event);
});

if (epInputs.length > 0) {
epAutosuggest.setAttribute(
'style',
`
top: ${epInputs[0].offsetHeight - 1};
background-color: ${getComputedStyle(epInputs[0], 'background-color')}
`,
);
}
// For the Autosuggest element that will be cloned.
let autosuggestElement;

// to be used by the handleUpDown function
// to keep track of the currently selected result
Expand Down Expand Up @@ -509,7 +436,7 @@ function init() {
* helper function to deselect results
*/
const deSelectResults = () => {
results.forEach((result) => {
Array.from(results).forEach((result) => {
result.classList.remove('selected');
result.setAttribute('aria-selected', 'false');
});
Expand Down Expand Up @@ -685,19 +612,171 @@ function init() {
};

/**
* Listen for any events:
* Wrap an element with an autosuggest container.
*
* @param {Element} element Element to wrap.
* @return {void}
*/
const wrapInAutosuggestContainer = (element) => {
const epContainer = document.createElement('div');

epContainer.classList.add('ep-autosuggest-container');

element.insertAdjacentElement('afterend', epContainer);

epContainer.appendChild(element);
};

/**
* Insert an autosuggest list after an element.
*
* keyup
* send them for a query to the Elasticsearch server
* handle up and down keys to move between results
* @param {Element} element Element to add the autosuggest list after.
* @return {void}
*/
const insertAutosuggestElement = (element) => {
if (!autosuggestElement) {
autosuggestElement = document.createElement('div');
autosuggestElement.classList.add('ep-autosuggest');

const autosuggestList = document.createElement('ul');

autosuggestList.classList.add('autosuggest-list');
autosuggestList.setAttribute('role', 'listbox');

autosuggestElement.appendChild(autosuggestList);
}

const clonedElement = autosuggestElement.cloneNode(true);

element.insertAdjacentElement('afterend', clonedElement);
};

/**
* Prepare an input for Autosuggest.
*
* blur
* hide the autosuggest box
* @param {Element} input Input to prepare.
* @return {void}
*/
[...epInputs, ...epBlockInputs].forEach((input) => {
const prepareInputForAutosuggest = (input) => {
/**
* Skip facet widget search fields.
*/
if (input.classList.contains('facet-search')) {
return;
}

/**
* Disable autocomplete.
*/
input.setAttribute('autocomplete', 'off');

/**
* We know the markup of the Search block, so we don't need to add a
* wrapper.
*/
if (input.classList.contains('wp-block-search__input')) {
input.form.classList.add('ep-autosuggest-container');
insertAutosuggestElement(input.parentElement);
} else {
wrapInAutosuggestContainer(input);
insertAutosuggestElement(input);
}

/**
* Dispatch an event announcing the input has moved.
*/
const event = new CustomEvent('elasticpress.input.moved');

input.dispatchEvent(event);

/**
* Listen for any events:
*
* keyup
* send them for a query to the Elasticsearch server
* handle up and down keys to move between results
*
* blur
* hide the autosuggest box
*/
input.addEventListener('keyup', handleKeyup);
input.addEventListener('blur', function () {
window.setTimeout(hideAutosuggestBox, 200);
});
});
};

/**
* Find inputs within an element and prepare them for Autosuggest.
*
* @param {Element} element Element to find inputs within.
* @return {void}
*/
const findAndPrepareInputsForAutosuggest = (element) => {
const inputs = element.querySelectorAll(selectors);

if (inputs) {
Array.from(inputs).forEach(prepareInputForAutosuggest);
}
};

/**
* Observe the document for new potential Autosuggest inputs, and add
* Autosuggest to any found inputs.
*
* @return {void}
*/
const observeDocumentForInputs = () => {
const target = document.body;
const config = {
subtree: true,
childList: true,
};

const observer = new MutationObserver((mutations, observer) => {
mutations.forEach((mutation) => {
Array.from(mutation.addedNodes).forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}

/**
* Adding autosuggest to an input moves it in the DOM,
* which would trigger our observer, so we need to
* stop observing until it's been prepared.
*/
observer.disconnect();

/**
* If the node is an input, prepare it for Autosuggest if
* it matches the selectors, otherwise search the node for
* inputs.
*/
if (node.tagName === 'INPUT') {
if (node.matches(selectors)) {
prepareInputForAutosuggest(node);
}
} else {
findAndPrepareInputsForAutosuggest(node);
}

/**
* Resume observing.
*/
observer.observe(target, config);
});
});
});

observer.observe(target, config);
};

/**
* Add autosuggest to any inputs in the document.
*/
findAndPrepareInputsForAutosuggest(document.body);

/**
* When the DOM is ready start observing for new inputs.
*/
domReady(observeDocumentForInputs);
}
24 changes: 24 additions & 0 deletions assets/js/utils/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,27 @@ export const showElements = (els) => showOrHideNodes(els, 'inline-block');
* @return {Function} - showOrHideNodes
*/
export const hideElements = (els) => showOrHideNodes(els, 'none');

/**
* Specify a function to execute when the DOM is fully loaded.
*
* @param {Function} callback A function to execute after the DOM is ready.
* @return {void}
*/
export const domReady = (callback) => {
if (typeof document === 'undefined') {
return;
}

if (
document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly.
document.readyState === 'interactive' // DOMContentLoaded fires at this point, so we call directly.
) {
callback();
return;
}

// DOMContentLoaded has not fired yet, delay callback until then.
// eslint-disable-next-line @wordpress/no-global-event-listener
document.addEventListener('DOMContentLoaded', callback);
};
2 changes: 1 addition & 1 deletion dist/js/autosuggest-script.min.js

Large diffs are not rendered by default.