Files
breadcrumb-the-shire/web/js/core/app-details-open-state.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

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