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>
100 lines
3.2 KiB
JavaScript
100 lines
3.2 KiB
JavaScript
/**
|
|
* Cascading multi-select — filters child options based on parent selection.
|
|
*/
|
|
import { getMultiSelectInstance } from './app-multiselect-init.js';
|
|
import { warnOnce, resolveHost } from '../core/app-dom.js';
|
|
|
|
const parseValueList = (value) =>
|
|
String(value || '')
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
|
|
const setDisabledState = (option, disabled) => {
|
|
option.dataset.disabled = disabled ? '1' : '0';
|
|
option.classList.toggle('is-disabled', disabled);
|
|
option.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
|
option.style.pointerEvents = disabled ? 'none' : '';
|
|
};
|
|
|
|
const toggleSelectAll = (instance, disabled) => {
|
|
const selectAll = instance?.element?.querySelector('.multi-select-all');
|
|
if (!selectAll) {return;}
|
|
selectAll.classList.toggle('is-disabled', disabled);
|
|
selectAll.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
|
selectAll.style.pointerEvents = disabled ? 'none' : '';
|
|
};
|
|
|
|
export const initMultiSelectCascade = ({
|
|
parent,
|
|
child,
|
|
map = {},
|
|
mode = 'disable',
|
|
clearInvalid = true,
|
|
root = document,
|
|
} = {}) => {
|
|
const host = resolveHost(root);
|
|
const parentInput = typeof parent === 'string' ? host.querySelector(parent) : parent;
|
|
const childInput = typeof child === 'string' ? host.querySelector(child) : child;
|
|
if (!parentInput || !childInput) {
|
|
if (!parentInput) {
|
|
warnOnce('UI_EL_MISSING', `Missing multiselect parent: ${parent}`, { module: 'multiselect-cascade' });
|
|
}
|
|
if (!childInput) {
|
|
warnOnce('UI_EL_MISSING', `Missing multiselect child: ${child}`, { module: 'multiselect-cascade' });
|
|
}
|
|
return { destroy: () => {} };
|
|
}
|
|
|
|
const childInstance = getMultiSelectInstance(childInput, host);
|
|
if (!childInstance) {
|
|
warnOnce('UI_INIT_MISSING', 'Missing multiselect instance for child', { module: 'multiselect-cascade' });
|
|
return { destroy: () => {} };
|
|
}
|
|
|
|
const apply = () => {
|
|
const selectedParents = parseValueList(parentInput.value);
|
|
const allowAll = selectedParents.length === 0;
|
|
const allowed = new Set();
|
|
|
|
if (!allowAll) {
|
|
selectedParents.forEach((id) => {
|
|
const entries = map[id] || [];
|
|
entries.forEach((entry) => allowed.add(String(entry)));
|
|
});
|
|
}
|
|
|
|
if (clearInvalid && !allowAll) {
|
|
const selected = childInstance.selectedValues || [];
|
|
selected.forEach((value) => {
|
|
if (!allowed.has(String(value))) {
|
|
childInstance.unselect(value);
|
|
}
|
|
});
|
|
}
|
|
|
|
const options = childInstance.element.querySelectorAll('.multi-select-option');
|
|
let disabledCount = 0;
|
|
options.forEach((option) => {
|
|
const isAllowed = allowAll || allowed.has(String(option.dataset.value || ''));
|
|
const disabled = mode === 'disable' ? !isAllowed : false;
|
|
if (disabled) {disabledCount += 1;}
|
|
setDisabledState(option, disabled);
|
|
});
|
|
|
|
toggleSelectAll(childInstance, disabledCount > 0);
|
|
};
|
|
|
|
parentInput.addEventListener('change', apply);
|
|
apply();
|
|
|
|
return {
|
|
destroy: () => {
|
|
parentInput.removeEventListener('change', apply);
|
|
const options = childInstance.element.querySelectorAll('.multi-select-option');
|
|
options.forEach((option) => setDisabledState(option, false));
|
|
toggleSelectAll(childInstance, false);
|
|
},
|
|
};
|
|
};
|