major update

This commit is contained in:
2026-03-04 15:56:58 +01:00
parent 16759a2732
commit 8f4dd5840d
478 changed files with 24313 additions and 8201 deletions

View File

@@ -0,0 +1,86 @@
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
export function initActiveFilterChips(options = {}) {
const {
container,
removeButtonLabel = 'Remove filter',
clearAllLabel = 'Clear all filters',
onRemove = null,
onClearAll = null
} = options;
const root = typeof container === 'string' ? document.querySelector(container) : container;
if (!root) {
return null;
}
const bindEvents = () => {
const removeButtons = Array.from(root.querySelectorAll('[data-active-filter-remove]'));
removeButtons.forEach((button) => {
if (button.dataset.bound === '1') {return;}
button.dataset.bound = '1';
button.addEventListener('click', () => {
if (typeof onRemove !== 'function') {return;}
onRemove({
id: button.dataset.activeFilterId || '',
param: button.dataset.activeFilterParam || '',
value: button.dataset.activeFilterValue || '',
valueType: button.dataset.activeFilterValueType || 'scalar',
meta: button.dataset.activeFilterMeta || ''
});
});
});
const clearButton = root.querySelector('[data-active-filter-clear]');
if (clearButton && clearButton.dataset.bound !== '1') {
clearButton.dataset.bound = '1';
clearButton.addEventListener('click', () => {
if (typeof onClearAll === 'function') {
onClearAll();
}
});
}
};
const render = (chips = []) => {
if (!Array.isArray(chips) || chips.length === 0) {
root.innerHTML = '';
root.hidden = true;
return;
}
root.hidden = false;
const list = chips.map((chip) => {
const id = String(chip?.id ?? '');
const param = String(chip?.param ?? '');
const value = String(chip?.value ?? '');
const valueType = String(chip?.valueType ?? 'scalar');
const label = String(chip?.label ?? '');
const meta = String(chip?.meta ?? '');
return `<li>
<button type="button" class="app-filter-chip" data-active-filter-remove data-active-filter-id="${escapeHtml(id)}" data-active-filter-param="${escapeHtml(param)}" data-active-filter-value="${escapeHtml(value)}" data-active-filter-value-type="${escapeHtml(valueType)}" data-active-filter-meta="${escapeHtml(meta)}" aria-label="${escapeHtml(removeButtonLabel)}">
<span>${escapeHtml(label)}</span>
<i class="bi bi-x-lg" aria-hidden="true"></i>
</button>
</li>`;
}).join('');
const clearChip = `<li>
<button type="button" class="app-filter-chip app-filter-chip-clear" data-active-filter-clear aria-label="${escapeHtml(clearAllLabel)}">
<span>${escapeHtml(clearAllLabel)}</span>
<i class="bi bi-x-lg" aria-hidden="true"></i>
</button>
</li>`;
root.innerHTML = `<div class="app-active-filters-inner"><ul class="app-filter-chip-list">${list}${clearChip}</ul></div>`;
bindEvents();
};
return { render };
}

View File

@@ -0,0 +1,70 @@
const CONFIRM_ATTR = 'data-confirm-message';
const isSubmitControl = (element) => {
if (!(element instanceof HTMLElement)) {
return false;
}
if (element instanceof HTMLButtonElement) {
const type = String(element.getAttribute('type') || 'submit').trim().toLowerCase();
return type === '' || type === 'submit';
}
if (element instanceof HTMLInputElement) {
const type = String(element.type || '').trim().toLowerCase();
return type === 'submit' || type === 'image';
}
return false;
};
const submitterByForm = new Map();
const onClickCapture = (event) => {
const trigger = event.target instanceof HTMLElement ? event.target.closest('a, button, input') : null;
if (!(trigger instanceof HTMLElement)) {
return;
}
if (isSubmitControl(trigger)) {
const form = trigger.form instanceof HTMLFormElement ? trigger.form : null;
if (form) {
submitterByForm.set(form, trigger);
}
return;
}
const confirmMessage = String(trigger.getAttribute(CONFIRM_ATTR) || '').trim();
if (confirmMessage === '') {
return;
}
if (!window.confirm(confirmMessage)) {
event.preventDefault();
event.stopPropagation();
}
};
const onSubmitCapture = (event) => {
const form = event.target instanceof HTMLFormElement ? event.target : null;
if (!(form instanceof HTMLFormElement)) {
return;
}
const submitter = event.submitter instanceof HTMLElement
? event.submitter
: (submitterByForm.get(form) || null);
submitterByForm.delete(form);
const submitterMessage = String(submitter?.getAttribute(CONFIRM_ATTR) || '').trim();
const formMessage = String(form.getAttribute(CONFIRM_ATTR) || '').trim();
const confirmMessage = submitterMessage !== '' ? submitterMessage : formMessage;
if (confirmMessage === '') {
return;
}
if (!window.confirm(confirmMessage)) {
event.preventDefault();
event.stopPropagation();
}
};
document.addEventListener('click', onClickCapture, true);
document.addEventListener('submit', onSubmitCapture, true);

View File

@@ -1,23 +1,6 @@
const readStored = (storageKey) => {
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
};
import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';
const writeStored = (storageKey, openKeys) => {
try {
window.localStorage.setItem(storageKey, JSON.stringify(openKeys));
} catch (error) {
// ignore storage errors
}
};
const controllerRegistry = new WeakMap();
const initDetailsGroup = (group) => {
if (!group || group.dataset.detailsStorageBound === '1') {
@@ -29,42 +12,15 @@ const initDetailsGroup = (group) => {
return;
}
const managedDetails = Array.from(group.querySelectorAll('details[data-details-key]'));
const alwaysOpenDetails = Array.from(group.querySelectorAll('details[data-details-always-open]'));
if (!managedDetails.length && !alwaysOpenDetails.length) {
const controller = initPersistedDetailsGroup({
root: group,
storageKey,
});
if (!controller) {
return;
}
const storedOpenKeys = readStored(storageKey);
if (storedOpenKeys.length > 0) {
managedDetails.forEach((details) => {
const key = (details.dataset.detailsKey || '').trim();
if (key !== '') {
details.open = storedOpenKeys.includes(key);
}
});
}
alwaysOpenDetails.forEach((details) => {
details.open = true;
details.addEventListener('toggle', () => {
if (!details.open) {
details.open = true;
}
});
});
managedDetails.forEach((details) => {
details.addEventListener('toggle', () => {
const openKeys = managedDetails
.filter((item) => item.open)
.map((item) => (item.dataset.detailsKey || '').trim())
.filter(Boolean);
writeStored(storageKey, openKeys);
});
});
controllerRegistry.set(group, controller);
group.dataset.detailsStorageBound = '1';
};

View File

@@ -0,0 +1,284 @@
import { warnOnce } from '../core/app-dom.js';
export function initFilterDrawer(options = {}) {
const {
root = document,
drawerSelector = '[data-filter-drawer]',
openSelector = '[data-filter-drawer-open]',
closeSelector = '[data-filter-drawer-close]',
applySelector = '[data-filter-drawer-apply]',
resetSelector = '[data-filter-drawer-reset]',
onOpen = null,
onClose = null,
onApply = null,
onReset = null,
onDiscard = null
} = options;
const host = root && typeof root.querySelector === 'function' ? root : document;
const drawer = host.querySelector(drawerSelector);
if (!drawer) {
warnOnce('UI_EL_MISSING', `Missing filter drawer: ${drawerSelector}`, { module: 'filter-drawer' });
return null;
}
const openButtons = Array.from(host.querySelectorAll(openSelector));
const closeButtons = Array.from(drawer.querySelectorAll(closeSelector));
const applyButton = drawer.querySelector(applySelector);
const resetButton = drawer.querySelector(resetSelector);
const applyBaseLabel = applyButton ? String(applyButton.textContent || '').trim() : '';
const openButtonBaseLabels = new WeakMap();
const panel = drawer.querySelector('[data-filter-drawer-panel]') || drawer;
let isOpen = false;
let lockedScrollY = 0;
let lastTrigger = null;
if (!drawer.hasAttribute('role')) {
drawer.setAttribute('role', 'dialog');
}
if (!drawer.hasAttribute('aria-modal')) {
drawer.setAttribute('aria-modal', 'true');
}
drawer.setAttribute('aria-hidden', drawer.hidden ? 'true' : 'false');
if (!panel.hasAttribute('tabindex')) {
panel.setAttribute('tabindex', '-1');
}
const lockPageScroll = () => {
const body = document.body;
if (body.dataset.filterDrawerLock === '1') {return;}
lockedScrollY = window.scrollY || window.pageYOffset || 0;
body.dataset.filterDrawerLock = '1';
body.style.position = 'fixed';
body.style.top = `-${lockedScrollY}px`;
body.style.left = '0';
body.style.right = '0';
body.style.width = '100%';
};
const unlockPageScroll = () => {
const body = document.body;
if (body.dataset.filterDrawerLock !== '1') {return;}
body.style.position = '';
body.style.top = '';
body.style.left = '';
body.style.right = '';
body.style.width = '';
delete body.dataset.filterDrawerLock;
window.scrollTo(0, lockedScrollY);
};
const getFocusableElements = () => {
const selectors = [
'a[href]',
'area[href]',
'input:not([type="hidden"]):not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
];
return Array.from(panel.querySelectorAll(selectors.join(','))).filter((element) => {
if (!element) {return false;}
if (element.getAttribute('aria-hidden') === 'true') {return false;}
if (element instanceof HTMLElement && element.hidden) {return false;}
return true;
});
};
const focusFirstField = () => {
const focusable = getFocusableElements()[0] || panel;
if (focusable && typeof focusable.focus === 'function') {
focusable.focus();
}
};
const setOpen = (nextOpen) => {
isOpen = nextOpen;
drawer.hidden = !nextOpen;
drawer.setAttribute('aria-hidden', nextOpen ? 'false' : 'true');
drawer.classList.toggle('is-open', nextOpen);
document.body.classList.toggle('filter-drawer-open', nextOpen);
if (nextOpen) {
lockPageScroll();
} else {
unlockPageScroll();
}
openButtons.forEach((button) => {
button.setAttribute('aria-expanded', nextOpen ? 'true' : 'false');
});
if (!nextOpen) {
if (lastTrigger && typeof lastTrigger.focus === 'function' && document.contains(lastTrigger)) {
lastTrigger.focus();
}
lastTrigger = null;
if (typeof onClose === 'function') {
onClose();
}
return;
}
if (typeof onOpen === 'function') {
onOpen();
}
focusFirstField();
};
const open = (trigger = null) => {
if (isOpen) {return;}
lastTrigger = trigger && typeof trigger.focus === 'function'
? trigger
: (document.activeElement instanceof HTMLElement ? document.activeElement : null);
setOpen(true);
};
const close = (mode = 'discard') => {
if (!isOpen) {return;}
if (mode === 'discard' && typeof onDiscard === 'function') {
onDiscard();
}
setOpen(false);
};
openButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
open(button);
});
});
closeButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
close('discard');
});
});
if (applyButton) {
applyButton.addEventListener('click', async (event) => {
event.preventDefault();
if (applyButton.disabled) {
return;
}
let shouldClose = true;
if (typeof onApply === 'function') {
const result = await onApply();
shouldClose = result !== false;
}
if (shouldClose) {
close('apply');
}
});
}
if (resetButton) {
resetButton.addEventListener('click', async (event) => {
event.preventDefault();
if (typeof onReset === 'function') {
await onReset();
}
});
}
drawer.addEventListener('click', (event) => {
if (!isOpen) {return;}
if (!panel.contains(event.target)) {
close('discard');
}
});
document.addEventListener('keydown', (event) => {
if (!isOpen) {return;}
if (event.key === 'Escape') {
event.preventDefault();
close('discard');
return;
}
if (event.key !== 'Tab') {return;}
const focusable = getFocusableElements();
if (!focusable.length) {
event.preventDefault();
panel.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !panel.contains(active)) {
event.preventDefault();
last.focus();
}
return;
}
if (active === last) {
event.preventDefault();
first.focus();
}
});
const setApplyEnabled = (enabled) => {
if (!applyButton) {return;}
const nextEnabled = enabled === true;
applyButton.disabled = !nextEnabled;
applyButton.setAttribute('aria-disabled', nextEnabled ? 'false' : 'true');
};
const setApplyCount = (count) => {
if (!applyButton) {return;}
const parsed = Number.parseInt(String(count ?? '0'), 10);
const normalized = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
applyButton.textContent = normalized > 0
? `${applyBaseLabel} (${normalized})`
: applyBaseLabel;
};
const setTriggerCount = (count) => {
const parsed = Number.parseInt(String(count ?? '0'), 10);
const normalized = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
openButtons.forEach((button) => {
const baseLabel = openButtonBaseLabels.get(button)
|| String(button.getAttribute('aria-label') || button.textContent || '').trim();
if (!baseLabel) {return;}
if (!openButtonBaseLabels.has(button)) {
openButtonBaseLabels.set(button, baseLabel);
}
const nextLabel = normalized > 0 ? `${baseLabel} (${normalized})` : baseLabel;
const icon = button.querySelector('i');
if (icon) {
while (icon.nextSibling) {
icon.parentNode?.removeChild(icon.nextSibling);
}
icon.insertAdjacentText('afterend', ` ${nextLabel}`);
} else {
button.textContent = nextLabel;
}
button.setAttribute('aria-label', nextLabel);
button.setAttribute('data-tooltip', nextLabel);
});
};
setApplyEnabled(false);
setApplyCount(0);
setTriggerCount(0);
return {
open,
close,
isOpen: () => isOpen,
drawer,
setApplyEnabled,
setApplyCount,
setTriggerCount
};
}

View File

@@ -86,7 +86,24 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
syncInput.dispatchEvent(new Event('change', { bubbles: true }));
isSyncing = false;
};
instance.element.addEventListener('multi-select:change', syncValues);
let boundElement = null;
const bindSyncListener = () => {
if (!instance?.element || instance.element === boundElement) {
return;
}
instance.element.addEventListener('multi-select:change', syncValues);
boundElement = instance.element;
};
bindSyncListener();
if (typeof instance.refresh === 'function' && instance._syncRefreshWrapped !== true) {
const originalRefresh = instance.refresh.bind(instance);
instance.refresh = (...args) => {
const result = originalRefresh(...args);
bindSyncListener();
return result;
};
instance._syncRefreshWrapped = true;
}
const values = instance.selectedValues || [];
syncInput.value = Array.isArray(values) ? values.join(',') : '';
syncInput.addEventListener('change', () => {

View File

@@ -0,0 +1,11 @@
import { autoInitStandardDetailPages } from '../core/app-detail-page-factory.js';
export function initStandardDetailPages(root = document) {
return autoInitStandardDetailPages(root);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initStandardDetailPages());
} else {
initStandardDetailPages();
}

View File

@@ -1,4 +1,5 @@
import { requireEl, warnOnce } from '../core/app-dom.js';
import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';
export function initAsidePanels(options = {}) {
const {
@@ -43,51 +44,34 @@ export function initAsidePanels(options = {}) {
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
};
const panelDetailsRegistry = new WeakMap();
const findActiveDetailsKeys = (panel) => Array.from(panel.querySelectorAll('details[data-details-key]'))
.filter((details) => details.querySelector('a.active, a[aria-current="page"]'))
.map((details) => details.dataset.detailsKey || '')
.filter(Boolean);
const initPanelDetails = (panel) => {
if (!panel || panel.dataset.detailsBound === '1') {return;}
if (!panel) {return;}
const existing = panelDetailsRegistry.get(panel);
if (existing) {
existing.apply();
return;
}
const storageKey = panel.dataset.asideDetailsStorage || '';
if (!storageKey) {return;}
const detailsEls = Array.from(panel.querySelectorAll('details[data-details-key]'));
if (!detailsEls.length) {return;}
const readStored = () => {
try {
const raw = window.localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : [];
} catch (error) {
return [];
}
};
const writeStored = (value) => {
try {
const list = Array.isArray(value) ? value : [];
window.localStorage.setItem(storageKey, JSON.stringify(list));
} catch (error) {
// ignore storage errors
}
};
const applyStored = () => {
const stored = readStored();
if (!stored.length) {return;}
detailsEls.forEach((details) => {
details.open = stored.includes(details.dataset.detailsKey || '');
});
};
detailsEls.forEach((details) => {
details.addEventListener('toggle', () => {
const openKeys = detailsEls
.filter((item) => item.open)
.map((item) => item.dataset.detailsKey || '')
.filter(Boolean);
writeStored(openKeys);
});
const openActive = panel.dataset.asideDetailsOpenActive === '1';
const detailsState = initPersistedDetailsGroup({
root: panel,
storageKey,
ensureOpenKeys: openActive ? () => findActiveDetailsKeys(panel) : []
});
applyStored();
panel.dataset.detailsBound = '1';
if (!detailsState) {
return;
}
panelDetailsRegistry.set(panel, detailsState);
};
const readStored = () => {