359 lines
9.7 KiB
JavaScript
359 lines
9.7 KiB
JavaScript
/**
|
|
* Generic autocomplete lookup field.
|
|
*/
|
|
import { getJson } from '../core/app-http.js';
|
|
import { resolveHost } from '../core/app-dom.js';
|
|
|
|
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
|
|
|
/**
|
|
* @param {HTMLElement} container
|
|
*/
|
|
export function initLookupField(container) {
|
|
if (!(container instanceof HTMLElement)) {
|
|
return EMPTY_API;
|
|
}
|
|
|
|
const url = container.dataset.lookupUrl || '';
|
|
const minChars = parseInt(container.dataset.lookupMinChars || '2', 10);
|
|
const debounceMs = parseInt(container.dataset.lookupDebounce || '300', 10);
|
|
const valueKey = container.dataset.lookupValueKey || 'value';
|
|
const displayKey = container.dataset.lookupDisplayKey || 'label';
|
|
const placeholder = container.dataset.lookupPlaceholder || '';
|
|
const initialDisplay = container.dataset.lookupInitialDisplay || '';
|
|
const initialValue = container.dataset.lookupInitialValue || '';
|
|
|
|
if (!url) {
|
|
return EMPTY_API;
|
|
}
|
|
|
|
const hiddenValue = container.querySelector('[data-lookup-value]');
|
|
const hiddenDisplay = container.querySelector('[data-lookup-display-value]');
|
|
|
|
const listenerController = new AbortController();
|
|
const on = (target, type, handler, options = {}) => {
|
|
if (!target || typeof target.addEventListener !== 'function') {
|
|
return;
|
|
}
|
|
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
|
};
|
|
|
|
const cleanupNodes = [];
|
|
const clearNode = (el) => {
|
|
while (el.firstChild) {
|
|
el.removeChild(el.firstChild);
|
|
}
|
|
};
|
|
|
|
container.classList.add('app-lookup-field');
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'app-lookup-field-input-wrap';
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'app-lookup-field-input';
|
|
input.placeholder = placeholder;
|
|
input.setAttribute('role', 'combobox');
|
|
input.setAttribute('aria-expanded', 'false');
|
|
input.setAttribute('aria-autocomplete', 'list');
|
|
input.setAttribute('autocomplete', 'off');
|
|
|
|
const indicator = document.createElement('div');
|
|
indicator.className = 'app-lookup-field-indicator';
|
|
|
|
wrap.appendChild(input);
|
|
wrap.appendChild(indicator);
|
|
|
|
const dropdown = document.createElement('ul');
|
|
dropdown.className = 'app-lookup-field-dropdown';
|
|
dropdown.setAttribute('role', 'listbox');
|
|
dropdown.hidden = true;
|
|
|
|
container.appendChild(wrap);
|
|
container.appendChild(dropdown);
|
|
cleanupNodes.push(wrap, dropdown);
|
|
|
|
let results = [];
|
|
let activeIndex = -1;
|
|
let debounceTimer = null;
|
|
let blurTimer = null;
|
|
let selected = false;
|
|
let activeRequestController = null;
|
|
|
|
if (initialValue && initialDisplay) {
|
|
input.value = initialDisplay;
|
|
selected = true;
|
|
input.dataset.lookupSelected = '';
|
|
showClear();
|
|
}
|
|
|
|
function showSpinner() {
|
|
clearNode(indicator);
|
|
const spinner = document.createElement('div');
|
|
spinner.className = 'app-lookup-field-spinner';
|
|
indicator.appendChild(spinner);
|
|
}
|
|
|
|
function showClear() {
|
|
clearNode(indicator);
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'app-lookup-field-clear';
|
|
button.setAttribute('aria-label', 'Clear');
|
|
const icon = document.createElement('i');
|
|
icon.className = 'bi bi-x-lg';
|
|
button.appendChild(icon);
|
|
on(button, 'click', clearSelection);
|
|
indicator.appendChild(button);
|
|
}
|
|
|
|
function hideIndicator() {
|
|
clearNode(indicator);
|
|
}
|
|
|
|
function openDropdown() {
|
|
dropdown.hidden = false;
|
|
input.setAttribute('aria-expanded', 'true');
|
|
}
|
|
|
|
function closeDropdown() {
|
|
dropdown.hidden = true;
|
|
input.setAttribute('aria-expanded', 'false');
|
|
activeIndex = -1;
|
|
}
|
|
|
|
function setActive(index) {
|
|
const options = dropdown.querySelectorAll('.app-lookup-field-option');
|
|
options.forEach((opt, i) => {
|
|
opt.setAttribute('aria-selected', i === index ? 'true' : 'false');
|
|
});
|
|
activeIndex = index;
|
|
|
|
const active = options[index];
|
|
if (active) {
|
|
active.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
function selectItem(index) {
|
|
const item = results[index];
|
|
if (!item) {
|
|
return;
|
|
}
|
|
|
|
input.value = item[displayKey] || '';
|
|
input.dataset.lookupSelected = '';
|
|
selected = true;
|
|
|
|
if (hiddenValue) hiddenValue.value = item[valueKey] || '';
|
|
if (hiddenDisplay) hiddenDisplay.value = item[displayKey] || '';
|
|
|
|
closeDropdown();
|
|
showClear();
|
|
|
|
container.dispatchEvent(new CustomEvent('lookup:select', {
|
|
detail: item,
|
|
bubbles: true,
|
|
}));
|
|
}
|
|
|
|
function clearSelection() {
|
|
input.value = '';
|
|
delete input.dataset.lookupSelected;
|
|
selected = false;
|
|
|
|
if (hiddenValue) hiddenValue.value = '';
|
|
if (hiddenDisplay) hiddenDisplay.value = '';
|
|
|
|
hideIndicator();
|
|
input.focus();
|
|
|
|
container.dispatchEvent(new CustomEvent('lookup:clear', { bubbles: true }));
|
|
}
|
|
|
|
function renderResults(items) {
|
|
results = items;
|
|
activeIndex = -1;
|
|
clearNode(dropdown);
|
|
|
|
if (items.length === 0) {
|
|
const msg = document.createElement('li');
|
|
msg.className = 'app-lookup-field-message';
|
|
msg.textContent = container.dataset.lookupEmptyText || 'No results';
|
|
dropdown.appendChild(msg);
|
|
openDropdown();
|
|
return;
|
|
}
|
|
|
|
items.forEach((item, index) => {
|
|
const li = document.createElement('li');
|
|
li.className = 'app-lookup-field-option';
|
|
li.setAttribute('role', 'option');
|
|
li.setAttribute('aria-selected', 'false');
|
|
li.dataset.index = String(index);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'app-lookup-field-option-label';
|
|
label.textContent = item[displayKey] || '';
|
|
li.appendChild(label);
|
|
|
|
if (item.meta) {
|
|
const meta = document.createElement('span');
|
|
meta.className = 'app-lookup-field-option-meta';
|
|
meta.textContent = item.meta;
|
|
li.appendChild(meta);
|
|
}
|
|
|
|
on(li, 'mousedown', (event) => {
|
|
event.preventDefault();
|
|
selectItem(index);
|
|
});
|
|
on(li, 'mouseenter', () => {
|
|
setActive(index);
|
|
});
|
|
|
|
dropdown.appendChild(li);
|
|
});
|
|
|
|
openDropdown();
|
|
}
|
|
|
|
function renderError() {
|
|
clearNode(dropdown);
|
|
const msg = document.createElement('li');
|
|
msg.className = 'app-lookup-field-message';
|
|
msg.textContent = container.dataset.lookupErrorText || 'Search failed';
|
|
dropdown.appendChild(msg);
|
|
openDropdown();
|
|
}
|
|
|
|
async function fetchResults(query) {
|
|
if (activeRequestController) {
|
|
activeRequestController.abort();
|
|
}
|
|
activeRequestController = new AbortController();
|
|
|
|
showSpinner();
|
|
|
|
try {
|
|
const separator = url.includes('?') ? '&' : '?';
|
|
const data = await getJson(`${url}${separator}q=${encodeURIComponent(query)}`, {
|
|
signal: activeRequestController.signal,
|
|
});
|
|
hideIndicator();
|
|
const items = Array.isArray(data) ? data : (data?.data || []);
|
|
renderResults(items);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
return;
|
|
}
|
|
hideIndicator();
|
|
renderError();
|
|
}
|
|
}
|
|
|
|
on(input, 'input', () => {
|
|
if (selected) {
|
|
selected = false;
|
|
delete input.dataset.lookupSelected;
|
|
if (hiddenValue) hiddenValue.value = '';
|
|
if (hiddenDisplay) hiddenDisplay.value = '';
|
|
}
|
|
|
|
const query = input.value.trim();
|
|
if (query.length < minChars) {
|
|
closeDropdown();
|
|
hideIndicator();
|
|
return;
|
|
}
|
|
|
|
window.clearTimeout(debounceTimer);
|
|
debounceTimer = window.setTimeout(() => {
|
|
void fetchResults(query);
|
|
}, debounceMs);
|
|
});
|
|
|
|
on(input, 'keydown', (event) => {
|
|
if (dropdown.hidden) return;
|
|
|
|
const options = dropdown.querySelectorAll('.app-lookup-field-option');
|
|
if (!options.length) return;
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0);
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1);
|
|
} else if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
if (activeIndex >= 0) {
|
|
selectItem(activeIndex);
|
|
}
|
|
} else if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
closeDropdown();
|
|
}
|
|
});
|
|
|
|
on(input, 'focus', () => {
|
|
if (input.value.trim().length >= minChars && !selected && results.length > 0) {
|
|
openDropdown();
|
|
}
|
|
});
|
|
|
|
on(input, 'blur', () => {
|
|
window.clearTimeout(blurTimer);
|
|
blurTimer = window.setTimeout(() => closeDropdown(), 150);
|
|
});
|
|
|
|
return {
|
|
input,
|
|
clearSelection,
|
|
destroy: () => {
|
|
listenerController.abort();
|
|
if (activeRequestController) {
|
|
activeRequestController.abort();
|
|
}
|
|
window.clearTimeout(debounceTimer);
|
|
window.clearTimeout(blurTimer);
|
|
cleanupNodes.forEach((node) => {
|
|
if (node.parentNode === container) {
|
|
container.removeChild(node);
|
|
}
|
|
});
|
|
container.classList.remove('app-lookup-field');
|
|
delete container.dataset.lookupInitialized;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function initLookupFields(root = document, options = {}) {
|
|
const host = resolveHost(root);
|
|
const selector = String(options.selector || '[data-app-lookup]').trim() || '[data-app-lookup]';
|
|
const elements = Array.from(host.querySelectorAll(selector));
|
|
if (elements.length === 0) {
|
|
return EMPTY_API;
|
|
}
|
|
|
|
const instances = [];
|
|
elements.forEach((el) => {
|
|
if (el.dataset.lookupInitialized) {
|
|
return;
|
|
}
|
|
el.dataset.lookupInitialized = '1';
|
|
instances.push(initLookupField(el));
|
|
});
|
|
|
|
return {
|
|
destroy: () => {
|
|
instances.forEach((instance) => {
|
|
if (instance && typeof instance.destroy === 'function') {
|
|
instance.destroy();
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|