feat(js): add app-http contracts and migrate helpdesk runtime layer

This commit is contained in:
2026-04-20 22:31:13 +02:00
parent ec2f03e0cf
commit f189ef9df6
34 changed files with 2023 additions and 1134 deletions

View File

@@ -1,24 +1,19 @@
/**
* Generic autocomplete lookup field.
*
* Usage:
* <div data-app-lookup
* data-lookup-url="/helpdesk/debitor-lookup-data"
* data-lookup-min-chars="2"
* data-lookup-debounce="300"
* data-lookup-value-key="value"
* data-lookup-display-key="label"
* data-lookup-placeholder="Search...">
* <input type="hidden" name="debitor_no" data-lookup-value>
* <input type="hidden" name="debitor_name" data-lookup-display-value>
* </div>
*
* The component creates a visible text input, a dropdown, and indicator icons.
* On selection, it sets the hidden input values and fires a 'lookup:select' CustomEvent.
*/
import { getJson } from '../core/app-http.js';
import { resolveHost } from '../core/app-dom.js';
/** @param {HTMLElement} container */
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);
@@ -28,10 +23,28 @@ export function initLookupField(container) {
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]');
// Build DOM
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');
@@ -59,15 +72,15 @@ export function initLookupField(container) {
container.appendChild(wrap);
container.appendChild(dropdown);
cleanupNodes.push(wrap, dropdown);
// State
let results = [];
let activeIndex = -1;
let debounceTimer = null;
let abortController = null;
let blurTimer = null;
let selected = false;
let activeRequestController = null;
// Restore initial state
if (initialValue && initialDisplay) {
input.value = initialDisplay;
selected = true;
@@ -75,34 +88,30 @@ export function initLookupField(container) {
showClear();
}
// ── Indicator helpers ──────────────────────────────────────────────
function showSpinner() {
clearElement(indicator);
clearNode(indicator);
const spinner = document.createElement('div');
spinner.className = 'app-lookup-field-spinner';
indicator.appendChild(spinner);
}
function showClear() {
clearElement(indicator);
clearNode(indicator);
const button = document.createElement('button');
button.type = 'button';
button.className = 'app-lookup-field-clear';
button.setAttribute('aria-label', 'Clear');
button.addEventListener('click', clearSelection);
const icon = document.createElement('i');
icon.className = 'bi bi-x-lg';
button.appendChild(icon);
on(button, 'click', clearSelection);
indicator.appendChild(button);
}
function hideIndicator() {
clearElement(indicator);
clearNode(indicator);
}
// ── Dropdown helpers ───────────────────────────────────────────────
function openDropdown() {
dropdown.hidden = false;
input.setAttribute('aria-expanded', 'true');
@@ -114,67 +123,6 @@ export function initLookupField(container) {
activeIndex = -1;
}
function clearElement(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function renderResults(items) {
results = items;
activeIndex = -1;
clearElement(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 = 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);
}
li.addEventListener('mousedown', (e) => {
e.preventDefault();
selectItem(index);
});
li.addEventListener('mouseenter', () => {
setActive(index);
});
dropdown.appendChild(li);
});
openDropdown();
}
function renderError() {
clearElement(dropdown);
const msg = document.createElement('li');
msg.className = 'app-lookup-field-message';
msg.textContent = container.dataset.lookupErrorText || 'Search failed';
dropdown.appendChild(msg);
openDropdown();
}
function setActive(index) {
const options = dropdown.querySelectorAll('.app-lookup-field-option');
options.forEach((opt, i) => {
@@ -182,18 +130,17 @@ export function initLookupField(container) {
});
activeIndex = index;
// Scroll active into view
const active = options[index];
if (active) {
active.scrollIntoView({ block: 'nearest' });
}
}
// ── Selection ──────────────────────────────────────────────────────
function selectItem(index) {
const item = results[index];
if (!item) return;
if (!item) {
return;
}
input.value = item[displayKey] || '';
input.dataset.lookupSelected = '';
@@ -225,38 +172,88 @@ export function initLookupField(container) {
container.dispatchEvent(new CustomEvent('lookup:clear', { bubbles: true }));
}
// ── Fetch ──────────────────────────────────────────────────────────
function renderResults(items) {
results = items;
activeIndex = -1;
clearNode(dropdown);
function fetchResults(query) {
if (abortController) abortController.abort();
abortController = new AbortController();
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();
const separator = url.includes('?') ? '&' : '?';
fetch(url + separator + 'q=' + encodeURIComponent(query), {
signal: abortController.signal,
headers: { 'Accept': 'application/json' },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((data) => {
hideIndicator();
const items = Array.isArray(data) ? data : (data.data || []);
renderResults(items);
})
.catch((err) => {
if (err.name === 'AbortError') return;
hideIndicator();
renderError();
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();
}
}
// ── Input events ───────────────────────────────────────────────────
input.addEventListener('input', () => {
on(input, 'input', () => {
if (selected) {
selected = false;
delete input.dataset.lookupSelected;
@@ -271,59 +268,91 @@ export function initLookupField(container) {
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fetchResults(query), debounceMs);
window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
void fetchResults(query);
}, debounceMs);
});
input.addEventListener('keydown', (e) => {
on(input, 'keydown', (event) => {
if (dropdown.hidden) return;
const options = dropdown.querySelectorAll('.app-lookup-field-option');
if (!options.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
} else if (event.key === 'Enter') {
event.preventDefault();
if (activeIndex >= 0) {
selectItem(activeIndex);
}
} else if (e.key === 'Escape') {
e.preventDefault();
} else if (event.key === 'Escape') {
event.preventDefault();
closeDropdown();
}
});
input.addEventListener('focus', () => {
on(input, 'focus', () => {
if (input.value.trim().length >= minChars && !selected && results.length > 0) {
openDropdown();
}
});
input.addEventListener('blur', () => {
// Delay to allow mousedown on option to fire first
setTimeout(() => closeDropdown(), 150);
on(input, 'blur', () => {
window.clearTimeout(blurTimer);
blurTimer = window.setTimeout(() => closeDropdown(), 150);
});
return { input, clearSelection };
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;
},
};
}
// ── Auto-init ──────────────────────────────────────────────────────
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;
}
function initAll() {
document.querySelectorAll('[data-app-lookup]').forEach((el) => {
if (el.dataset.lookupInitialized) return;
const instances = [];
elements.forEach((el) => {
if (el.dataset.lookupInitialized) {
return;
}
el.dataset.lookupInitialized = '1';
initLookupField(el);
instances.push(initLookupField(el));
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
return {
destroy: () => {
instances.forEach((instance) => {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
},
};
}