feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support

Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.

New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering

New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
  module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
  FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:19:56 +01:00
parent c364e2b46d
commit c7b8fd516a
139 changed files with 7591 additions and 2535 deletions

View File

@@ -2,7 +2,7 @@
* Initializes tab navigation with chevron-based overflow scrolling.
*
* Markup (author writes):
* <div data-tabs>
* <div data-app-component="tabs" data-tabs>
* <div class="app-tabs-nav">
* <button data-tab="system" data-tab-default>System</button>
* ...
@@ -13,106 +13,164 @@
* JS wraps the nav in `.app-tabs-nav-wrap` and injects chevron buttons when
* the strip overflows. No-JS fallback: native horizontal scrollbar stays.
*/
export function initTabs(root = document) {
const containers = root.querySelectorAll('[data-tabs]');
import { createUiStorage } from '../core/app-ui-storage.js';
import { resolveHost } from '../core/app-dom.js';
import { belongsToForm } from '../core/app-form-utils.js';
const SCROLL_STEP = 160; // px per click
const SCROLL_THRESHOLD = 2; // tolerance for "at edge" detection
const resolveContainers = (host, selector) => {
const containers = [];
if (host instanceof HTMLElement && host.matches(selector)) {
containers.push(host);
}
containers.push(...Array.from(host.querySelectorAll(selector)));
return containers;
};
export function initTabs(root = document, config = {}) {
const host = resolveHost(root);
const selector = String(config.selector || '[data-app-component="tabs"]').trim() || '[data-app-component="tabs"]';
const storage = createUiStorage({
namespace: config.storageNamespace || 'app-ui',
version: config.storageVersion || 'v1',
scope: 'tabs',
});
const containers = resolveContainers(host, selector);
const instances = [];
containers.forEach((container) => {
if (container.dataset.tabsBound === '1') {return;}
if (!(container instanceof HTMLElement)) {
return;
}
if (container.dataset.tabsBound === '1' && container._tabsApi) {
instances.push(container._tabsApi);
return;
}
const cleanupFns = [];
const addCleanup = (cleanup) => {
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
};
container.dataset.tabsBound = '1';
container.dataset.tabsReady = '0';
const cleanupFns = [];
const tabs = Array.from(container.querySelectorAll('[data-tab]'));
const panels = Array.from(container.querySelectorAll('[data-tab-panel]'));
if (!tabs.length || !panels.length) {return;}
if (!tabs.length || !panels.length) {
const api = {
destroy: () => {
delete container.dataset.tabsBound;
delete container.dataset.tabsReady;
delete container._tabsApi;
},
};
container._tabsApi = api;
instances.push(api);
return;
}
const storageKey = container.dataset.tabsStorageKey || (container.id ? `tabs:${container.id}` : null);
const urlParamName = container.dataset.tabsParam || 'tab';
const storageKey = String(container.dataset.tabsStorageKey || (container.id ? `tabs:${container.id}` : '')).trim();
const scopedStorageKey = storage.buildKey('state', storageKey);
const urlParamName = String(container.dataset.tabsParam || config.urlParam || 'tab').trim() || 'tab';
// Helper to get URL parameter
const getUrlParam = (name) => {
const params = new URLSearchParams(window.location.search);
return params.get(name);
};
// Helper to set URL parameter
const setUrlParam = (name, value) => {
const url = new URL(window.location);
const url = new URL(window.location.href);
url.searchParams.set(name, value);
window.history.replaceState({}, '', url);
};
// Get initial active tab from URL param, localStorage, or data-tab-default
const readStoredTab = () => {
if (storageKey === '' || container.hasAttribute('data-tabs-ignore-storage')) {
return '';
}
const scopedValue = scopedStorageKey ? storage.getItem(scopedStorageKey) : null;
if (typeof scopedValue === 'string' && scopedValue.trim() !== '') {
return scopedValue.trim();
}
return '';
};
const persistTab = (tabName) => {
if (storageKey === '' || container.hasAttribute('data-tabs-ignore-storage')) {
return;
}
if (scopedStorageKey) {
storage.setItem(scopedStorageKey, tabName);
}
};
let activeTab = null;
const urlTab = getUrlParam(urlParamName);
if (urlTab && panels.some(p => p.dataset.tabPanel === urlTab)) {
if (urlTab && panels.some((panel) => panel.dataset.tabPanel === urlTab)) {
activeTab = urlTab;
} else if (storageKey && !container.hasAttribute('data-tabs-ignore-storage')) {
try {
const stored = localStorage.getItem(storageKey);
if (stored && panels.some(p => p.dataset.tabPanel === stored)) {
activeTab = stored;
}
} catch (e) {
// localStorage not available
} else {
const storedTab = readStoredTab();
if (storedTab && panels.some((panel) => panel.dataset.tabPanel === storedTab)) {
activeTab = storedTab;
}
}
if (!activeTab) {
const defaultTab = container.querySelector('[data-tab-default]');
activeTab = defaultTab?.dataset.tab || tabs[0]?.dataset.tab;
activeTab = defaultTab?.dataset.tab || tabs[0]?.dataset.tab || null;
}
const nav = container.querySelector('.app-tabs-nav');
const tabIdPrefix = container.id || `tabs-${Math.random().toString(36).slice(2, 8)}`;
const scrollTabIntoView = (tabEl) => {
if (!nav || !(tabEl instanceof HTMLElement)) {
return;
}
tabEl.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
};
const activateTab = (tabName) => {
tabs.forEach(tab => {
tabs.forEach((tab) => {
const isActive = tab.dataset.tab === tabName;
tab.classList.toggle('is-active', isActive);
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
tab.setAttribute('tabindex', isActive ? '0' : '-1');
});
panels.forEach(panel => {
panels.forEach((panel) => {
const isVisible = panel.dataset.tabPanel === tabName;
panel.hidden = !isVisible;
panel.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
});
// Update URL parameter
setUrlParam(urlParamName, tabName);
// Save to localStorage
if (storageKey) {
try {
localStorage.setItem(storageKey, tabName);
} catch (e) {
// localStorage not available
}
}
persistTab(tabName);
};
// ----------------------------------------------------------------
// ARIA: role, aria-controls, aria-labelledby (WAI-ARIA Tabs pattern)
// ----------------------------------------------------------------
const nav = container.querySelector('.app-tabs-nav');
const tabIdPrefix = container.id || `tabs-${Math.random().toString(36).slice(2, 8)}`;
tabs.forEach(tab => {
tabs.forEach((tab) => {
const tabName = tab.dataset.tab;
const tabId = `${tabIdPrefix}-tab-${tabName}`;
const panelId = `${tabIdPrefix}-panel-${tabName}`;
tab.setAttribute('role', 'tab');
tab.id = tabId;
tab.setAttribute('aria-controls', panelId);
tab.addEventListener('click', (e) => {
e.preventDefault();
const onClick = (event) => {
event.preventDefault();
activateTab(tab.dataset.tab);
// Scroll the active tab into view within the nav strip
scrollTabIntoView(tab);
});
};
tab.addEventListener('click', onClick);
addCleanup(() => tab.removeEventListener('click', onClick));
});
panels.forEach(panel => {
panels.forEach((panel) => {
const panelName = panel.dataset.tabPanel;
const panelId = `${tabIdPrefix}-panel-${panelName}`;
const tabId = `${tabIdPrefix}-tab-${panelName}`;
@@ -123,152 +181,135 @@ export function initTabs(root = document) {
if (nav) {
nav.setAttribute('role', 'tablist');
}
// ----------------------------------------------------------------
// Arrow-key navigation (WAI-ARIA Tabs pattern)
// ----------------------------------------------------------------
if (nav) {
nav.addEventListener('keydown', (e) => {
const currentIndex = tabs.indexOf(e.target);
if (currentIndex === -1) {return;}
const onNavKeyDown = (event) => {
const currentIndex = tabs.indexOf(event.target);
if (currentIndex === -1) {
return;
}
let nextIndex = -1;
if (e.key === 'ArrowRight') {
if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length;
} else if (e.key === 'ArrowLeft') {
} else if (event.key === 'ArrowLeft') {
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
} else if (event.key === 'Home') {
nextIndex = 0;
} else if (e.key === 'End') {
} else if (event.key === 'End') {
nextIndex = tabs.length - 1;
}
if (nextIndex >= 0) {
e.preventDefault();
event.preventDefault();
tabs[nextIndex].focus();
activateTab(tabs[nextIndex].dataset.tab);
scrollTabIntoView(tabs[nextIndex]);
}
});
};
nav.addEventListener('keydown', onNavKeyDown);
addCleanup(() => nav.removeEventListener('keydown', onNavKeyDown));
const destroyChevron = initChevronScroll(nav);
addCleanup(destroyChevron);
}
// Activate initial tab
if (activeTab) {
activateTab(activeTab);
}
container.dataset.tabsReady = '1';
// ----------------------------------------------------------------
// Chevron overflow scrolling
// ----------------------------------------------------------------
/** @param {HTMLElement} tabEl */
function scrollTabIntoView(tabEl) {
if (!nav) {return;}
tabEl.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
if (nav) {
initChevronScroll(nav);
// Once the first tab is activated, ensure it's visible
const activeBtn = nav.querySelector('.is-active');
if (activeBtn) {
// Defer so layout has settled
requestAnimationFrame(() => scrollTabIntoView(activeBtn));
}
}
// --- Form validation awareness ---
const form = container.closest('form');
if (form) {
if (form instanceof HTMLFormElement) {
const inputSelector = 'input,select,textarea';
const formId = form.id;
const belongsToForm = (field) => {
const f = field.getAttribute('form');
if (!f) return true; // kein form-Attr → gehört zum Ancestor-Form
if (!formId) return false; // Feld hat form-Attr, unser Form hat keine ID
return f === formId;
};
const clearInvalidMarkers = () => {
tabs.forEach(tab => tab.classList.remove('has-invalid'));
tabs.forEach((tab) => tab.classList.remove('has-invalid'));
};
const findInvalidPanels = () => {
clearInvalidMarkers();
let firstInvalidTab = null;
panels.forEach(panel => {
panels.forEach((panel) => {
const fields = panel.querySelectorAll(inputSelector);
const hasInvalid = Array.from(fields).some(f => belongsToForm(f) && !f.disabled && !f.checkValidity());
if (hasInvalid) {
const tabName = panel.dataset.tabPanel;
const tab = tabs.find(t => t.dataset.tab === tabName);
if (tab) {
tab.classList.add('has-invalid');
}
if (!firstInvalidTab) {
firstInvalidTab = tabName;
}
const hasInvalid = Array.from(fields).some(
(field) => belongsToForm(field, form) && !field.disabled && !field.checkValidity()
);
if (!hasInvalid) {
return;
}
const tabName = panel.dataset.tabPanel;
const tab = tabs.find((candidate) => candidate.dataset.tab === tabName);
if (tab) {
tab.classList.add('has-invalid');
}
if (!firstInvalidTab) {
firstInvalidTab = tabName;
}
});
return firstInvalidTab;
};
// Intercept submit buttons — both inside the form and external ones linked via form="id".
// We use document-level click (capture) so we catch ALL submit triggers before native validation.
const onFormClickCapture = (e) => {
const btn = e.target.closest('button, [type="submit"]');
if (!btn) {return;}
const onFormClickCapture = (event) => {
const trigger = event.target.closest('button, [type="submit"]');
if (!trigger) {
return;
}
// Check if this button submits OUR form
const btnFormAttr = btn.getAttribute('form');
const isInternalSubmit = !btnFormAttr && form.contains(btn) && (btn.type === 'submit' || (!btn.type && btn.tagName === 'BUTTON'));
const isExternalSubmit = btnFormAttr && formId && btnFormAttr === formId && btn.type === 'submit';
if (!isInternalSubmit && !isExternalSubmit) {return;}
const triggerFormAttr = trigger.getAttribute('form');
const isInternalSubmit = !triggerFormAttr
&& form.contains(trigger)
&& (trigger.type === 'submit' || (!trigger.type && trigger.tagName === 'BUTTON'));
const isExternalSubmit = Boolean(triggerFormAttr) && Boolean(formId) && triggerFormAttr === formId && trigger.type === 'submit';
if (!isInternalSubmit && !isExternalSubmit) {
return;
}
const firstInvalidTab = findInvalidPanels();
if (!firstInvalidTab) {return;}
if (!firstInvalidTab) {
return;
}
// Active panel already shows the first invalid tab — let native validation handle it
const activePanel = panels.find(p => !p.hidden);
const activePanel = panels.find((panel) => !panel.hidden);
const needsTabSwitch = activePanel?.dataset.tabPanel !== firstInvalidTab;
// Open any closed <details> ancestors of invalid fields so the browser can focus them
const invalidFields = form.querySelectorAll(inputSelector);
for (const field of invalidFields) {
if (!belongsToForm(field) || field.disabled || field.checkValidity()) {continue;}
let el = field.parentElement;
while (el && el !== form) {
if (el.tagName === 'DETAILS' && !el.open) {
el.open = true;
if (!belongsToForm(field) || field.disabled || field.checkValidity()) {
continue;
}
let element = field.parentElement;
while (element && element !== form) {
if (element.tagName === 'DETAILS' && !element.open) {
element.open = true;
}
el = el.parentElement;
element = element.parentElement;
}
}
if (!needsTabSwitch) {return;}
if (!needsTabSwitch) {
return;
}
// Prevent native submit so the browser doesn't try to focus a hidden field
e.preventDefault();
event.preventDefault();
activateTab(firstInvalidTab);
requestAnimationFrame(() => {
form.reportValidity();
});
};
const onFormInput = (e) => {
const panel = e.target.closest('[data-tab-panel]');
if (!panel) {return;}
const onFormInput = (event) => {
const panel = event.target.closest('[data-tab-panel]');
if (!panel) {
return;
}
const tabName = panel.dataset.tabPanel;
const tab = tabs.find(t => t.dataset.tab === tabName);
if (!tab) {return;}
const tab = tabs.find((candidate) => candidate.dataset.tab === tabName);
if (!tab) {
return;
}
const fields = panel.querySelectorAll(inputSelector);
if (Array.from(fields).every(f => !belongsToForm(f) || f.disabled || f.checkValidity())) {
if (Array.from(fields).every((field) => !belongsToForm(field) || field.disabled || field.checkValidity())) {
tab.classList.remove('has-invalid');
}
};
@@ -276,124 +317,148 @@ export function initTabs(root = document) {
document.addEventListener('click', onFormClickCapture, true);
form.addEventListener('input', onFormInput);
form.addEventListener('change', onFormInput);
cleanupFns.push(() => {
addCleanup(() => {
document.removeEventListener('click', onFormClickCapture, true);
form.removeEventListener('input', onFormInput);
form.removeEventListener('change', onFormInput);
});
}
// Listen for popstate (back/forward navigation)
const onPopState = () => {
const newTab = getUrlParam(urlParamName);
if (newTab && panels.some(p => p.dataset.tabPanel === newTab)) {
activateTab(newTab);
const nextTab = getUrlParam(urlParamName);
if (nextTab && panels.some((panel) => panel.dataset.tabPanel === nextTab)) {
activateTab(nextTab);
}
};
window.addEventListener('popstate', onPopState);
cleanupFns.push(() => window.removeEventListener('popstate', onPopState));
addCleanup(() => window.removeEventListener('popstate', onPopState));
const destroy = () => {
cleanupFns.forEach((fn) => fn());
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns.length = 0;
delete container.dataset.tabsBound;
delete container.dataset.tabsReady;
delete container._tabsApi;
};
container._tabsApi = { activateTab, destroy };
instances.push(container._tabsApi);
const api = { activateTab, destroy };
container._tabsApi = api;
instances.push(api);
});
return instances;
return {
instances,
destroy: () => {
instances.forEach((instance) => {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
},
};
}
// ------------------------------------------------------------------
// Chevron-scroll helper — wraps .app-tabs-nav in .app-tabs-nav-wrap
// and injects / buttons that appear only when the strip overflows.
// ------------------------------------------------------------------
const SCROLL_STEP = 160; // px per click
const SCROLL_THRESHOLD = 2; // tolerance for "at edge" detection
/** @param {HTMLElement} nav The .app-tabs-nav element */
function initChevronScroll(nav) {
// Wrap: nav → wrap > [chevronStart, nav, chevronEnd]
if (!(nav instanceof HTMLElement) || !nav.parentNode) {
return () => {};
}
const wrap = document.createElement('div');
wrap.className = 'app-tabs-nav-wrap';
nav.parentNode.insertBefore(wrap, nav);
wrap.appendChild(nav);
// Create chevron buttons
const btnStart = createChevronBtn('start', 'bi-chevron-left');
const btnEnd = createChevronBtn('end', 'bi-chevron-right');
wrap.appendChild(btnStart);
wrap.appendChild(btnEnd);
// Scroll handlers
btnStart.addEventListener('click', () => {
nav.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' });
});
btnEnd.addEventListener('click', () => {
nav.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' });
});
const cleanupFns = [];
const bind = (target, eventName, handler, options = undefined) => {
target.addEventListener(eventName, handler, options);
cleanupFns.push(() => target.removeEventListener(eventName, handler, options));
};
// Continuous scroll on long-press
addLongPress(btnStart, () => nav.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' }));
addLongPress(btnEnd, () => nav.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' }));
const scrollStart = () => nav.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' });
const scrollEnd = () => nav.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' });
bind(btnStart, 'click', scrollStart);
bind(btnEnd, 'click', scrollEnd);
const destroyLongPressStart = addLongPress(btnStart, scrollStart);
const destroyLongPressEnd = addLongPress(btnEnd, scrollEnd);
cleanupFns.push(destroyLongPressStart, destroyLongPressEnd);
// Observe overflow
const update = () => {
const hasOverflow = nav.scrollWidth > nav.clientWidth + SCROLL_THRESHOLD;
wrap.classList.toggle('has-overflow', hasOverflow);
if (hasOverflow) {
const atStart = nav.scrollLeft <= SCROLL_THRESHOLD;
const atEnd = nav.scrollLeft + nav.clientWidth >= nav.scrollWidth - SCROLL_THRESHOLD;
btnStart.disabled = atStart;
btnEnd.disabled = atEnd;
if (!hasOverflow) {
btnStart.disabled = true;
btnEnd.disabled = true;
return;
}
const atStart = nav.scrollLeft <= SCROLL_THRESHOLD;
const atEnd = nav.scrollLeft + nav.clientWidth >= nav.scrollWidth - SCROLL_THRESHOLD;
btnStart.disabled = atStart;
btnEnd.disabled = atEnd;
};
nav.addEventListener('scroll', update, { passive: true });
// ResizeObserver to detect container width changes
bind(nav, 'scroll', update, { passive: true });
let resizeObserver = null;
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(update);
ro.observe(nav);
resizeObserver = new ResizeObserver(update);
resizeObserver.observe(nav);
}
// Initial check (deferred so layout is settled)
requestAnimationFrame(update);
return () => {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns.length = 0;
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (wrap.parentNode) {
wrap.parentNode.insertBefore(nav, wrap);
wrap.remove();
}
};
}
/** @returns {HTMLButtonElement} */
function createChevronBtn(direction, iconClass) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `app-tabs-chevron app-tabs-chevron--${direction}`;
btn.setAttribute('aria-hidden', 'true');
btn.setAttribute('tabindex', '-1');
const button = document.createElement('button');
button.type = 'button';
button.className = `app-tabs-chevron app-tabs-chevron--${direction}`;
button.setAttribute('aria-hidden', 'true');
button.setAttribute('tabindex', '-1');
const icon = document.createElement('i');
icon.className = `bi ${iconClass}`;
icon.setAttribute('aria-hidden', 'true');
btn.appendChild(icon);
return btn;
button.appendChild(icon);
return button;
}
/** Repeats callback while button is held down */
function addLongPress(btn, callback) {
function addLongPress(button, callback) {
let interval = null;
const start = () => { interval = setInterval(callback, 180); };
const stop = () => { clearInterval(interval); interval = null; };
btn.addEventListener('pointerdown', start);
btn.addEventListener('pointerup', stop);
btn.addEventListener('pointerleave', stop);
btn.addEventListener('pointercancel', stop);
}
const start = () => {
interval = setInterval(callback, 180);
};
const stop = () => {
if (interval) {
clearInterval(interval);
interval = null;
}
};
// Auto-initialize on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initTabs());
} else {
initTabs();
button.addEventListener('pointerdown', start);
button.addEventListener('pointerup', stop);
button.addEventListener('pointerleave', stop);
button.addEventListener('pointercancel', stop);
return () => {
stop();
button.removeEventListener('pointerdown', start);
button.removeEventListener('pointerup', stop);
button.removeEventListener('pointerleave', stop);
button.removeEventListener('pointercancel', stop);
};
}