/** * 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; }