/**
* @module webgis4u/components/AutoComplete
*/
import { createElement } from '../util/dom/createElement';
import KeyCodeEnum from '../util/dom/KeyCodeEnum';
import { PromiseStateEnum, state as promiseState } from '../util/promise/state';
import './AutoComplete/AutoComplete.scss';
/**
* @callback OnItemHoverCallback
* @param {Event} The original event
* @param {any} choice The choice that was hovered
*/
/**
* @callback OnItemSelectCallback
* @param {Event} event The original event
* @param {any} choice The choice that was hovered
*/
/**
* @callback OnListUpdatedCallback
* @param {Event} event The original event
* @param {Array<any>} choices The choices
*/
const CSS_CLASS = 'autocomplete';
const CSS_CLASS_ACTIVE = `${CSS_CLASS}-active`;
const CSS_CLASS_LIST = `${CSS_CLASS}-items`;
/**
* close all autocomplete lists in the document
* except the one passed as an argument
*
* @param {*} element The element that should not be closed
*/
function closeAllLists(element) {
// Convert 'live' list to array and loop it
[...document.getElementsByClassName(CSS_CLASS_LIST)].forEach((e) => {
if (element !== e && element !== e.parentNode) {
e.parentNode.removeChild(e);
}
});
}
// execute a function when someone clicks in the document
document.addEventListener('click', (e) => {
closeAllLists(e.target);
});
/**
* @typedef Options
* @type {object}
* @property {HTMLElement} element
* @property {number} [minLength=0] The minimum length at which the search is executed
* @property {string} [placeholder] The placeholder text
* @property {Function} [getChoiceText] Function that is executed with one list item and returns the text
* @property {module:webgis4u/components/AutoComplete~OnItemHoverCallback} [onItemHover]
* @property {module:webgis4u/components/AutoComplete~OnItemSelectCallback} [onItemSelected]
*/
/**
* Creates an auto complete able input element
*/
class AutoComplete {
/**
* @type {HTMLInputElement}
* The input element
*/
element;
/**
* @type {HTMLElement|null}
* The list element that will contain the items (values)
*/
list = null;
/**
* @type {number}
* The currently focused
*/
currentFocus;
/**
* @type {Array}
*/
choices = [];
/**
* @type {Array<any>}
* Array containing the choices that were filtered
*/
filteredChoices = [];
/**
* @type {Function|null}
*/
_getChoiceText = null;
/**
* @type {null|module:webgis4u/components/AutoComplete~OnItemSelectCallback}
* Callback for on Select
*/
onItemSelected = null;
/**
* @type {null|module:webgis4u/components/AutoComplete~OnItemHoverCallback}
* Callback for on hover
*/
onItemHover = null;
/**
* @type {null|module:webgis4u/components/AutoComplete~OnListUpdatedCallback}
* Callback for on hover
*/
_onListUpdated = null
/**
* @type {string}
* Message that is shown when no search results are found
*/
messageNotFound = '';
/**
* @type {string}
* Message that is shown while search is pending
*/
messagePending = '';
/**
* @type {Array<any>|Function}
* The source to provide the choices.
* Can be an array or a function
*/
source;
/**
* @type {number|null}
*/
limit = null;
/**
* Constructor
* @param {Options}
*/
constructor(options) {
this.element = options.element;
this.minLength = options.minLength || 0;
this.limit = options.limit || null;
this.placeholder = options.placeholder;
this.source = options.source;
this._getChoiceText = options.getChoiceText || null;
this.onItemSelected = options.onItemSelected || null;
this.onItemHover = options.onItemHover || null;
this._onListUpdated = options.onListUpdated || null;
// Set up the messages
const { notFound, pending } = options.messages || {};
this.messageNotFound = notFound || '';
this.messagePending = pending || '';
this.init();
}
init() {
this.initWrapper();
this.initElement();
}
initWrapper() {
this.wrapper = createElement({
tag: 'span',
cssClass: CSS_CLASS,
});
const {
element,
element: {
parentNode,
},
} = this;
parentNode.insertBefore(this.wrapper, element);
this.element = this.wrapper.insertBefore(element, null);
}
/**
* Initializes the element
* @private
*/
initElement() {
const {
element,
} = this;
let {
placeholder,
} = this;
// Read values from element
placeholder = placeholder || element.getAttribute('placeholder');
// Set element values
element.setAttribute('type', 'text');
element.setAttribute('placeholder', placeholder);
element.addEventListener('input', this.handleOnInput);
element.addEventListener('keydown', this.handleOnKeydown);
}
/**
* @param {any} choice The choice
* @returns {string} The text for the choice
* @private
*/
getChoiceText(choice) {
return (this._getChoiceText)
? this._getChoiceText(choice)
: choice;
}
/**
* Acquires the data
* @param {string} query The entered query
* @private
*/
async getSource(query) {
if (Array.isArray(this.source)) {
const regex = new RegExp(query, 'i');
return this.source.filter(choice => regex.test(this.getChoiceText(choice)));
}
if (typeof (this.source) === 'function') {
return this.source(query);
}
throw new TypeError('Source must be an array or an function');
}
/**
* Set the current element in the list active
*
* @param {HTMLCollection} listItems
* @private
*/
addActive(listItems) {
this.removeActive(listItems);
if (this.currentFocus >= listItems.length) this.currentFocus = 0;
if (this.currentFocus < 0) this.currentFocus = (listItems.length - 1);
const element = listItems[this.currentFocus];
element.classList.add(CSS_CLASS_ACTIVE);
const choice = this.filteredChoices[this.currentFocus];
if (this.onItemHover) {
this.onItemHover(null, choice);
}
}
/**
* a function to remove the "active" class from all autocomplete items
*
* @param {HTMLCollection} listItems
* @private
*/
removeActive(listItems) {
[...listItems].forEach((e) => {
e.classList.remove(CSS_CLASS_ACTIVE);
});
}
/**
* Creates the list item for the given choice
* @param {any} choice The choice for which the list item should be creates
* @returns {HTMLElement}
* @private
*/
createListItem(choice) {
const choiceValue = this.getChoiceText(choice);
const b = document.createElement('div');
b.setAttribute('data-value', choiceValue);
b.innerText = choiceValue;
// execute a function when someone clicks on the item value (DIV element)
b.addEventListener('click', (event) => {
this.onListItemClicked(event, choice, choiceValue);
});
b.addEventListener('mouseover', (event) => {
this.onItemHover(event, choice);
});
return b;
}
/**
* Creates the element that shows the pending state
* @returns {HTMLElement}
* @private
*/
createPending() {
const b = document.createElement('div');
const text = this.messagePending || '...';
b.innerHTML = `<i>${text}</i>`;
return b;
}
/**
* Event for the onClick event on a specific list item
* @param {Event} event The triggered event
* @param {any} choice The choice that was clicked
* @param {string} choiceValue The value shown as text for the choice
* @private
*/
onListItemClicked = (event, choice, choiceValue) => {
// insert the value for the autocomplete text field
this.element.value = choiceValue;
if (this.onItemSelected) {
this.onItemSelected(event, choice);
}
// close the list of autocompleted values,
// (or any other open lists of autocompleted values
closeAllLists();
}
/**
* Triggers the onListUpdate callback
* @param {Array} choices The choices shown in the list
* @private
*/
onListUpdated(choices) {
if (this._onListUpdated) {
this._onListUpdated(choices);
}
}
/**
* Updates the list
* @param {string} value
*/
async updateList(value) {
// If value does not have any value, do nothing
if (!value) { return; }
const { element } = this;
this.currentFocus = -1;
// Create list and store local reference
this.list = createElement({
tag: 'div',
cssClass: CSS_CLASS_LIST,
});
// append the DIV element as a child of the autocomplete container
element.parentNode.appendChild(this.list);
// Acquire the data
const MaybeResolved = this.getSource(value);
if (await promiseState(MaybeResolved) === PromiseStateEnum.Pending) {
this.list.appendChild(
this.createPending(),
);
}
// Wait for the data
this.filteredChoices = await MaybeResolved;
this.list.innerHTML = '';
// Add the not found if there are no choices
if (this.filteredChoices.length === 0) {
const notFoundElement = document.createElement('div');
notFoundElement.innerHTML = this.messageNotFound;
this.list.appendChild(notFoundElement);
this.onListUpdated(this.filteredChoices);
return;
}
// Limit the list of results
if (this.limit !== null) {
this.filteredChoices = this.filteredChoices.filter(
(o, index) => index < this.limit,
);
}
// And finally add the found choices
this.filteredChoices.forEach((choice) => {
this.list.appendChild(
this.createListItem(choice),
);
});
// Finally notify for list update
this.onListUpdated(this.filteredChoices);
}
/**
* Event listener for the input changed event
* @private
*/
handleOnInput = () => {
const {
element: {
value,
},
} = this;
// close any already open lists of autocompleted values
closeAllLists();
this.updateList(value);
}
/**
* Event listener for the input keydown event
* @private
*/
handleOnKeydown = (e) => {
const { filteredChoices, list } = this;
if (!filteredChoices || filteredChoices.length === 0) { return; }
if (!list) { return; }
const listItems = list.getElementsByTagName('div');
if (listItems.length === 0) { return; }
// Everything we need is available
const { keyCode } = e;
if (keyCode === KeyCodeEnum.ARROW_DOWN) {
// Focus next item
this.currentFocus += 1;
this.addActive(listItems);
} else if (keyCode === KeyCodeEnum.ARROW_UP) {
// Focus previous item
this.currentFocus -= 1;
this.addActive(listItems);
} else if (keyCode === KeyCodeEnum.ENTER) {
// Prevent forms from being submitted
e.preventDefault();
if (this.currentFocus > -1) {
// Simulate click on the active element
listItems[this.currentFocus].click();
}
}
}
}
export default AutoComplete;