@browser.style/auto-suggest
Version:
Auto-suggest component for browser.style
290 lines (245 loc) • 9.18 kB
JavaScript
import { FormElement } from '../common/form.element.js';
/**
* AutoSuggest
* @description <auto-suggest> is a custom element that provides a search input field with auto-suggest functionality.
* @author Mads Stoumann
* @version 1.0.24
* @summary 10-01-2025
* @class AutoSuggest
* @extends {FormElement}
*/
export class AutoSuggest extends FormElement {
get basePath() {
return new URL('.', import.meta.url).href;
}
constructor() {
super();
this.data = [];
}
initializeComponent() {
this.displayValue = this.getAttribute('display') || '';
this.defaultValues = {
input: this.getAttribute('display') || '',
value: this.getAttribute('value') || ''
};
this.initialObject = JSON.parse(this.getAttribute('initial-object') || 'null');
this.listId = `list${this.uuid()}`;
this.settings = ['api', 'api-array-path', 'api-display-path', 'api-text-path', 'api-value-path', 'cache', 'debounce', 'invalid', 'label', 'list-mode'].reduce((s, attr) => {
s[attr.replace(/-([a-z])/g, (_, l) => l.toUpperCase())] = this.getAttribute(attr) ?? null;
return s;
}, {});
if (!this.settings.api) {
console.error('API endpoint is not defined.');
return;
}
this.settings.cache = this.settings.cache === 'true';
this.settings.listMode = this.settings.listMode || 'datalist';
this.settings.nolimit = this.hasAttribute('nolimit');
this.settings.debounceTime = parseInt(this.settings.debounce) || 300;
this.root.innerHTML = this.template();
this.input = this.root.querySelector('input');
this.list = this.root.querySelector(`#${this.listId}`);
if (this.isFormElement) {
this.input.value = this.displayValue;
const initialValue = this.getAttribute('value') || '';
this.value = initialValue;
}
this.debouncedFetch = this.debounced(this.settings.debounceTime, this.fetchData.bind(this));
this.addEvents();
}
get displayValue() {
return this.isFormElement ?
(this.getAttribute('display') || this.input?.value || '') :
null;
}
set displayValue(v) {
if (!this.isFormElement) return;
this.setAttribute('display', v);
if (this.input) {
this.input.value = v;
}
}
addEvents() {
const selected = () => this.settings.listMode === 'ul' ? null :
[...this.list.options].find(entry => entry.value === this.input.value);
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (this.settings.nolimit) {
this.dispatchEvent(new CustomEvent('autoSuggestNoSelection', { bubbles: true }));
this.reset();
}
}
if (this.settings.listMode === 'ul' && e.key === 'ArrowDown' && this.list.children.length) {
e.preventDefault();
this.list.togglePopover(true);
this.list.children[0].focus();
}
if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
this.resetToDefault();
if (this.initialObject) this.dispatch(JSON.stringify(this.initialObject), true);
}
});
this.input.addEventListener('input', (e) => {
const value = this.input.value.length >= this.input.minLength ? this.input.value.toLowerCase() : '';
if (!value) return;
const option = selected();
if (option && (e.inputType === "insertReplacementText" || e.inputType == null)) {
this.value = option.dataset.value;
this.reset(false);
this.dispatch(option.dataset.obj);
return;
}
this.debouncedFetch(value);
});
this.input.addEventListener('search', () => {
if (this.input.value.length === 0) this.reset(false);
else if (!this.settings.nolimit) {
const option = selected();
this.input.setCustomValidity(option ? '' : this.settings.invalid);
this.input.reportValidity();
}
});
if (this.settings.listMode === 'ul') this.setupULListeners();
}
dispatch(dataObj = null, isInitial = false) {
if (!dataObj) return;
const detail = typeof dataObj === 'string' ?
JSON.parse(dataObj) :
dataObj;
if (isInitial) detail.isInitial = true;
this.dispatchEvent(new CustomEvent('autoSuggestSelect', { detail, bubbles: true }));
}
formReset() {
this.resetToDefault();
this.dispatchEvent(new CustomEvent('autoSuggestClear', { bubbles: true }));
}
async fetchData(value) {
if (!this.settings.cache || !this.data.length) {
this.dispatchEvent(new CustomEvent('autoSuggestFetchStart', { bubbles: true }));
try {
const response = await fetch(this.settings.api + encodeURIComponent(value));
const data = await response.json();
this.dispatchEvent(new CustomEvent('autoSuggestFetchEnd', { bubbles: true }));
this.data = this.settings.apiArrayPath ?
this.getNestedValue(data, this.settings.apiArrayPath) || [] :
Array.isArray(data) ? data : [];
if (!this.data.length) {
this.dispatchEvent(new CustomEvent('autoSuggestNoResults', { bubbles: true }));
}
this.list.innerHTML = this.render(this.data);
if (this.settings.listMode === 'ul' && this.data.length) {
this.list.togglePopover(true);
this.setAttribute('open', '');
}
} catch (error) {
this.dispatchEvent(new CustomEvent('autoSuggestFetchError', { detail: error, bubbles: true }));
}
}
}
getNestedValue(obj, key) {
return key ? key.split('.').reduce((acc, part) =>
acc && typeof acc === 'object' ? acc[part] : undefined, obj) : undefined;
}
render(data) {
return data.map(obj => {
const value = this.getNestedValue(obj, this.settings.apiValuePath);
const display = this.getNestedValue(obj, this.settings.apiDisplayPath);
const text = this.settings.apiTextPath ? this.getNestedValue(obj, this.settings.apiTextPath) : '';
const dataObj = this.escapeJsonForHtml(obj);
return this.settings.listMode === 'ul'
? `<li role="option" tabindex="0" data-display="${display}" data-text="${text}" data-value="${value}" data-obj='${dataObj}'>${display}</li>`
: `<option value="${display}" data-display="${display}" data-text="${text}" data-value="${value}" data-obj='${dataObj}'>${text || ''}</option>`;
}).join('');
}
reset(fullReset = true) {
if (fullReset) this.resetToDefault();
this.data = [];
this.list.innerHTML = this.settings.listMode === 'ul' ? '' : '<option value="">';
if (this.settings.listMode === 'ul') {
this.list.scrollTo(0, 0);
this.list.togglePopover(false);
this.removeAttribute('open');
}
this.input.setCustomValidity('');
this.dispatchEvent(new CustomEvent('autoSuggestClear', { bubbles: true }));
}
resetToDefault() {
const display = this.getNestedValue(this.initialObject, this.settings.apiDisplayPath) || this.defaultValues.input;
const value = this.getNestedValue(this.initialObject, this.settings.apiValuePath) || this.defaultValues.value;
if (this.isFormElement) {
this.displayValue = display;
this.input.value = display;
this.value = value;
} else {
this.input.value = display;
this.value = value;
}
this.input.setCustomValidity('');
if (this.settings.listMode === 'ul') {
this.list.togglePopover(false);
this.removeAttribute('open');
}
}
selectItem(target) {
const { obj, value } = target.dataset;
const displayText = target.textContent.trim();
if (this.isFormElement) {
super.value = value;
this.displayValue = displayText;
this.input.value = displayText;
} else {
this.input.value = displayText;
}
this.reset(false);
this.dispatch(obj);
setTimeout(() => this.input.focus(), 0);
}
setupULListeners() {
this.list.addEventListener('click', (e) => {
if (e.target?.tagName === 'LI') this.selectItem(e.target);
});
this.list.addEventListener('beforetoggle', (e) => {
if (e.newState === 'closed') this.removeAttribute('open');
});
this.list.addEventListener('keydown', (e) => {
if (e.target?.tagName === 'LI') {
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = e.target.nextElementSibling;
if (next) next.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = e.target.previousElementSibling;
if (prev) prev.focus();
else this.input.focus();
} else if (e.key === 'Enter') {
this.selectItem(e.target);
}
}
});
}
template() {
const list = this.settings.listMode === 'ul'
? `<ul popover id="${this.listId}" part="list" role="listbox" style="position-anchor:--${this.listId}"></ul>`
: `<datalist id="${this.listId}" part="list"></datalist>`;
return `
${this.settings.label ? `<label part="row"><span part="label">
${this.getAttribute('required') === 'true' ? `<abbr title="required">*</abbr>`:''}${this.settings.label}</span>` : ''}
<input
autocomplete="${this.getAttribute('autocomplete') || 'off'}"
enterkeyhint="search"
inputmode="search"
${this.settings.listMode === 'ul' ? '' : `list="${this.listId}"`}
minlength="${this.getAttribute('minlength') || 3}"
part="input"
placeholder="${this.getAttribute('placeholder') || ''}"
spellcheck="${this.getAttribute('spellcheck') || false}"
style="anchor-name:--${this.listId}"
type="${this.getAttribute('type') || 'search'}"
value="${this.defaultValues.input}">
${this.settings.label ? '</label>' : ''}
${list}`;
}
}
AutoSuggest.register();