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

@@ -1,13 +1,16 @@
/**
* Initializes MultiSelect.js instances from select[multiple] elements.
*/
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
let multiSelectLoader = null;
const resolveScriptUrl = (script) => {
if (script) {return script;}
const assetBase = window.APP_ASSET_BASE || document.baseURI;
if (script) {
return script;
}
const root = document.documentElement;
const assetBase = String(root?.dataset?.assetBase || '').trim() || document.baseURI;
return new URL('vendor/multi-select/MultiSelect.js', assetBase).toString();
};
@@ -28,19 +31,59 @@ const ensureMultiSelect = async (script) => {
return multiSelectLoader;
};
export const initMultiSelect = async (target = '[data-multi-select]') => {
const elements = typeof target === 'string' ? document.querySelectorAll(target) : [target];
if (!elements.length) {return [];}
const resolveElements = (target, host) => {
if (typeof target === 'string') {
return Array.from(host.querySelectorAll(target));
}
if (target instanceof Element) {
return [target];
}
if (target && typeof target.length === 'number') {
return Array.from(target);
}
return [];
};
const destroyElementInstance = (element, instance = null) => {
if (!(element instanceof HTMLElement)) {
return;
}
const cleanup = element._multiSelectCleanup;
if (typeof cleanup === 'function') {
cleanup();
}
delete element._multiSelectCleanup;
delete element.dataset.multiSelectInit;
const resolvedInstance = instance || element._multiSelectInstance || null;
if (resolvedInstance && typeof resolvedInstance.destroy === 'function') {
resolvedInstance.destroy();
}
delete element._multiSelectInstance;
};
export const initMultiSelect = async (target = '[data-multi-select]', root = document) => {
const host = resolveHost(root);
const elements = resolveElements(target, host);
if (!elements.length) {
return [];
}
const MultiSelect = await ensureMultiSelect();
const instances = [];
elements.forEach((element) => {
if (!element || element.dataset.multiSelectInit === '1') {return;}
if (!(element instanceof HTMLElement) || element.dataset.multiSelectInit === '1') {
return;
}
const cleanupFns = [];
const syncTarget = element.dataset.syncTarget;
const syncInput = syncTarget ? document.querySelector(syncTarget) : null;
const options = {};
const placeholder = element.dataset.placeholder;
if (placeholder) {options.placeholder = placeholder;}
if (placeholder) {
options.placeholder = placeholder;
}
if (element.dataset.search !== undefined) {
options.search = element.dataset.search === 'true';
}
@@ -65,6 +108,7 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
if (element.dataset.selectAllLabel) {
options.selectAllLabel = element.dataset.selectAllLabel;
}
let isSyncing = false;
let instance;
try {
@@ -73,16 +117,20 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
warnOnce('UI_INIT_FAIL', 'multi-select init failed', { module: 'multi-select', element, error });
return;
}
if (element.dataset.disabled === 'true' || element.disabled) {
instance.disable();
}
element.dataset.multiSelectInit = '1';
element._multiSelectInstance = instance;
instances.push(instance);
if (syncInput) {
if (syncInput instanceof HTMLInputElement) {
syncInput._multiSelectInstance = instance;
const syncValues = () => {
if (!syncInput || isSyncing) {return;}
if (isSyncing) {
return;
}
isSyncing = true;
const values = instance.selectedValues || [];
syncInput.value = Array.isArray(values) ? values.join(',') : '';
@@ -95,9 +143,11 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
return;
}
instance.element.addEventListener('multi-select:change', syncValues);
cleanupFns.push(() => instance.element?.removeEventListener('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) => {
@@ -107,40 +157,86 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
};
instance._syncRefreshWrapped = true;
}
const values = instance.selectedValues || [];
syncInput.value = Array.isArray(values) ? values.join(',') : '';
syncInput.addEventListener('change', () => {
if (isSyncing) {return;}
const onSyncInputChange = () => {
if (isSyncing) {
return;
}
const nextValues = syncInput.value
? syncInput.value.split(',').map((item) => item.trim()).filter(Boolean)
: [];
instance.setValues(nextValues);
});
};
syncInput.addEventListener('change', onSyncInputChange);
cleanupFns.push(() => syncInput.removeEventListener('change', onSyncInputChange));
}
element._multiSelectCleanup = () => {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns.length = 0;
if (syncInput instanceof HTMLInputElement && syncInput._multiSelectInstance === instance) {
delete syncInput._multiSelectInstance;
}
};
});
return instances;
};
export const getMultiSelectInstance = (target) => {
const element = typeof target === 'string' ? document.querySelector(target) : target;
if (!element) {return null;}
if (element._multiSelectInstance) {return element._multiSelectInstance;}
export const getMultiSelectInstance = (target, root = document) => {
const host = resolveHost(root);
const element = typeof target === 'string' ? host.querySelector(target) : target;
if (!element) {
return null;
}
if (element._multiSelectInstance) {
return element._multiSelectInstance;
}
const syncTarget = element.dataset?.syncTarget;
if (syncTarget) {
const syncElement = document.querySelector(syncTarget);
if (syncElement && syncElement._multiSelectInstance) {return syncElement._multiSelectInstance;}
if (syncElement && syncElement._multiSelectInstance) {
return syncElement._multiSelectInstance;
}
}
return null;
};
const autoInit = () => {
initMultiSelect().catch((error) => {
warnOnce('UI_INIT_FAIL', 'multi-select auto init failed', { module: 'multi-select', error });
});
};
export function initMultiSelectComponent(root = document, options = {}) {
const selector = String(options.selector || '[data-multi-select]').trim() || '[data-multi-select]';
const host = resolveHost(root);
let destroyed = false;
let instances = [];
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit);
} else {
autoInit();
initMultiSelect(selector, host)
.then((initialized) => {
if (destroyed) {
initialized.forEach((instance) => {
const element = instance?.element;
if (element instanceof HTMLElement) {
destroyElementInstance(element, instance);
}
});
return;
}
instances = initialized;
})
.catch((error) => {
warnOnce('UI_INIT_FAIL', 'multi-select runtime init failed', { module: 'multi-select', error });
});
return {
destroy: () => {
destroyed = true;
instances.forEach((instance) => {
const element = instance?.element;
if (element instanceof HTMLElement) {
destroyElementInstance(element, instance);
}
});
instances = [];
},
};
}