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>
242 lines
6.2 KiB
JavaScript
242 lines
6.2 KiB
JavaScript
/**
|
|
* Persists open/closed state of details elements to localStorage by key.
|
|
*/
|
|
import { createUiStorage } from './app-ui-storage.js';
|
|
|
|
const toElement = (value) => {
|
|
if (value instanceof HTMLElement) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'string') {
|
|
return document.querySelector(value);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const normalizeKeys = (value) => Array.from(new Set(
|
|
(Array.isArray(value) ? value : [])
|
|
.map((key) => String(key || '').trim())
|
|
.filter(Boolean)
|
|
));
|
|
|
|
const readStoredKeys = (storage, scopedStorageKey) => {
|
|
if (!scopedStorageKey) {
|
|
return [];
|
|
}
|
|
try {
|
|
const raw = storage.getItem(scopedStorageKey);
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
return normalizeKeys(parsed);
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const writeStoredKeys = (storage, scopedStorageKey, keys) => {
|
|
if (!scopedStorageKey) {
|
|
return;
|
|
}
|
|
try {
|
|
storage.setItem(scopedStorageKey, JSON.stringify(normalizeKeys(keys)));
|
|
} catch (error) {
|
|
// ignore storage errors
|
|
}
|
|
};
|
|
|
|
const resolveEnsureKeys = (value) => {
|
|
if (typeof value === 'function') {
|
|
return normalizeKeys(value());
|
|
}
|
|
return normalizeKeys(value);
|
|
};
|
|
|
|
export function initPersistedDetailsGroup(options = {}) {
|
|
const root = toElement(options.root || options.group || null);
|
|
if (!root) {
|
|
return null;
|
|
}
|
|
|
|
const storageKey = String(options.storageKey || '').trim();
|
|
if (storageKey === '') {
|
|
return null;
|
|
}
|
|
const storage = createUiStorage({
|
|
namespace: options.storageNamespace || 'app-ui',
|
|
version: options.storageVersion || 'v1',
|
|
scope: options.storageScope || 'details-open',
|
|
});
|
|
const scopedStorageKey = storage.buildKey('state', storageKey);
|
|
if (!scopedStorageKey) {
|
|
return null;
|
|
}
|
|
|
|
const detailsSelector = String(options.detailsSelector || 'details[data-details-key]').trim();
|
|
const alwaysOpenSelector = String(options.alwaysOpenSelector || 'details[data-details-always-open]').trim();
|
|
const ensureOpenKeysSource = options.ensureOpenKeys ?? [];
|
|
const managedDetails = Array.from(root.querySelectorAll(detailsSelector))
|
|
.filter((details) => details instanceof HTMLDetailsElement);
|
|
const alwaysOpenDetails = Array.from(root.querySelectorAll(alwaysOpenSelector))
|
|
.filter((details) => details instanceof HTMLDetailsElement);
|
|
|
|
if (!managedDetails.length && !alwaysOpenDetails.length) {
|
|
return null;
|
|
}
|
|
|
|
const detailsByKey = new Map();
|
|
managedDetails.forEach((details) => {
|
|
const key = String(details.dataset.detailsKey || '').trim();
|
|
if (key !== '' && !detailsByKey.has(key)) {
|
|
detailsByKey.set(key, details);
|
|
}
|
|
});
|
|
|
|
let isInternalSync = false;
|
|
|
|
const withInternalSync = (callback) => {
|
|
isInternalSync = true;
|
|
try {
|
|
callback();
|
|
} finally {
|
|
isInternalSync = false;
|
|
}
|
|
};
|
|
|
|
const applyAlwaysOpen = () => {
|
|
withInternalSync(() => {
|
|
alwaysOpenDetails.forEach((details) => {
|
|
details.open = true;
|
|
});
|
|
});
|
|
};
|
|
|
|
const collectOpenKeys = () => managedDetails
|
|
.filter((details) => details.open)
|
|
.map((details) => String(details.dataset.detailsKey || '').trim())
|
|
.filter(Boolean);
|
|
|
|
const mergeOpenKeys = (keys) => {
|
|
const merged = new Set(normalizeKeys(keys));
|
|
resolveEnsureKeys(ensureOpenKeysSource).forEach((key) => merged.add(key));
|
|
alwaysOpenDetails.forEach((details) => {
|
|
const key = String(details.dataset.detailsKey || '').trim();
|
|
if (key !== '') {
|
|
merged.add(key);
|
|
}
|
|
});
|
|
return Array.from(merged);
|
|
};
|
|
|
|
const writeOpenKeys = (keys) => {
|
|
writeStoredKeys(storage, scopedStorageKey, mergeOpenKeys(keys));
|
|
};
|
|
|
|
const persistCurrentState = () => {
|
|
writeOpenKeys(collectOpenKeys());
|
|
};
|
|
|
|
const ensureOpen = (keys) => {
|
|
const normalizedKeys = normalizeKeys(keys);
|
|
if (!normalizedKeys.length) {
|
|
return;
|
|
}
|
|
withInternalSync(() => {
|
|
normalizedKeys.forEach((key) => {
|
|
const details = detailsByKey.get(key);
|
|
if (details) {
|
|
details.open = true;
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const apply = () => {
|
|
const storedKeys = readStoredKeys(storage, scopedStorageKey);
|
|
const ensureKeys = resolveEnsureKeys(ensureOpenKeysSource);
|
|
|
|
if (storedKeys.length > 0) {
|
|
const nextOpenSet = new Set(mergeOpenKeys(storedKeys));
|
|
withInternalSync(() => {
|
|
managedDetails.forEach((details) => {
|
|
const key = String(details.dataset.detailsKey || '').trim();
|
|
if (key !== '') {
|
|
details.open = nextOpenSet.has(key);
|
|
}
|
|
});
|
|
});
|
|
} else if (ensureKeys.length > 0) {
|
|
ensureOpen(ensureKeys);
|
|
}
|
|
|
|
applyAlwaysOpen();
|
|
persistCurrentState();
|
|
};
|
|
|
|
const onManagedToggle = (event) => {
|
|
if (isInternalSync) {
|
|
return;
|
|
}
|
|
|
|
const target = event.currentTarget instanceof HTMLDetailsElement ? event.currentTarget : null;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
const key = String(target.dataset.detailsKey || '').trim();
|
|
if (key !== '' && resolveEnsureKeys(ensureOpenKeysSource).includes(key) && !target.open) {
|
|
withInternalSync(() => {
|
|
target.open = true;
|
|
});
|
|
}
|
|
|
|
applyAlwaysOpen();
|
|
persistCurrentState();
|
|
};
|
|
|
|
const onAlwaysOpenToggle = (event) => {
|
|
if (isInternalSync) {
|
|
return;
|
|
}
|
|
const target = event.currentTarget instanceof HTMLDetailsElement ? event.currentTarget : null;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
if (!target.open) {
|
|
withInternalSync(() => {
|
|
target.open = true;
|
|
});
|
|
}
|
|
persistCurrentState();
|
|
};
|
|
|
|
managedDetails.forEach((details) => {
|
|
details.addEventListener('toggle', onManagedToggle);
|
|
});
|
|
|
|
alwaysOpenDetails
|
|
.filter((details, index, list) => list.indexOf(details) === index)
|
|
.forEach((details) => {
|
|
details.addEventListener('toggle', onAlwaysOpenToggle);
|
|
});
|
|
|
|
apply();
|
|
|
|
return {
|
|
apply,
|
|
destroy: () => {
|
|
managedDetails.forEach((details) => {
|
|
details.removeEventListener('toggle', onManagedToggle);
|
|
});
|
|
alwaysOpenDetails
|
|
.filter((details, index, list) => list.indexOf(details) === index)
|
|
.forEach((details) => {
|
|
details.removeEventListener('toggle', onAlwaysOpenToggle);
|
|
});
|
|
},
|
|
readOpenKeys: () => readStoredKeys(storage, scopedStorageKey),
|
|
writeOpenKeys,
|
|
};
|
|
}
|