import { Utils } from './utils';
import { Dropdown, DropdownOptions } from './dropdown';
import { Component, BaseOptions, InitElements, MElement } from './component';
export interface AutocompleteData {
/**
* A primitive value that can be converted to string.
* If "text" is not provided, it will also be used as "option text" as well
*/
id: string | number;
/**
* This optional attribute is used as "display value" for the current entry.
* When provided, it will also be taken into consideration by the standard search function.
*/
text?: string;
/**
* This optional attribute is used to provide a valid image URL to the current option.
*/
image?: string;
/**
* Optional attributes which describes the option.
*/
description?: string;
}
export interface AutocompleteOptions extends BaseOptions {
/**
* Data object defining autocomplete options with
* optional icon strings.
*/
data: AutocompleteData[];
/**
* Flag which can be set if multiple values can be selected. The Result will be an Array.
* @default false
*/
isMultiSelect: boolean;
/**
* Callback for when autocompleted.
*/
onAutocomplete: (entries: AutocompleteData[]) => void;
/**
* Minimum number of characters before autocomplete starts.
* @default 1
*/
minLength: number;
/**
* The height of the Menu which can be set via css-property.
* @default '300px'
*/
maxDropDownHeight: string;
/**
* Function is called when the input text is altered and data can also be loaded asynchronously.
* If the results are collected the items in the list can be updated via the function setMenuItems(collectedItems).
* @param text Searched text.
* @param autocomplete Current autocomplete instance.
*/
onSearch: (text: string, autocomplete: Autocomplete) => void;
/**
* If true will render the key from each item directly as HTML.
* User input MUST be properly sanitized first.
* @default false
*/
allowUnsafeHTML: boolean;
/**
* Pass options object to select dropdown initialization.
* @default {}
*/
dropdownOptions: Partial;
/**
* Predefined selected values
*/
selected: number[] | string[];
}
const _defaults: AutocompleteOptions = {
data: [], // Autocomplete data set
onAutocomplete: null, // Callback for when autocompleted
dropdownOptions: {
// Default dropdown options
autoFocus: false,
closeOnClick: false,
coverTrigger: false
},
minLength: 1, // Min characters before autocomplete starts
isMultiSelect: false,
onSearch: (text: string, autocomplete: Autocomplete) => {
const normSearch = text.toLocaleLowerCase();
autocomplete.setMenuItems(
autocomplete.options.data.filter((option) =>
option.id.toString().toLocaleLowerCase().includes(normSearch)
|| option.text?.toLocaleLowerCase().includes(normSearch)
)
);
},
maxDropDownHeight: '300px',
allowUnsafeHTML: false,
selected: []
};
export class Autocomplete extends Component {
declare el: HTMLInputElement;
/** If the autocomplete is open. */
isOpen: boolean;
/** Number of matching autocomplete options. */
count: number;
/** Index of the current selected option. */
activeIndex: number;
private oldVal: string;
private $active: HTMLElement | null;
private _mousedown: boolean;
container: HTMLElement;
/** Instance of the dropdown plugin for this autocomplete. */
dropdown: Dropdown;
static _keydown: boolean;
selectedValues: AutocompleteData[];
menuItems: AutocompleteData[];
constructor(el: HTMLInputElement, options: Partial) {
super(el, options, Autocomplete);
this.el['M_Autocomplete'] = this;
this.options = {
...Autocomplete.defaults,
...options
};
this.isOpen = false;
this.count = 0;
this.activeIndex = -1;
this.oldVal = '';
this.selectedValues = this.selectedValues || this.options.selected.map((value) => { id: value }) || [];
this.menuItems = this.options.data || [];
this.$active = null;
this._mousedown = false;
this._setupDropdown();
this._setupEventHandlers();
}
static get defaults(): AutocompleteOptions {
return _defaults;
}
/**
* Initializes instance of Autocomplete.
* @param el HTML element.
* @param options Component options.
*/
static init(el: HTMLInputElement, options?: Partial): Autocomplete;
/**
* Initializes instances of Autocomplete.
* @param els HTML elements.
* @param options Component options.
*/
static init(
els: InitElements,
options?: Partial
): Autocomplete[];
/**
* Initializes instances of Autocomplete.
* @param els HTML elements.
* @param options Component options.
*/
static init(
els: HTMLInputElement | InitElements,
options: Partial = {}
): Autocomplete | Autocomplete[] {
return super.init(els, options, Autocomplete);
}
static getInstance(el: HTMLElement): Autocomplete {
return el['M_Autocomplete'];
}
destroy() {
this._removeEventHandlers();
this._removeDropdown();
this.el['M_Autocomplete'] = undefined;
}
_setupEventHandlers() {
this.el.addEventListener('blur', this._handleInputBlur);
this.el.addEventListener('keyup', this._handleInputKeyup);
this.el.addEventListener('focus', this._handleInputFocus);
this.el.addEventListener('keydown', this._handleInputKeydown);
this.el.addEventListener('click', this._handleInputClick);
this.container.addEventListener('mousedown', this._handleContainerMousedownAndTouchstart);
this.container.addEventListener('mouseup', this._handleContainerMouseupAndTouchend);
if (typeof window.ontouchstart !== 'undefined') {
this.container.addEventListener('touchstart', this._handleContainerMousedownAndTouchstart);
this.container.addEventListener('touchend', this._handleContainerMouseupAndTouchend);
}
}
_removeEventHandlers() {
this.el.removeEventListener('blur', this._handleInputBlur);
this.el.removeEventListener('keyup', this._handleInputKeyup);
this.el.removeEventListener('focus', this._handleInputFocus);
this.el.removeEventListener('keydown', this._handleInputKeydown);
this.el.removeEventListener('click', this._handleInputClick);
this.container.removeEventListener('mousedown', this._handleContainerMousedownAndTouchstart);
this.container.removeEventListener('mouseup', this._handleContainerMouseupAndTouchend);
if (typeof window.ontouchstart !== 'undefined') {
this.container.removeEventListener('touchstart', this._handleContainerMousedownAndTouchstart);
this.container.removeEventListener('touchend', this._handleContainerMouseupAndTouchend);
}
}
_setupDropdown() {
this.container = document.createElement('ul');
this.container.style.maxHeight = this.options.maxDropDownHeight;
this.container.id = `autocomplete-options-${Utils.guid()}`;
this.container.classList.add('autocomplete-content', 'dropdown-content');
this.container.ariaExpanded = 'true';
this.el.setAttribute('data-target', this.container.id);
this.menuItems.forEach((menuItem) => {
const itemElement = this._createDropdownItem(menuItem);
this.container.append(itemElement);
});
// ! Issue in Component Dropdown: _placeDropdown moves dom-position
this.el.parentElement.appendChild(this.container);
// Initialize dropdown
const dropdownOptions = {
...Autocomplete.defaults.dropdownOptions,
...this.options.dropdownOptions
};
// @todo shouldn't we conditionally check if dropdownOptions.onItemClick is set in first place?
const userOnItemClick = dropdownOptions.onItemClick;
// Ensuring the select Option call when user passes custom onItemClick function to dropdown
dropdownOptions.onItemClick = (li) => {
if (!li) return;
const entryID = li.getAttribute('data-id');
this.selectOption(entryID);
// Handle user declared onItemClick if needed
if (userOnItemClick && typeof userOnItemClick === 'function')
userOnItemClick.call(this.dropdown, this.el);
};
this.dropdown = Dropdown.init(this.el, dropdownOptions);
// ! Workaround for Label: move label up again
// TODO: Just use PopperJS in future!
const label = this.el.parentElement.querySelector('label');
if (label) this.el.after(label);
// Sketchy removal of dropdown click handler
this.el.removeEventListener('click', this.dropdown._handleClick);
if(!this.options.isMultiSelect && !(this.options.selected.length === 0)) {
const selectedValue = this.menuItems.filter((value) => value.id === this.selectedValues[0].id);
this.el.value = selectedValue[0].text;
}
// Set Value if already set in HTML
if (this.el.value) this.selectOption(this.el.value);
// Add StatusInfo
const div = document.createElement('div');
div.classList.add('status-info');
div.setAttribute('style', 'position: absolute;right:0;top:0;');
this.el.parentElement.appendChild(div);
this._updateSelectedInfo();
}
_removeDropdown() {
this.container.ariaExpanded = 'false';
this.container.parentNode.removeChild(this.container);
}
_handleInputBlur = () => {
if (!this._mousedown) {
this.close();
this._resetAutocomplete();
}
};
_handleInputKeyup = (e: KeyboardEvent) => {
if (e.type === 'keyup') Autocomplete._keydown = false;
this.count = 0;
const actualValue = this.el.value.toLocaleLowerCase();
// Don't capture enter or arrow key usage.
if (
Utils.keys.ENTER.includes(e.key) ||
Utils.keys.ARROW_UP.includes(e.key) ||
Utils.keys.ARROW_DOWN.includes(e.key)
)
return;
// Check if the input isn't empty
// Check if focus triggered by tab
if (this.oldVal !== actualValue && Utils.tabPressed) {
this.open();
}
this._inputChangeDetection(actualValue);
};
_handleInputFocus = () => {
this.count = 0;
const actualValue = this.el.value.toLocaleLowerCase();
this._inputChangeDetection(actualValue);
};
_inputChangeDetection = (value: string) => {
// Value has changed!
if (this.oldVal !== value) {
this._setStatusLoading();
this.options.onSearch(this.el.value, this);
}
// Reset Single-Select when Input cleared
if (!this.options.isMultiSelect && this.el.value.length === 0) {
this.selectedValues = [];
this._triggerChanged();
}
this.oldVal = value;
};
_handleInputKeydown = (e: KeyboardEvent) => {
Autocomplete._keydown = true;
// Arrow keys and enter key usage
const numItems = this.container.querySelectorAll('li').length;
// select element on Enter
if (Utils.keys.ENTER.includes(e.key) && this.activeIndex >= 0) {
const liElement = this.container.querySelectorAll('li')[this.activeIndex];
if (liElement) {
this.selectOption(liElement.getAttribute('data-id'));
e.preventDefault();
}
return;
}
// Capture up and down key
if (Utils.keys.ARROW_UP.includes(e.key) || Utils.keys.ARROW_DOWN.includes(e.key)) {
e.preventDefault();
if (Utils.keys.ARROW_UP.includes(e.key) && this.activeIndex > 0) this.activeIndex--;
if (Utils.keys.ARROW_DOWN.includes(e.key) && this.activeIndex < numItems - 1)
this.activeIndex++;
this.$active?.classList.remove('active');
if (this.activeIndex >= 0) {
this.$active = this.container.querySelectorAll('li')[this.activeIndex];
this.$active?.classList.add('active');
// Focus selected
this.container.children[this.activeIndex].scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
}
}
};
_handleInputClick = () => {
this.open();
};
_handleContainerMousedownAndTouchstart = () => {
this._mousedown = true;
};
_handleContainerMouseupAndTouchend = () => {
this._mousedown = false;
};
_resetCurrentElementPosition() {
this.activeIndex = -1;
this.$active?.classList.remove('active');
}
_resetAutocomplete() {
this.container.replaceChildren();
this._resetCurrentElementPosition();
this.oldVal = null;
this.isOpen = false;
this._mousedown = false;
}
_highlightPartialText(input: string, label: string) {
const start = label.toLocaleLowerCase().indexOf('' + input.toLocaleLowerCase() + '');
const end = start + input.length - 1;
//custom filters may return results where the string does not match any part
if (start == -1 || end == -1) {
return [label, '', ''];
}
return [label.slice(0, start), label.slice(start, end + 1), label.slice(end + 1)];
}
_createDropdownItem(entry: AutocompleteData) {
const item = document.createElement('li');
item.setAttribute('data-id', entry.id);
item.setAttribute(
'style',
'display:grid; grid-auto-flow: column; user-select: none; align-items: center;'
);
item.tabIndex = 0;
// Checkbox
if (this.options.isMultiSelect) {
item.innerHTML = `
sel.id === entry.id) ? ' checked="checked"' : ''
}>
`;
}
// Image
if (entry.image) {
const img = document.createElement('img');
img.classList.add('circle');
img.src = entry.image;
item.appendChild(img);
}
// Text
const inputText = this.el.value.toLocaleLowerCase();
const parts = this._highlightPartialText(inputText, (entry.text || entry.id).toString());
const div = document.createElement('div');
div.setAttribute('style', 'line-height:1.2;font-weight:500;');
if (this.options.allowUnsafeHTML) {
div.innerHTML = parts[0] + '' + parts[1] + '' + parts[2];
} else {
div.appendChild(document.createTextNode(parts[0]));
if (parts[1]) {
const highlight = document.createElement('span');
highlight.textContent = parts[1];
highlight.classList.add('highlight');
div.appendChild(highlight);
div.appendChild(document.createTextNode(parts[2]));
}
}
const itemText = document.createElement('div');
itemText.classList.add('item-text');
itemText.setAttribute('style', 'padding:5px;overflow:hidden;');
item.appendChild(itemText);
item.querySelector('.item-text').appendChild(div);
// Description
if (
typeof entry.description === 'string' ||
(typeof entry.description === 'number' && !isNaN(entry.description))
) {
const description = document.createElement('small');
description.setAttribute(
'style',
'line-height:1.3;color:grey;white-space:nowrap;text-overflow:ellipsis;display:block;width:90%;overflow:hidden;'
);
description.innerText = entry.description;
item.querySelector('.item-text').appendChild(description);
}
// Set Grid
const getGridConfig = () => {
if (this.options.isMultiSelect) {
if (entry.image) return '40px min-content auto'; // cb-img-txt
return '40px auto'; // cb-txt
}
if (entry.image) return 'min-content auto'; // img-txt
return 'auto'; // txt
};
item.style.gridTemplateColumns = getGridConfig();
return item;
}
_renderDropdown() {
this._resetAutocomplete();
// Check if Data is empty
if (this.menuItems.length === 0) {
this.menuItems = this.selectedValues; // Show selected Items
}
for (let i = 0; i < this.menuItems.length; i++) {
const item = this._createDropdownItem(this.menuItems[i]);
this.container.append(item);
}
}
_setStatusLoading() {
this.el.parentElement.querySelector('.status-info').innerHTML =
``;
}
_updateSelectedInfo() {
const statusElement = this.el.parentElement.querySelector('.status-info');
if (statusElement) {
if (this.options.isMultiSelect)
statusElement.innerHTML = this.selectedValues.length.toString();
else statusElement.innerHTML = '';
}
}
_refreshInputText() {
if (this.selectedValues.length === 1) {
const entry = this.selectedValues[0];
this.el.value = entry.text || entry.id; // Write Text to Input
}
}
_triggerChanged() {
this.el.dispatchEvent(new Event('change'));
// Trigger Autocomplete Event
if (typeof this.options.onAutocomplete === 'function')
this.options.onAutocomplete.call(this, this.selectedValues);
}
/**
* Show autocomplete.
*/
open = () => {
const inputText = this.el.value.toLocaleLowerCase();
this._resetAutocomplete();
if (inputText.length >= this.options.minLength) {
this.isOpen = true;
this._renderDropdown();
}
// Open dropdown
if (!this.dropdown.isOpen) {
setTimeout(() => {
this.dropdown.open();
}, 0); // TODO: why?
} else this.dropdown.recalculateDimensions(); // Recalculate dropdown when its already open
};
/**
* Hide autocomplete.
*/
close = () => {
this.dropdown.close();
};
/**
* Updates the visible or selectable items shown in the menu.
* @param menuItems Items to be available.
* @param selected Selected item ids
* @param open Option to conditionally open dropdown
*/
setMenuItems(menuItems: AutocompleteData[], selected: number[] | string[] = null, open: boolean = true) {
this.menuItems = menuItems;
this.options.data = menuItems;
if (selected) {
this.selectedValues = this.menuItems.filter(
(item) => !(selected.indexOf(item.id) === -1)
);
}
if (this.options.isMultiSelect) {
this._renderDropdown();
} else {
this._refreshInputText();
}
if (open) this.open();
this._updateSelectedInfo();
this._triggerChanged();
}
/**
* Sets selected values.
* @deprecated @see https://github.com/materializecss/materialize/issues/552
* @param entries
*/
setValues(entries: AutocompleteData[]) {
this.selectedValues = entries;
this._updateSelectedInfo();
if (!this.options.isMultiSelect) {
this._refreshInputText();
}
this._triggerChanged();
}
/**
* Select a specific autocomplete option via id-property.
* @param id The id of a data-entry.
*/
selectOption(id: number | string) {
const entry = this.menuItems.find((item) => item.id == id);
if (!entry) return;
// Toggle Checkbox
/* const li = this.container.querySelector('li[data-id="' + id + '"]');
if (!li) return;*/
if (this.options.isMultiSelect) {
/* const checkbox = li.querySelector('input[type="checkbox"]');
checkbox.checked = !checkbox.checked;*/
if (!(this.selectedValues.filter(
(selectedEntry) => selectedEntry.id === entry.id
).length >= 1)) this.selectedValues.push(entry);
else this.selectedValues = this.selectedValues.filter(
(selectedEntry) => selectedEntry.id !== entry.id
);
this._renderDropdown();
this.el.focus();
} else {
// Single-Select
this.selectedValues = [entry];
this._refreshInputText();
this._resetAutocomplete();
this.close();
}
this._updateSelectedInfo();
this._triggerChanged();
}
selectOptions(ids: []) {
const entries = this.menuItems.filter(
(item) => !(ids.indexOf(item.id) === -1)
);
if (!entries) return;
this.selectedValues = entries;
this._renderDropdown();
}
}