Files
breadcrumb-the-shire/web/js/components/app-multiselect-cascade.js
fs c7b8fd516a 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>
2026-03-18 22:19:56 +01:00

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);
},
};
};