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>
311 lines
9.8 KiB
JavaScript
311 lines
9.8 KiB
JavaScript
/**
|
|
* Aside panel switcher — opens/closes named sidebar panels.
|
|
*/
|
|
import { warnOnce, resolveHost } from '../core/app-dom.js';
|
|
import { isEditableTarget } from '../core/app-form-utils.js';
|
|
import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';
|
|
import { createUiStorage } from '../core/app-ui-storage.js';
|
|
import { bindAsidePanelChannel, requestSidebarAction } from '../core/app-ui-channels.js';
|
|
|
|
export function initAsidePanels(root = document, options = {}) {
|
|
const {
|
|
barSelector = '.aside-icon-bar',
|
|
panelSelector = '.app-sidebar-panel',
|
|
titleSelector = '.app-sidebar-title',
|
|
toolsSelector = '[data-aside-tools]',
|
|
revealSidebarOnActivate = true,
|
|
persistSidebarStateOnActivate = true,
|
|
storageNamespace = 'app-ui',
|
|
storageVersion = 'v1',
|
|
storageScope = 'aside-panels',
|
|
detailsStorageScope = 'aside-details',
|
|
} = options;
|
|
|
|
const host = resolveHost(root);
|
|
const bar = host.matches?.(barSelector)
|
|
? host
|
|
: host.querySelector(barSelector);
|
|
if (!(bar instanceof HTMLElement)) {
|
|
warnOnce('UI_EL_MISSING', `Missing aside bar: ${barSelector}`, { module: 'aside-panels' });
|
|
return { destroy: () => {} };
|
|
}
|
|
if (bar.dataset.asidePanelsBound === '1' && bar._asidePanelsApi) {
|
|
return bar._asidePanelsApi;
|
|
}
|
|
|
|
const tabs = Array.from(bar.querySelectorAll('[data-aside-target]'));
|
|
if (!tabs.length) {
|
|
warnOnce('UI_EL_MISSING', 'No aside tabs found', { module: 'aside-panels' });
|
|
return { destroy: () => {} };
|
|
}
|
|
const shortcutItems = Array.from(bar.querySelectorAll('[data-aside-target], [data-aside-shortcut]'));
|
|
const panels = Array.from(host.querySelectorAll(panelSelector));
|
|
if (!panels.length) {
|
|
warnOnce('UI_EL_MISSING', 'No aside panels found', { module: 'aside-panels' });
|
|
}
|
|
|
|
const titleEl = host.querySelector(titleSelector);
|
|
const toolsHost = host.querySelector(toolsSelector);
|
|
const defaultTitle = titleEl?.dataset.asideTitleDefault || titleEl?.textContent || '';
|
|
const storageKey = bar.dataset.asideStorage || '';
|
|
const storage = createUiStorage({
|
|
namespace: storageNamespace,
|
|
version: storageVersion,
|
|
scope: storageScope,
|
|
});
|
|
const scopedStorageKey = storage.buildKey('state', storageKey);
|
|
const isMac = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent);
|
|
const shortcutPrefix = isMac ? '⌘' : 'Ctrl+';
|
|
const panelDetailsRegistry = new WeakMap();
|
|
const panelDetailsInstances = new Set();
|
|
const rootEl = document.documentElement;
|
|
|
|
const getPanelKey = (panel) => panel?.dataset?.asidePanel || '';
|
|
const getTabKey = (tab) => tab?.dataset?.asideTarget || '';
|
|
const getPanelTools = (panel) => panel?.querySelector('[data-aside-panel-tools]') || null;
|
|
const getTabHref = (tab) => tab?.dataset?.asideHref || '';
|
|
const actionMenus = () => Array.from(bar.querySelectorAll('[data-aside-target], [data-aside-shortcut]'));
|
|
|
|
const findActiveDetailsKeys = (panel) => Array.from(panel.querySelectorAll('details[data-details-key]'))
|
|
.filter((details) => details.querySelector('a.active, a[aria-current="page"]'))
|
|
.map((details) => details.dataset.detailsKey || '')
|
|
.filter(Boolean);
|
|
|
|
const initPanelDetails = (panel) => {
|
|
if (!(panel instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const existing = panelDetailsRegistry.get(panel);
|
|
if (existing) {
|
|
existing.apply();
|
|
return;
|
|
}
|
|
const panelStorageKey = panel.dataset.asideDetailsStorage || '';
|
|
if (!panelStorageKey) {
|
|
return;
|
|
}
|
|
const openActive = panel.dataset.asideDetailsOpenActive === '1';
|
|
const detailsState = initPersistedDetailsGroup({
|
|
root: panel,
|
|
storageKey: panelStorageKey,
|
|
ensureOpenKeys: openActive ? () => findActiveDetailsKeys(panel) : [],
|
|
storageNamespace,
|
|
storageVersion,
|
|
storageScope: detailsStorageScope,
|
|
});
|
|
if (detailsState) {
|
|
panelDetailsRegistry.set(panel, detailsState);
|
|
panelDetailsInstances.add(detailsState);
|
|
}
|
|
};
|
|
|
|
const readStored = () => {
|
|
if (!scopedStorageKey) {
|
|
return null;
|
|
}
|
|
return storage.getItem(scopedStorageKey);
|
|
};
|
|
|
|
const writeStored = (key) => {
|
|
if (!scopedStorageKey) {
|
|
return;
|
|
}
|
|
storage.setItem(scopedStorageKey, key);
|
|
};
|
|
|
|
const syncTools = (panel) => {
|
|
if (!(toolsHost instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
toolsHost.innerHTML = '';
|
|
const tools = getPanelTools(panel);
|
|
if (!(tools instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
toolsHost.innerHTML = tools.innerHTML;
|
|
};
|
|
|
|
const isSidebarHidden = () => rootEl.classList.contains('sidebar-hidden');
|
|
const isSidebarCollapsed = () => rootEl.classList.contains('sidebar-collapsed');
|
|
|
|
const openSidebar = (persist) => {
|
|
if (!revealSidebarOnActivate) {
|
|
return;
|
|
}
|
|
if (isSidebarHidden()) {
|
|
requestSidebarAction('show', { persist });
|
|
}
|
|
if (isSidebarCollapsed()) {
|
|
requestSidebarAction('open', { persist });
|
|
}
|
|
};
|
|
|
|
const setActive = (key, { fromUser = false } = {}) => {
|
|
if (!key) {
|
|
return;
|
|
}
|
|
|
|
let activePanel = null;
|
|
panels.forEach((panel) => {
|
|
const isActive = getPanelKey(panel) === key;
|
|
if (isActive) {
|
|
activePanel = panel;
|
|
}
|
|
panel.hidden = !isActive;
|
|
panel.setAttribute('aria-hidden', isActive ? 'false' : 'true');
|
|
});
|
|
|
|
tabs.forEach((tab) => {
|
|
const isActive = getTabKey(tab) === key;
|
|
tab.classList.toggle('active', isActive);
|
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
|
tab.setAttribute('tabindex', isActive ? '0' : '-1');
|
|
});
|
|
|
|
if (titleEl instanceof HTMLElement) {
|
|
const nextTitle = activePanel?.dataset?.asideTitle || defaultTitle;
|
|
if (nextTitle) {
|
|
titleEl.textContent = nextTitle;
|
|
}
|
|
}
|
|
if (activePanel instanceof HTMLElement) {
|
|
syncTools(activePanel);
|
|
initPanelDetails(activePanel);
|
|
}
|
|
writeStored(key);
|
|
if (fromUser) {
|
|
openSidebar(persistSidebarStateOnActivate);
|
|
}
|
|
};
|
|
|
|
const getInitialKey = () => {
|
|
const stored = readStored();
|
|
if (stored && panels.some((panel) => getPanelKey(panel) === stored)) {
|
|
return stored;
|
|
}
|
|
const activeTab = tabs.find((tab) => tab.classList.contains('active'));
|
|
if (activeTab) {
|
|
return getTabKey(activeTab);
|
|
}
|
|
const visiblePanel = panels.find((panel) => !panel.hidden);
|
|
if (visiblePanel) {
|
|
return getPanelKey(visiblePanel);
|
|
}
|
|
return getTabKey(tabs[0]);
|
|
};
|
|
|
|
setActive(getInitialKey(), { fromUser: false });
|
|
document.documentElement.classList.remove('aside-pending');
|
|
|
|
const cleanupFns = [];
|
|
const originalTooltips = new Map();
|
|
shortcutItems.forEach((item, idx) => {
|
|
const base = item.dataset.tooltipBase || item.getAttribute('data-tooltip') || item.getAttribute('aria-label') || '';
|
|
if (!base) {
|
|
return;
|
|
}
|
|
const shortcutKey = idx === 9 ? '0' : String(idx + 1);
|
|
item.dataset.tooltipBase = base;
|
|
originalTooltips.set(item, item.getAttribute('data-tooltip') || '');
|
|
item.setAttribute('data-tooltip', `${base} (${shortcutPrefix}${shortcutKey})`);
|
|
});
|
|
|
|
tabs.forEach((tab) => {
|
|
const onClick = (event) => {
|
|
event.preventDefault();
|
|
const key = getTabKey(tab);
|
|
const isActive = tab.classList.contains('active');
|
|
const href = getTabHref(tab);
|
|
|
|
if (href) {
|
|
const base = document.baseURI || window.location.origin;
|
|
const targetUrl = new URL(href, base);
|
|
const currentUrl = new URL(window.location.href);
|
|
const isSame = targetUrl.pathname === currentUrl.pathname && targetUrl.search === currentUrl.search;
|
|
if (!isSame) {
|
|
setActive(key, { fromUser: true });
|
|
window.location.href = targetUrl.toString();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isSidebarCollapsed()) {
|
|
requestSidebarAction('open', { persist: persistSidebarStateOnActivate });
|
|
setActive(key, { fromUser: true });
|
|
return;
|
|
}
|
|
|
|
if (isActive) {
|
|
requestSidebarAction(isSidebarHidden() ? 'show' : 'hide', { persist: true });
|
|
return;
|
|
}
|
|
setActive(key, { fromUser: true });
|
|
};
|
|
tab.addEventListener('click', onClick);
|
|
cleanupFns.push(() => tab.removeEventListener('click', onClick));
|
|
});
|
|
|
|
const onKeyDown = (event) => {
|
|
if (!(event.metaKey || event.ctrlKey)) {
|
|
return;
|
|
}
|
|
if (event.altKey) {
|
|
return;
|
|
}
|
|
if (isEditableTarget(event.target)) {
|
|
return;
|
|
}
|
|
const key = event.key;
|
|
if (!/^[0-9]$/.test(key)) {
|
|
return;
|
|
}
|
|
const index = key === '0' ? 9 : (Number.parseInt(key, 10) - 1);
|
|
if (index < 0 || index >= actionMenus().length) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
actionMenus()[index].click();
|
|
};
|
|
document.addEventListener('keydown', onKeyDown);
|
|
cleanupFns.push(() => document.removeEventListener('keydown', onKeyDown));
|
|
|
|
cleanupFns.push(bindAsidePanelChannel((detail) => {
|
|
const action = String(detail?.action || '').trim();
|
|
const key = String(detail?.panel || '').trim();
|
|
if (action !== 'open' || key === '') {
|
|
return;
|
|
}
|
|
if (!tabs.some((tab) => getTabKey(tab) === key)) {
|
|
return;
|
|
}
|
|
setActive(key, { fromUser: detail?.fromUser !== false });
|
|
}));
|
|
|
|
const api = {
|
|
setActive,
|
|
open: (key) => setActive(key, { fromUser: true }),
|
|
destroy: () => {
|
|
cleanupFns.forEach((cleanup) => cleanup());
|
|
originalTooltips.forEach((tooltip, item) => {
|
|
if (tooltip) {
|
|
item.setAttribute('data-tooltip', tooltip);
|
|
} else {
|
|
item.removeAttribute('data-tooltip');
|
|
}
|
|
});
|
|
panelDetailsInstances.forEach((detailsState) => {
|
|
if (detailsState && typeof detailsState.destroy === 'function') {
|
|
detailsState.destroy();
|
|
}
|
|
});
|
|
panelDetailsInstances.clear();
|
|
delete bar.dataset.asidePanelsBound;
|
|
delete bar._asidePanelsApi;
|
|
},
|
|
};
|
|
|
|
bar.dataset.asidePanelsBound = '1';
|
|
bar._asidePanelsApi = api;
|
|
return api;
|
|
}
|