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>
This commit is contained in:
2026-03-18 22:19:56 +01:00
parent c364e2b46d
commit c7b8fd516a
139 changed files with 7591 additions and 2535 deletions

View File

@@ -32,16 +32,9 @@
}
dialog[data-app-confirm-dialog] footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 10px;
}
dialog[data-app-confirm-dialog] footer button {
margin: 0;
}
dialog[data-app-confirm-dialog][data-confirm-variant="danger"] > article {
border-top: 3px solid var(--app-del-color, #b42318);
}
@@ -49,11 +42,4 @@
dialog[data-app-confirm-dialog][data-confirm-variant="warning"] > article {
border-top: 3px solid var(--app-warning-color, #b54708);
}
@media (max-width: 576px) {
dialog[data-app-confirm-dialog] footer {
flex-direction: column-reverse;
align-items: stretch;
}
}
}

View File

@@ -0,0 +1,75 @@
/**
* app-dialog.css
* Base dialog styles shared by all dialog variants (confirm, bookmark, session-warning).
* Handles footer layout, button sizing, header close button, and mobile fallback.
* Variant-specific styles live in their own component files (app-confirm-dialog.css, etc.).
*/
@layer components {
/* ── Footer layout ───────────────────────────────────────────────────── */
dialog > article > footer,
dialog > article form > footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* ── Footer buttons: auto-width, no margin ───────────────────────────── */
dialog > article > footer [role="button"],
dialog > article > footer button,
dialog > article form > footer [role="button"],
dialog > article form > footer button {
width: auto;
margin: 0;
}
/* ── Header: title flush, close button positioned ────────────────────── */
dialog > article > header > * {
margin-bottom: 0;
}
dialog > article > header .close,
dialog > article > header :is(a, button)[rel="prev"] {
margin: 0;
margin-left: var(--app-spacing);
padding: 0;
float: right;
}
/* ── Close icon ──────────────────────────────────────────────────────── */
dialog > article .close,
dialog > article :is(a, button)[rel="prev"] {
display: block;
width: 1rem;
height: 1rem;
margin-top: calc(var(--app-spacing) * -1);
margin-bottom: var(--app-spacing);
margin-left: auto;
border: none;
background-image: var(--app-icon-close);
background-position: center;
background-size: auto 1rem;
background-repeat: no-repeat;
background-color: transparent;
opacity: 0.5;
transition: opacity var(--app-transition);
}
dialog > article .close:is([aria-current]:not([aria-current="false"]), :hover, :active, :focus),
dialog > article :is(a, button)[rel="prev"]:is([aria-current]:not([aria-current="false"]), :hover, :active, :focus) {
opacity: 1;
}
/* ── Mobile: stack buttons vertically ────────────────────────────────── */
@media (max-width: 576px) {
dialog > article > footer,
dialog > article form > footer {
flex-direction: column-reverse;
align-items: stretch;
}
dialog > article > footer button,
dialog > article form > footer button {
width: 100%;
}
}
}

View File

@@ -1,34 +1,36 @@
.page-layout {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
@layer layout {
.page-layout {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--app-border);
}
.page-header .app-brand img {
height: 2.5rem;
width: auto;
}
.page-header .app-brand img {
height: 2.5rem;
width: auto;
}
.page-header nav ul {
display: flex;
gap: 1.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.page-header nav ul {
display: flex;
gap: 1.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.page-content {
flex: 1;
max-width: 60rem;
margin: 0 auto;
padding: 2rem 1.5rem;
width: 100%;
.page-content {
flex: 1;
max-width: 60rem;
margin: 0 auto;
padding: 2rem 1.5rem;
width: 100%;
}
}

View File

@@ -2769,10 +2769,6 @@
overflow: auto;
}
dialog > article > footer button {
margin-bottom: 0;
}
.bi::before,
[class^="bi-"]::before,
[class*=" bi-"]::before {
@@ -2791,68 +2787,7 @@
}
}
dialog > article > header > * {
margin-bottom: 0;
}
dialog > article > header .close,
dialog > article > header :is(a, button)[rel="prev"] {
margin: 0;
margin-left: var(--app-spacing);
padding: 0;
float: right;
}
dialog > article > footer {
text-align: right;
}
dialog > article > footer [role="button"],
dialog > article > footer button {
margin-bottom: 0;
}
dialog > article > footer [role="button"]:not(:first-of-type),
dialog > article > footer button:not(:first-of-type) {
margin-left: calc(var(--app-spacing) * 0.5);
}
dialog > article .close,
dialog > article :is(a, button)[rel="prev"] {
display: block;
width: 1rem;
height: 1rem;
margin-top: calc(var(--app-spacing) * -1);
margin-bottom: var(--app-spacing);
margin-left: auto;
border: none;
background-image: var(--app-icon-close);
background-position: center;
background-size: auto 1rem;
background-repeat: no-repeat;
background-color: transparent;
opacity: 0.5;
transition: opacity var(--app-transition);
}
dialog
> article
.close:is(
[aria-current]:not([aria-current="false"]),
:hover,
:active,
:focus
),
dialog
> article
:is(a, button)[rel="prev"]:is(
[aria-current]:not([aria-current="false"]),
:hover,
:active,
:focus
) {
opacity: 1;
}
/* Dialog header/footer/close styles → web/css/components/app-dialog.css */
dialog:not([open]),
dialog[open="false"] {

View File

@@ -287,263 +287,4 @@
margin-bottom: var(--app-spacing);
}
.app-sidebar .app-sidebar-bookmark-item > a > i {
flex-shrink: 0;
width: 16px;
text-align: center;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item {
display: flex;
align-items: center;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-shell {
position: relative;
display: flex;
flex-direction: column;
gap: 0.2rem;
width: 100%;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: var(--app-spacing);
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 0;
margin: 0;
color: var(--app-muted-color);
font-size: 0.72rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > span {
flex: 1;
min-width: 0;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > i {
flex-shrink: 0;
width: 16px;
text-align: center;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu {
margin: 0;
margin-right: 5px;
position: relative;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
summary:after {
display: none;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary {
list-style: none;
border: 0;
background: transparent;
color: var(--app-muted-color);
padding: 0.25rem;
min-width: 1.5rem;
min-height: 1.5rem;
line-height: 1;
margin: 0;
border-radius: 3px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 120ms ease,
background 120ms ease,
opacity 120ms ease;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary {
opacity: 0;
pointer-events: none;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-item:hover
.app-sidebar-bookmark-action-menu
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-item:focus-within
.app-sidebar-bookmark-action-menu
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-group-header:hover
.app-sidebar-bookmark-action-menu
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-group-header:focus-within
.app-sidebar-bookmark-action-menu
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu[open]
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary:focus-visible {
opacity: 1;
pointer-events: auto;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary::-webkit-details-marker {
display: none;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary
i {
font-size: 0.95rem;
pointer-events: none;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu[open]
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary:hover,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary:focus-visible {
color: var(--app-contrast);
background: color-mix(in srgb, var(--app-contrast) 10%, transparent);
}
@media (hover: none), (pointer: coarse) {
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary {
opacity: 1;
pointer-events: auto;
}
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list {
position: absolute;
inset-inline-end: 0;
inset-block-start: calc(100% + 4px);
min-width: 10rem;
max-width: calc(220px - 2 * var(--app-spacing));
border: 1px solid var(--app-border);
border-radius: var(--app-border-radius);
background: var(--app-card-bg, var(--app-background-color));
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.14),
0 1px 3px rgba(0, 0, 0, 0.08);
padding: 0.25rem;
z-index: 10;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
> li {
margin: 0;
padding: 0;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
button {
width: 100%;
border: 0;
background: transparent;
color: inherit;
text-align: start;
padding: 0.375rem 0.5rem;
margin: 0;
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
font-size: 0.8125rem;
line-height: 1.4;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
button:hover,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
button:focus-visible {
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
button[disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
li:has(> .app-sidebar-bookmark-action-danger) {
margin-top: 0.2rem;
padding-top: 0.2rem;
border-top: 1px solid var(--app-border);
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
.app-sidebar-bookmark-action-danger {
color: var(--app-del-color, #c62828);
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
.app-sidebar-bookmark-action-danger:hover,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
.app-sidebar-bookmark-action-danger:focus-visible {
background: rgba(198, 40, 40, 0.12);
}
}

View File

@@ -13,6 +13,8 @@ use MintyPHP\Http\RequestContext;
use MintyPHP\Http\Request;
use MintyPHP\I18n;
use MintyPHP\Router;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Session;
@@ -24,12 +26,27 @@ chdir(__DIR__ . '/..');
require 'vendor/autoload.php';
// Load the config parameters
require 'config/config.php';
// Load the routes
require 'config/router.php';
// Register shortcut functions
require 'lib/Support/helpers.php';
$container = require 'lib/App/registerContainer.php';
setAppContainer($container);
// Resolve runtime page root before routes are initialized.
$moduleRegistry = app(ModuleRegistry::class);
if (count($moduleRegistry->getModules()) > 0) {
$runtimePageRoot = __DIR__ . '/../storage/runtime/pages';
if (!is_dir($runtimePageRoot) && !is_link($runtimePageRoot)) {
$builder = new ModuleRuntimePageBuilder();
$builder->build($runtimePageRoot, __DIR__ . '/../pages', $moduleRegistry->getModules());
}
if (is_dir($runtimePageRoot) || is_link($runtimePageRoot)) {
Router::$pageRoot = 'storage/runtime/pages/';
}
}
// Load routes after container/module registry are available.
require 'config/router.php';
RequestContext::start();
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
@@ -200,8 +217,8 @@ if ($requestedLocale === '' && !$isAssetRequest && !$isApiRequest) {
// Guard: restrict non-public pages to logged-in users
// Merge core public paths with module-contributed public paths.
$modulePublicPaths = [];
if (app(\MintyPHP\App\Module\ModuleRegistry::class) instanceof \MintyPHP\App\Module\ModuleRegistry) {
$modulePublicPaths = app(\MintyPHP\App\Module\ModuleRegistry::class)->getPublicPaths();
if (app(ModuleRegistry::class) instanceof ModuleRegistry) {
$modulePublicPaths = app(ModuleRegistry::class)->getPublicPaths();
}
$allPublicPaths = array_values(array_unique(array_merge(
defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : [],

View File

@@ -12,19 +12,36 @@
root.classList.add('js');
root.classList.add('aside-pending');
var assetBase = root.dataset.assetBase || '';
if (assetBase) {
window.APP_ASSET_BASE = assetBase;
}
var normalizeToken = function (value) {
return String(value == null ? '' : value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '');
};
var buildStorageKey = function (scope, key) {
var namespace = normalizeToken('app-ui') || 'app-ui';
var version = normalizeToken('v1') || 'v1';
var normalizedScope = normalizeToken(scope || '') || 'global';
var normalizedKey = normalizeToken(key || '');
if (!normalizedKey) {
return '';
}
return namespace + ':' + version + ':' + normalizedScope + ':state:' + normalizedKey;
};
try {
if (window.localStorage.getItem('app-sidebar-collapsed') === '1') {
var sidebarCollapsedKey = buildStorageKey('sidebar', 'sidebar-collapsed');
var sidebarHiddenKey = buildStorageKey('sidebar', 'sidebar-hidden');
var contrastKey = buildStorageKey('contrast', 'contrast-mode');
if (sidebarCollapsedKey && window.localStorage.getItem(sidebarCollapsedKey) === '1') {
root.classList.add('sidebar-collapsed');
}
if (window.localStorage.getItem('app-sidebar-hidden') === '1') {
if (sidebarHiddenKey && window.localStorage.getItem(sidebarHiddenKey) === '1') {
root.classList.add('sidebar-hidden');
}
var contrastValue = window.localStorage.getItem('app-contrast');
var contrastValue = contrastKey ? window.localStorage.getItem(contrastKey) : null;
if (contrastValue === 'high') {
root.dataset.contrast = 'high';
} else {
@@ -33,16 +50,4 @@
} catch (e) {
// ignore storage errors
}
if (typeof window.requestFsLightboxRefresh !== 'function') {
window.requestFsLightboxRefresh = function () {
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
window.__fsLightboxRefreshPending = false;
return true;
}
window.__fsLightboxRefreshPending = true;
return false;
};
}
})();

View File

@@ -1,25 +1,272 @@
import './core/app-telemetry.js';
import './components/app-flash-auto-dismiss.js';
import './components/app-tabs.js';
import './components/app-fslightbox-refresh.js';
import './components/app-password-hints.js';
import './components/app-copy-badge.js';
import './components/app-multiselect-init.js';
import './components/app-nav-history.js';
import './components/app-toggle-sidebar.js';
import './components/app-toggle-aside-panels.js';
import './components/app-toggle-details-aside.js';
import './components/app-filter-drawer.js';
import './components/app-active-filter-chips.js';
import './components/app-global-search.js';
import './components/app-theme-toggle.js';
import './components/app-contrast-toggle.js';
import './components/app-confirm-actions.js';
import './components/app-details-state.js';
import './components/app-tenant-switcher.js';
import './components/app-color-default-toggle.js';
import './components/app-custom-field-options-toggle.js';
import './components/app-tenant-sso-toggle.js';
import './components/app-settings-telemetry.js';
import './components/app-standard-detail-page.js';
import './components/app-auto-submit.js';
import { createComponentRuntime } from './core/app-component-runtime.js';
import { warnOnce } from './core/app-dom.js';
import { initFlashAutoDismiss } from './components/app-flash-auto-dismiss.js';
import { initPasswordHints } from './components/app-password-hints.js';
import { initBadgeCopy } from './components/app-copy-badge.js';
import { initMultiSelectComponent } from './components/app-multiselect-init.js';
import { initNavHistory } from './components/app-nav-history.js';
import { initSidebarToggle } from './components/app-toggle-sidebar.js';
import { initAsidePanels } from './components/app-toggle-aside-panels.js';
import { initDetailsAsideToggle } from './components/app-toggle-details-aside.js';
import { initGlobalSearch } from './components/app-global-search.js';
import { initThemeControls } from './components/app-theme-toggle.js';
import { initContrastToggle } from './components/app-contrast-toggle.js';
import { initConfirmActions } from './components/app-confirm-actions.js';
import { initDetailsState } from './components/app-details-state.js';
import { initTenantSwitcher } from './components/app-tenant-switcher.js';
import { initColorDefaultToggle } from './components/app-color-default-toggle.js';
import { initCustomFieldOptionsToggle } from './components/app-custom-field-options-toggle.js';
import { initTenantSsoToggle } from './components/app-tenant-sso-toggle.js';
import { initTelemetrySettings } from './components/app-settings-telemetry.js';
import { initStandardDetailPages } from './components/app-standard-detail-page.js';
import { initAutoSubmit } from './components/app-auto-submit.js';
import { initTabs } from './components/app-tabs.js';
import { initSessionWarning } from './components/app-session-warning.js';
import { initFsLightboxRefresh } from './components/app-fslightbox-refresh.js';
const runtime = createComponentRuntime({
root: document,
configId: 'app-components',
});
const phaseRank = {
early: 10,
default: 50,
late: 90,
};
const normalizePhase = (raw) => {
const phase = String(raw || '').trim().toLowerCase();
if (phase === 'early' || phase === 'default' || phase === 'late') {
return phase;
}
return 'late';
};
const normalizeModuleRuntimeComponents = (value) => {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((item) => item && typeof item === 'object')
.map((item) => {
const key = String(item.key || '').trim();
const moduleId = String(item.moduleId || '').trim();
const componentName = String(item.componentName || '').trim() || (moduleId && key ? `${moduleId}.${key}` : '');
const script = String(item.script || '').trim();
const exportName = String(item.export || '').trim();
const selector = String(item.selector || '').trim();
const scope = String(item.scope || '').trim().toLowerCase();
const configPath = String(item.configPath || '').trim();
const phase = normalizePhase(item.phase);
const order = Number.isFinite(Number(item.order)) ? Number(item.order) : 100;
return {
key,
moduleId,
componentName,
script,
exportName,
selector,
scope,
configPath,
phase,
order,
};
})
.filter((item) => item.componentName !== '' && item.script !== '')
.sort((a, b) => {
const phaseCmp = (phaseRank[a.phase] || phaseRank.late) - (phaseRank[b.phase] || phaseRank.late);
if (phaseCmp !== 0) {
return phaseCmp;
}
if (a.order !== b.order) {
return a.order - b.order;
}
return a.componentName.localeCompare(b.componentName);
});
};
const registerRuntimeComponent = async (definition) => {
const moduleContext = {
module: 'app-init',
runtimeComponent: definition.componentName,
};
const scriptUrl = (() => {
try {
return new URL(definition.script, document.baseURI).href;
} catch {
return '';
}
})();
if (!scriptUrl) {
warnOnce('UI_COMPONENT_INVALID', `Invalid runtime.component script URL for "${definition.componentName}"`, moduleContext);
return;
}
try {
const loaded = await import(scriptUrl);
const exportName = definition.exportName || 'default';
const init = loaded?.[exportName];
if (typeof init !== 'function') {
warnOnce(
'UI_COMPONENT_INVALID',
`runtime.component "${definition.componentName}" export "${exportName}" is not a function`,
moduleContext
);
return;
}
const componentOptions = {};
if (definition.scope !== '') {
componentOptions.scope = definition.scope;
}
if (definition.selector !== '') {
componentOptions.selector = definition.selector;
}
if (definition.configPath !== '') {
componentOptions.configPath = definition.configPath;
}
runtime.register(definition.componentName, init, componentOptions);
} catch (error) {
warnOnce('UI_COMPONENT_IMPORT_FAILED', `Failed to import runtime.component "${definition.componentName}"`, {
...moduleContext,
error,
});
}
};
const mountModuleComponentsByPhase = async (phase) => {
const pageConfig = runtime.getPageConfig();
const runtimeComponents = normalizeModuleRuntimeComponents(pageConfig?.moduleRuntimeComponents);
const phaseComponents = runtimeComponents.filter((item) => item.phase === phase);
for (const definition of phaseComponents) {
// eslint-disable-next-line no-await-in-loop
await registerRuntimeComponent(definition);
}
};
const bootRuntime = async () => {
// Phase A: low risk and global utilities.
runtime.register('confirm-actions', initConfirmActions, {
scope: 'global',
configPath: 'components.confirmActions',
});
runtime.register('flash-auto-dismiss', initFlashAutoDismiss, {
selector: '[data-app-component="flash-auto-dismiss"]',
configPath: 'components.flashAutoDismiss',
defaultConfig: {
selector: '.notice[data-flash-timeout]',
},
});
runtime.register('password-hints', initPasswordHints, {
scope: 'global',
configPath: 'components.passwordHints',
});
runtime.register('copy-badge', initBadgeCopy, {
scope: 'global',
configPath: 'components.copyBadge',
});
runtime.register('multiselect', initMultiSelectComponent, {
scope: 'global',
configPath: 'components.multiSelect',
});
runtime.register('nav-history', initNavHistory, {
selector: '[data-app-component="nav-history"]',
configPath: 'components.navHistory',
});
runtime.register('details-state', initDetailsState, {
scope: 'global',
configPath: 'components.detailsState',
});
runtime.register('settings-telemetry', initTelemetrySettings, {
scope: 'global',
configPath: 'components.settingsTelemetry',
});
runtime.register('auto-submit', initAutoSubmit, {
scope: 'global',
configPath: 'components.autoSubmit',
});
await mountModuleComponentsByPhase('early');
// Phase B: structure-defining components.
runtime.register('sidebar-toggle', initSidebarToggle, {
scope: 'global',
configPath: 'components.sidebarToggle',
});
runtime.register('aside-panels', initAsidePanels, {
scope: 'global',
configPath: 'components.asidePanels',
});
runtime.register('details-aside-toggle', initDetailsAsideToggle, {
scope: 'global',
configPath: 'components.detailsAsideToggle',
});
runtime.register('theme-controls', initThemeControls, {
selector: '[data-app-component="theme-controls"]',
configPath: 'components.themeControls',
});
runtime.register('contrast-toggle', initContrastToggle, {
selector: '[data-app-component="contrast-toggle"]',
configPath: 'components.contrastToggle',
});
runtime.register('tenant-switcher', initTenantSwitcher, {
selector: '[data-app-component="tenant-switcher"]',
configPath: 'components.tenantSwitcher',
});
runtime.register('color-default-toggle', initColorDefaultToggle, {
scope: 'global',
configPath: 'components.colorDefaultToggle',
});
runtime.register('custom-field-options-toggle', initCustomFieldOptionsToggle, {
scope: 'global',
configPath: 'components.customFieldOptionsToggle',
});
runtime.register('tenant-sso-toggle', initTenantSsoToggle, {
scope: 'global',
configPath: 'components.tenantSsoToggle',
});
runtime.register('standard-detail-page', initStandardDetailPages, {
scope: 'global',
configPath: 'components.standardDetailPage',
});
await mountModuleComponentsByPhase('default');
// Phase C: high-risk (search, side panel interactions).
runtime.register('global-search', initGlobalSearch, {
selector: '[data-app-component="global-search"]',
configPath: 'components.globalSearch',
});
runtime.register('tabs', initTabs, {
selector: '[data-app-component="tabs"]',
configPath: 'components.tabs',
defaultConfig: {
selector: '[data-app-component="tabs"]',
storageNamespace: 'app-ui',
storageVersion: 'v1',
},
});
runtime.register('session-warning', initSessionWarning, {
selector: '[data-app-component="session-warning"]',
configPath: 'components.sessionWarning',
defaultConfig: {
selector: '[data-app-session-warning-dialog]',
enabled: true,
},
});
runtime.register('fslightbox-refresh', initFsLightboxRefresh, {
scope: 'global',
configPath: 'components.fslightboxRefresh',
});
await mountModuleComponentsByPhase('late');
runtime.mountAll();
};
void bootRuntime();

View File

@@ -1,25 +1,39 @@
import './core/app-telemetry.js';
import './components/app-flash-auto-dismiss.js';
import './components/app-password-hints.js';
import './components/app-password-toggle.js';
import { createComponentRuntime } from './core/app-component-runtime.js';
import { resolveHost } from './core/app-dom.js';
import { initFlashAutoDismiss } from './components/app-flash-auto-dismiss.js';
import { initPasswordHints } from './components/app-password-hints.js';
import { initPasswordToggles } from './components/app-password-toggle.js';
const focusPrimaryErrorNotice = () => {
const errorNotice = document.querySelector(
'.notice[data-variant="error"][role="alert"][tabindex="-1"]',
);
if (!(errorNotice instanceof HTMLElement)) {
return;
const initLoginErrorFocus = (root = document, config = {}) => {
const host = resolveHost(root);
const selector = String(config.selector || '.notice[data-variant="error"][role="alert"][tabindex="-1"]').trim()
|| '.notice[data-variant="error"][role="alert"][tabindex="-1"]';
const errorNotice = host.querySelector(selector);
if (errorNotice instanceof HTMLElement) {
errorNotice.focus();
}
errorNotice.focus();
return { destroy: () => {} };
};
const lockLoginSubmitButtons = () => {
const forms = document.querySelectorAll('form[data-login-submit-lock="1"]');
const initLoginSubmitLock = (root = document, config = {}) => {
const host = resolveHost(root);
const selector = String(config.selector || 'form[data-login-submit-lock="1"]').trim() || 'form[data-login-submit-lock="1"]';
const forms = Array.from(host.querySelectorAll(selector)).filter(
(form) => form instanceof HTMLFormElement
);
if (!forms.length) {
return { destroy: () => {} };
}
const cleanupFns = [];
forms.forEach((form) => {
if (!(form instanceof HTMLFormElement)) {
if (form.dataset.loginSubmitLockBound === '1') {
return;
}
form.addEventListener('submit', () => {
form.dataset.loginSubmitLockBound = '1';
const onSubmit = () => {
if (form.dataset.submitted === '1') {
return;
}
@@ -32,9 +46,48 @@ const lockLoginSubmitButtons = () => {
submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true');
submitButton.dataset.submitState = 'loading';
};
form.addEventListener('submit', onSubmit);
cleanupFns.push(() => {
form.removeEventListener('submit', onSubmit);
delete form.dataset.loginSubmitLockBound;
});
});
return {
destroy: () => {
cleanupFns.forEach((cleanup) => cleanup());
},
};
};
focusPrimaryErrorNotice();
lockLoginSubmitButtons();
const runtime = createComponentRuntime({
root: document,
configId: 'login-components',
});
runtime.register('flash-auto-dismiss', initFlashAutoDismiss, {
selector: '[data-app-component="flash-auto-dismiss"]',
configPath: 'components.flashAutoDismiss',
defaultConfig: {
selector: '.notice[data-flash-timeout]',
},
});
runtime.register('password-hints', initPasswordHints, {
scope: 'global',
configPath: 'components.passwordHints',
});
runtime.register('password-toggle', initPasswordToggles, {
scope: 'global',
configPath: 'components.passwordToggle',
});
runtime.register('login-error-focus', initLoginErrorFocus, {
scope: 'global',
configPath: 'components.loginErrorFocus',
});
runtime.register('login-submit-lock', initLoginSubmitLock, {
scope: 'global',
configPath: 'components.loginSubmitLock',
});
runtime.mountAll();

View File

@@ -19,37 +19,39 @@ export function initActiveFilterChips(options = {}) {
} = options;
const root = typeof container === 'string' ? document.querySelector(container) : container;
if (!root) {
if (!(root instanceof HTMLElement)) {
return null;
}
const bindEvents = () => {
const removeButtons = Array.from(root.querySelectorAll('[data-active-filter-remove]'));
removeButtons.forEach((button) => {
if (button.dataset.bound === '1') {return;}
button.dataset.bound = '1';
button.addEventListener('click', () => {
if (typeof onRemove !== 'function') {return;}
onRemove({
id: button.dataset.activeFilterId || '',
param: button.dataset.activeFilterParam || '',
value: button.dataset.activeFilterValue || '',
valueType: button.dataset.activeFilterValueType || 'scalar',
meta: button.dataset.activeFilterMeta || ''
});
});
});
const abortController = new AbortController();
const { signal } = abortController;
const clearButton = root.querySelector('[data-active-filter-clear]');
if (clearButton && clearButton.dataset.bound !== '1') {
clearButton.dataset.bound = '1';
clearButton.addEventListener('click', () => {
if (typeof onClearAll === 'function') {
onClearAll();
}
});
root.addEventListener('click', (event) => {
const target = event.target instanceof HTMLElement ? event.target : null;
if (!target) {
return;
}
};
const removeButton = target.closest('[data-active-filter-remove]');
if (removeButton instanceof HTMLElement) {
if (typeof onRemove !== 'function') {
return;
}
onRemove({
id: removeButton.dataset.activeFilterId || '',
param: removeButton.dataset.activeFilterParam || '',
value: removeButton.dataset.activeFilterValue || '',
valueType: removeButton.dataset.activeFilterValueType || 'scalar',
meta: removeButton.dataset.activeFilterMeta || ''
});
return;
}
const clearButton = target.closest('[data-active-filter-clear]');
if (clearButton instanceof HTMLElement && typeof onClearAll === 'function') {
onClearAll();
}
}, { signal });
const render = (chips = []) => {
if (!Array.isArray(chips) || chips.length === 0) {
@@ -82,8 +84,11 @@ export function initActiveFilterChips(options = {}) {
</li>`;
root.innerHTML = `<div class="app-active-filters-inner"><ul class="app-filter-chip-list">${list}${clearChip}</ul></div>`;
bindEvents();
};
return { render };
const destroy = () => {
abortController.abort();
};
return { render, destroy };
}

View File

@@ -2,11 +2,38 @@
* Auto-submit: submits the parent form when a [data-auto-submit] element changes.
* Replaces inline onchange="this.form.submit()" handlers for CSP compliance.
*/
document.querySelectorAll('[data-auto-submit]').forEach((element) => {
element.addEventListener('change', () => {
const form = element.closest('form');
if (form) {
form.submit();
import { resolveHost } from '../core/app-dom.js';
export function initAutoSubmit(root = document, options = {}) {
const selector = String(options.selector || '[data-auto-submit]').trim() || '[data-auto-submit]';
const host = resolveHost(root);
const elements = Array.from(host.querySelectorAll(selector));
if (!elements.length) {
return { destroy: () => {} };
}
const cleanupFns = [];
elements.forEach((element) => {
if (!(element instanceof HTMLElement) || element.dataset.autoSubmitBound === '1') {
return;
}
element.dataset.autoSubmitBound = '1';
const onChange = () => {
const form = element.closest('form');
if (form instanceof HTMLFormElement) {
form.submit();
}
};
element.addEventListener('change', onChange);
cleanupFns.push(() => {
element.removeEventListener('change', onChange);
delete element.dataset.autoSubmitBound;
});
});
});
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
};
return { destroy };
}

View File

@@ -1,18 +1,16 @@
/**
* Toggles color input between custom value and default/inherited color.
*/
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const toggles = document.querySelectorAll('[data-color-default-toggle]');
const syncToggle = (toggle) => {
const syncToggle = (toggle, scope) => {
const targetSelector = toggle.dataset.colorTarget || '';
if (!targetSelector) {
warnOnce('UI_EL_MISSING', 'Missing data-color-target', { module: 'color-default-toggle' });
return;
}
const target = document.querySelector(targetSelector);
if (!target) {
const target = scope.querySelector(targetSelector);
if (!(target instanceof HTMLInputElement)) {
warnOnce('UI_EL_MISSING', `Missing color target: ${targetSelector}`, { module: 'color-default-toggle' });
return;
}
@@ -31,23 +29,54 @@ const syncToggle = (toggle) => {
}
};
toggles.forEach((toggle) => {
const targetSelector = toggle.dataset.colorTarget || '';
const target = targetSelector ? document.querySelector(targetSelector) : null;
if (targetSelector && !target) {
warnOnce('UI_EL_MISSING', `Missing color target: ${targetSelector}`, { module: 'color-default-toggle' });
export function initColorDefaultToggle(root = document, options = {}) {
const host = resolveHost(root);
const selector = String(options.selector || '[data-color-default-toggle]').trim() || '[data-color-default-toggle]';
const toggles = Array.from(host.querySelectorAll(selector)).filter(
(toggle) => toggle instanceof HTMLInputElement
);
if (!toggles.length) {
return { destroy: () => {} };
}
toggle.addEventListener('change', () => syncToggle(toggle));
const cleanupFns = [];
toggles.forEach((toggle) => {
if (toggle.dataset.colorDefaultToggleBound === '1') {
return;
}
toggle.dataset.colorDefaultToggleBound = '1';
if (target) {
target.addEventListener('input', () => {
if (toggle.checked) {
toggle.checked = false;
target.removeAttribute('disabled');
}
const scope = document;
const targetSelector = toggle.dataset.colorTarget || '';
const target = targetSelector ? scope.querySelector(targetSelector) : null;
if (targetSelector && !target) {
warnOnce('UI_EL_MISSING', `Missing color target: ${targetSelector}`, { module: 'color-default-toggle' });
}
const onToggleChange = () => syncToggle(toggle, scope);
toggle.addEventListener('change', onToggleChange);
cleanupFns.push(() => toggle.removeEventListener('change', onToggleChange));
if (target instanceof HTMLInputElement) {
const onTargetInput = () => {
if (toggle.checked) {
toggle.checked = false;
target.removeAttribute('disabled');
}
};
target.addEventListener('input', onTargetInput);
cleanupFns.push(() => target.removeEventListener('input', onTargetInput));
}
syncToggle(toggle, scope);
cleanupFns.push(() => {
delete toggle.dataset.colorDefaultToggleBound;
});
}
});
syncToggle(toggle);
});
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
};
return { destroy };
}

View File

@@ -5,6 +5,7 @@ import { confirmDialog, requestSubmitWithFallback } from '../core/app-confirm-di
import { isSubmitControl, belongsToForm } from '../core/app-form-utils.js';
const CONFIRM_ATTR = 'data-confirm-message';
const BOUND_KEY = '_confirmActionsBound';
const confirmedSubmitterByForm = new WeakMap();
const confirmedClickElements = new WeakSet();
@@ -105,5 +106,20 @@ const onSubmitCapture = (event) => {
});
};
document.addEventListener('click', onClickCapture, true);
document.addEventListener('submit', onSubmitCapture, true);
export function initConfirmActions(root = document) {
const host = root && typeof root.addEventListener === 'function' ? root : document;
if (host[BOUND_KEY]) {
return { destroy: () => {} };
}
host[BOUND_KEY] = true;
host.addEventListener('click', onClickCapture, true);
host.addEventListener('submit', onSubmitCapture, true);
const destroy = () => {
host.removeEventListener('click', onClickCapture, true);
host.removeEventListener('submit', onSubmitCapture, true);
delete host[BOUND_KEY];
};
return { destroy };
}

View File

@@ -1,9 +1,10 @@
/**
* High-contrast mode toggle with localStorage persistence.
*/
import { requireEl } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
import { createUiStorage } from '../core/app-ui-storage.js';
const STORAGE_KEY = 'app-contrast';
const STORAGE_KEY = 'contrast-mode';
const setContrast = (contrast) => {
const root = document.documentElement;
@@ -12,7 +13,9 @@ const setContrast = (contrast) => {
const setIcon = (button, contrast) => {
const icon = button.querySelector('i');
if (!icon) {return;}
if (!icon) {
return;
}
icon.classList.remove('bi-circle-half', 'bi-highlights');
icon.classList.add(contrast === 'high' ? 'bi-highlights' : 'bi-circle-half');
};
@@ -21,30 +24,48 @@ const setPressedState = (button, contrast) => {
button.setAttribute('aria-pressed', contrast === 'high' ? 'true' : 'false');
};
const initContrastToggle = () => {
const button = requireEl('[data-contrast-toggle]', { module: 'contrast-toggle' });
if (!button) {return;}
const root = document.documentElement;
const getCurrent = () => (root.dataset.contrast === 'high' ? 'high' : 'normal');
export function initContrastToggle(root = document, options = {}) {
const selector = String(options.selector || '[data-contrast-toggle]').trim() || '[data-contrast-toggle]';
const storage = createUiStorage({
namespace: options.storageNamespace || 'app-ui',
version: options.storageVersion || 'v1',
scope: options.storageScope || 'contrast',
});
const scopedStorageKey = storage.buildKey('state', options.storageKey || STORAGE_KEY);
const host = resolveHost(root);
const button = host.matches?.(selector) ? host : host.querySelector(selector);
if (!(button instanceof HTMLElement)) {
warnOnce('UI_EL_MISSING', `Missing contrast toggle: ${selector}`, { module: 'contrast-toggle' });
return { destroy: () => {} };
}
if (button.dataset.contrastToggleBound === '1') {
return {
destroy: () => {
delete button.dataset.contrastToggleBound;
},
};
}
button.dataset.contrastToggleBound = '1';
const getCurrent = () => (document.documentElement.dataset.contrast === 'high' ? 'high' : 'normal');
const current = getCurrent();
setIcon(button, current);
setPressedState(button, current);
button.addEventListener('click', () => {
const onClick = () => {
const next = getCurrent() === 'high' ? 'normal' : 'high';
setContrast(next);
setIcon(button, next);
setPressedState(button, next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch (e) {
// ignore storage errors
}
});
};
storage.setItem(scopedStorageKey, next);
};
button.addEventListener('click', onClick);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initContrastToggle);
} else {
initContrastToggle();
const destroy = () => {
button.removeEventListener('click', onClick);
delete button.dataset.contrastToggleBound;
};
return { destroy };
}

View File

@@ -1,6 +1,8 @@
/**
* Click-to-copy badge — copies text to clipboard with visual feedback.
*/
import { resolveHost } from '../core/app-dom.js';
const COPY_SELECTOR = '.badge[data-copy="true"]';
const COPIED_CLASS = 'is-copied';
@@ -13,7 +15,9 @@ const getCopyValue = (badge) => {
};
const copyText = async (value) => {
if (!value) {return false;}
if (!value) {
return false;
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
@@ -30,40 +34,74 @@ const copyText = async (value) => {
return ok;
};
const enhanceBadge = (badge) => {
if (badge.querySelector('.badge-copy-icon')) {return;}
const icon = document.createElement('span');
icon.className = 'badge-copy-icon';
icon.setAttribute('aria-hidden', 'true');
icon.innerHTML = '<i class="bi bi-copy"></i><i class="bi bi-check2"></i>';
badge.appendChild(icon);
badge.setAttribute('role', 'button');
badge.setAttribute('tabindex', '0');
if (!badge.hasAttribute('aria-label')) {
badge.setAttribute('aria-label', 'Copy to clipboard');
export function initBadgeCopy(root = document, options = {}) {
const selector = String(options.selector || COPY_SELECTOR).trim() || COPY_SELECTOR;
const host = resolveHost(root);
const badges = Array.from(host.querySelectorAll(selector));
if (!badges.length) {
return { destroy: () => {} };
}
const handler = async (event) => {
event.preventDefault();
const value = getCopyValue(badge);
const ok = await copyText(value);
if (!ok) {return;}
badge.classList.add(COPIED_CLASS);
window.setTimeout(() => badge.classList.remove(COPIED_CLASS), 1200);
const cleanupFns = [];
const timers = [];
const enhanceBadge = (badge) => {
if (badge.dataset.badgeCopyBound === '1') {
return;
}
badge.dataset.badgeCopyBound = '1';
if (!badge.querySelector('.badge-copy-icon')) {
const icon = document.createElement('span');
icon.className = 'badge-copy-icon';
icon.setAttribute('aria-hidden', 'true');
icon.innerHTML = '<i class="bi bi-copy"></i><i class="bi bi-check2"></i>';
badge.appendChild(icon);
}
badge.setAttribute('role', 'button');
badge.setAttribute('tabindex', '0');
if (!badge.hasAttribute('aria-label')) {
badge.setAttribute('aria-label', 'Copy to clipboard');
}
const handler = async (event) => {
event.preventDefault();
const value = getCopyValue(badge);
const ok = await copyText(value);
if (!ok) {
return;
}
badge.classList.add(COPIED_CLASS);
const timer = window.setTimeout(() => badge.classList.remove(COPIED_CLASS), 1200);
timers.push(timer);
};
const onKeyDown = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
handler(event);
}
};
badge.addEventListener('click', handler);
badge.addEventListener('keydown', onKeyDown);
cleanupFns.push(() => {
badge.removeEventListener('click', handler);
badge.removeEventListener('keydown', onKeyDown);
delete badge.dataset.badgeCopyBound;
});
};
badge.addEventListener('click', handler);
badge.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
handler(event);
badges.forEach((badge) => {
if (badge instanceof HTMLElement) {
enhanceBadge(badge);
}
});
};
const initBadgeCopy = () => {
document.querySelectorAll(COPY_SELECTOR).forEach(enhanceBadge);
};
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
timers.forEach((timer) => window.clearTimeout(timer));
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBadgeCopy);
} else {
initBadgeCopy();
return { destroy };
}
export const initCopyBadge = initBadgeCopy;

View File

@@ -1,18 +1,16 @@
/**
* Shows/hides custom field option list based on field type selection.
*/
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const TYPES_WITH_OPTIONS = new Set(['select', 'multiselect']);
const FILTERABLE_TYPES = new Set(['select', 'multiselect', 'boolean', 'date']);
const sources = document.querySelectorAll('[data-custom-field-type-source]');
const syncOptionsVisibility = (source, target) => {
const selectedType = String(source.value || '').trim().toLowerCase();
const showOptions = TYPES_WITH_OPTIONS.has(selectedType);
target.hidden = !showOptions;
target.querySelectorAll('textarea, input, select').forEach((control) => {
if (!control.dataset.initialDisabled) {
control.dataset.initialDisabled = control.hasAttribute('disabled') ? '1' : '0';
@@ -37,9 +35,9 @@ const syncFilterableAvailability = (source, target) => {
const filterableAllowed = FILTERABLE_TYPES.has(selectedType);
const checkbox = target.querySelector('[data-custom-field-filterable-input]');
if (!checkbox) {
if (!(checkbox instanceof HTMLInputElement)) {
warnOnce('UI_EL_MISSING', 'Missing filterable checkbox in custom field group', {
module: 'custom-field-options-toggle'
module: 'custom-field-options-toggle',
});
return;
}
@@ -64,38 +62,63 @@ const syncFilterableAvailability = (source, target) => {
checkbox.removeAttribute('disabled');
};
sources.forEach((source) => {
const group = String(source.dataset.customFieldGroup || '').trim();
if (!group) {
warnOnce('UI_EL_MISSING', 'Missing data-custom-field-group', { module: 'custom-field-options-toggle' });
return;
export function initCustomFieldOptionsToggle(root = document, options = {}) {
const host = resolveHost(root);
const sourceSelector = String(options.sourceSelector || '[data-custom-field-type-source]').trim() || '[data-custom-field-type-source]';
const sources = Array.from(host.querySelectorAll(sourceSelector));
if (!sources.length) {
return { destroy: () => {} };
}
const target = document.querySelector(
`[data-custom-field-options-target][data-custom-field-group="${group}"]`
);
if (!target) {
warnOnce('UI_EL_MISSING', `Missing custom field options target for group: ${group}`, {
module: 'custom-field-options-toggle'
const cleanupFns = [];
sources.forEach((source) => {
if (!(source instanceof HTMLSelectElement) || source.dataset.customFieldOptionsBound === '1') {
return;
}
source.dataset.customFieldOptionsBound = '1';
const group = String(source.dataset.customFieldGroup || '').trim();
if (!group) {
warnOnce('UI_EL_MISSING', 'Missing data-custom-field-group', { module: 'custom-field-options-toggle' });
return;
}
const target = document.querySelector(
`[data-custom-field-options-target][data-custom-field-group="${group}"]`
);
if (!(target instanceof HTMLElement)) {
warnOnce('UI_EL_MISSING', `Missing custom field options target for group: ${group}`, {
module: 'custom-field-options-toggle',
});
return;
}
const filterableTarget = document.querySelector(
`[data-custom-field-filterable-target][data-custom-field-group="${group}"]`
);
if (!(filterableTarget instanceof HTMLElement)) {
warnOnce('UI_EL_MISSING', `Missing custom field filterable target for group: ${group}`, {
module: 'custom-field-options-toggle',
});
return;
}
const onChange = () => {
syncOptionsVisibility(source, target);
syncFilterableAvailability(source, filterableTarget);
};
source.addEventListener('change', onChange);
cleanupFns.push(() => {
source.removeEventListener('change', onChange);
delete source.dataset.customFieldOptionsBound;
});
return;
}
const filterableTarget = document.querySelector(
`[data-custom-field-filterable-target][data-custom-field-group="${group}"]`
);
if (!filterableTarget) {
warnOnce('UI_EL_MISSING', `Missing custom field filterable target for group: ${group}`, {
module: 'custom-field-options-toggle'
});
return;
}
source.addEventListener('change', () => {
syncOptionsVisibility(source, target);
syncFilterableAvailability(source, filterableTarget);
onChange();
});
syncOptionsVisibility(source, target);
syncFilterableAvailability(source, filterableTarget);
});
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
};
return { destroy };
}

View File

@@ -2,38 +2,50 @@
* Initializes detail page open-state persistence and action policy.
*/
import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';
import { resolveHost } from '../core/app-dom.js';
const controllerRegistry = new WeakMap();
const initDetailsGroup = (group) => {
if (!group || group.dataset.detailsStorageBound === '1') {
return;
}
export function initDetailsState(root = document, options = {}) {
const selector = String(options.selector || '[data-details-storage]').trim() || '[data-details-storage]';
const storageNamespace = String(options.storageNamespace || 'app-ui').trim() || 'app-ui';
const storageVersion = String(options.storageVersion || 'v1').trim() || 'v1';
const storageScope = String(options.storageScope || 'details-open').trim() || 'details-open';
const host = resolveHost(root);
const groups = Array.from(host.querySelectorAll(selector));
groups.forEach((group) => {
if (!group || group.dataset.detailsStorageBound === '1') {
return;
}
const storageKey = (group.dataset.detailsStorage || '').trim();
if (!storageKey) {
return;
}
const storageKey = (group.dataset.detailsStorage || '').trim();
if (!storageKey) {
return;
}
const controller = initPersistedDetailsGroup({
root: group,
storageKey,
const controller = initPersistedDetailsGroup({
root: group,
storageKey,
storageNamespace,
storageVersion,
storageScope,
});
if (!controller) {
return;
}
controllerRegistry.set(group, controller);
group.dataset.detailsStorageBound = '1';
});
if (!controller) {
return;
}
controllerRegistry.set(group, controller);
group.dataset.detailsStorageBound = '1';
};
const initDetailsState = () => {
const groups = Array.from(document.querySelectorAll('[data-details-storage]'));
groups.forEach(initDetailsGroup);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDetailsState);
} else {
initDetailsState();
return {
destroy: () => {
groups.forEach((group) => {
const controller = controllerRegistry.get(group);
if (controller && typeof controller.destroy === 'function') {
controller.destroy();
}
delete group.dataset.detailsStorageBound;
});
},
};
}

View File

@@ -1,7 +1,7 @@
/**
* Slide-in filter drawer with focus trap, scroll lock, and apply/reset/discard lifecycle.
*/
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
export function initFilterDrawer(options = {}) {
const {
@@ -15,25 +15,34 @@ export function initFilterDrawer(options = {}) {
onClose = null,
onApply = null,
onReset = null,
onDiscard = null
onDiscard = null,
} = options;
const host = root && typeof root.querySelector === 'function' ? root : document;
const host = resolveHost(root);
const drawer = host.querySelector(drawerSelector);
if (!drawer) {
if (!(drawer instanceof HTMLElement)) {
warnOnce('UI_EL_MISSING', `Missing filter drawer: ${drawerSelector}`, { module: 'filter-drawer' });
return null;
}
if (drawer.dataset.filterDrawerBound === '1' && drawer._filterDrawerApi) {
return drawer._filterDrawerApi;
}
const openButtons = Array.from(host.querySelectorAll(openSelector));
const closeButtons = Array.from(drawer.querySelectorAll(closeSelector));
const applyButton = drawer.querySelector(applySelector);
const resetButton = drawer.querySelector(resetSelector);
const applyBaseLabel = applyButton ? String(applyButton.textContent || '').trim() : '';
const openButtonBaseLabels = new WeakMap();
const panel = drawer.querySelector('[data-filter-drawer-panel]') || drawer;
const cleanupFns = [];
const bind = (target, eventName, handler, bindOptions = undefined) => {
target.addEventListener(eventName, handler, bindOptions);
cleanupFns.push(() => target.removeEventListener(eventName, handler, bindOptions));
};
let isOpen = false;
let lockedScrollY = 0;
let lastTrigger = null;
@@ -51,8 +60,9 @@ export function initFilterDrawer(options = {}) {
const lockPageScroll = () => {
const body = document.body;
if (body.dataset.filterDrawerLock === '1') {return;}
if (body.dataset.filterDrawerLock === '1') {
return;
}
lockedScrollY = window.scrollY || window.pageYOffset || 0;
body.dataset.filterDrawerLock = '1';
body.style.position = 'fixed';
@@ -64,8 +74,9 @@ export function initFilterDrawer(options = {}) {
const unlockPageScroll = () => {
const body = document.body;
if (body.dataset.filterDrawerLock !== '1') {return;}
if (body.dataset.filterDrawerLock !== '1') {
return;
}
body.style.position = '';
body.style.top = '';
body.style.left = '';
@@ -83,13 +94,15 @@ export function initFilterDrawer(options = {}) {
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
'[tabindex]:not([tabindex="-1"])',
];
return Array.from(panel.querySelectorAll(selectors.join(','))).filter((element) => {
if (!element) {return false;}
if (element.getAttribute('aria-hidden') === 'true') {return false;}
if (element instanceof HTMLElement && element.hidden) {return false;}
if (element.getAttribute('aria-hidden') === 'true') {
return false;
}
if (element instanceof HTMLElement && element.hidden) {
return false;
}
return true;
});
};
@@ -107,14 +120,17 @@ export function initFilterDrawer(options = {}) {
drawer.setAttribute('aria-hidden', nextOpen ? 'false' : 'true');
drawer.classList.toggle('is-open', nextOpen);
document.body.classList.toggle('filter-drawer-open', nextOpen);
if (nextOpen) {
lockPageScroll();
} else {
unlockPageScroll();
}
openButtons.forEach((button) => {
button.setAttribute('aria-expanded', nextOpen ? 'true' : 'false');
});
if (!nextOpen) {
if (lastTrigger && typeof lastTrigger.focus === 'function' && document.contains(lastTrigger)) {
lastTrigger.focus();
@@ -125,6 +141,7 @@ export function initFilterDrawer(options = {}) {
}
return;
}
if (typeof onOpen === 'function') {
onOpen();
}
@@ -132,7 +149,9 @@ export function initFilterDrawer(options = {}) {
};
const open = (trigger = null) => {
if (isOpen) {return;}
if (isOpen) {
return;
}
lastTrigger = trigger && typeof trigger.focus === 'function'
? trigger
: (document.activeElement instanceof HTMLElement ? document.activeElement : null);
@@ -140,7 +159,9 @@ export function initFilterDrawer(options = {}) {
};
const close = (mode = 'discard') => {
if (!isOpen) {return;}
if (!isOpen) {
return;
}
if (mode === 'discard' && typeof onDiscard === 'function') {
onDiscard();
}
@@ -148,21 +169,21 @@ export function initFilterDrawer(options = {}) {
};
openButtons.forEach((button) => {
button.addEventListener('click', (event) => {
bind(button, 'click', (event) => {
event.preventDefault();
open(button);
});
});
closeButtons.forEach((button) => {
button.addEventListener('click', (event) => {
bind(button, 'click', (event) => {
event.preventDefault();
close('discard');
});
});
if (applyButton) {
applyButton.addEventListener('click', async (event) => {
bind(applyButton, 'click', async (event) => {
event.preventDefault();
if (applyButton.disabled) {
return;
@@ -179,7 +200,7 @@ export function initFilterDrawer(options = {}) {
}
if (resetButton) {
resetButton.addEventListener('click', async (event) => {
bind(resetButton, 'click', async (event) => {
event.preventDefault();
if (typeof onReset === 'function') {
await onReset();
@@ -187,22 +208,26 @@ export function initFilterDrawer(options = {}) {
});
}
drawer.addEventListener('click', (event) => {
if (!isOpen) {return;}
bind(drawer, 'click', (event) => {
if (!isOpen) {
return;
}
if (!panel.contains(event.target)) {
close('discard');
}
});
const onKeyDown = (event) => {
if (!isOpen) {return;}
if (!isOpen) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
close('discard');
return;
}
if (event.key !== 'Tab') {return;}
const focusable = getFocusableElements();
if (!focusable.length) {
event.preventDefault();
@@ -213,6 +238,7 @@ export function initFilterDrawer(options = {}) {
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !panel.contains(active)) {
event.preventDefault();
@@ -226,17 +252,21 @@ export function initFilterDrawer(options = {}) {
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
bind(document, 'keydown', onKeyDown);
const setApplyEnabled = (enabled) => {
if (!applyButton) {return;}
if (!applyButton) {
return;
}
const nextEnabled = enabled === true;
applyButton.disabled = !nextEnabled;
applyButton.setAttribute('aria-disabled', nextEnabled ? 'false' : 'true');
};
const setApplyCount = (count) => {
if (!applyButton) {return;}
if (!applyButton) {
return;
}
const parsed = Number.parseInt(String(count ?? '0'), 10);
const normalized = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
applyButton.textContent = normalized > 0
@@ -252,7 +282,9 @@ export function initFilterDrawer(options = {}) {
const baseLabel = openButtonBaseLabels.get(button)
|| String(button.getAttribute('aria-label') || button.textContent || '').trim();
if (!baseLabel) {return;}
if (!baseLabel) {
return;
}
if (!openButtonBaseLabels.has(button)) {
openButtonBaseLabels.set(button, baseLabel);
}
@@ -277,13 +309,16 @@ export function initFilterDrawer(options = {}) {
setTriggerCount(0);
const destroy = () => {
document.removeEventListener('keydown', onKeyDown);
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns.length = 0;
if (isOpen) {
close('discard');
}
delete drawer.dataset.filterDrawerBound;
delete drawer._filterDrawerApi;
};
return {
const api = {
open,
close,
isOpen: () => isOpen,
@@ -291,6 +326,10 @@ export function initFilterDrawer(options = {}) {
setApplyEnabled,
setApplyCount,
setTriggerCount,
destroy
destroy,
};
drawer.dataset.filterDrawerBound = '1';
drawer._filterDrawerApi = api;
return api;
}

View File

@@ -1,28 +1,35 @@
/**
* Auto-dismisses flash notices after their data-flash-timeout expires.
*/
export function initFlashAutoDismiss(options = {}) {
import { resolveHost } from '../core/app-dom.js';
export function initFlashAutoDismiss(root = document, options = {}) {
const {
selector = '.flash-stack .notice[data-flash-timeout]',
defaultTimeout = 0
defaultTimeout = 0,
} = options;
const notices = document.querySelectorAll(selector);
const host = resolveHost(root);
const notices = Array.from(host.querySelectorAll(selector));
if (!notices.length) {
return;
return { destroy: () => {} };
}
const timers = [];
let destroyed = false;
const postForm = async (form) => {
const action = form.getAttribute('action');
if (!action) {return null;}
if (!action) {
return null;
}
const body = new URLSearchParams(new FormData(form));
return fetch(action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'fetch'
'X-Requested-With': 'fetch',
},
body
body,
});
};
@@ -36,7 +43,11 @@ export function initFlashAutoDismiss(options = {}) {
}
notice.style.setProperty('--flash-timeout', `${timeout}ms`);
notice.classList.add('flash-timed');
window.setTimeout(async () => {
const timer = window.setTimeout(async () => {
if (destroyed) {
return;
}
const form = notice.querySelector('form');
if (form) {
const response = await postForm(form);
@@ -46,11 +57,13 @@ export function initFlashAutoDismiss(options = {}) {
}
notice.remove();
}, timeout);
timers.push(timer);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initFlashAutoDismiss());
} else {
initFlashAutoDismiss();
const destroy = () => {
destroyed = true;
timers.forEach((timer) => window.clearTimeout(timer));
};
return { destroy };
}

View File

@@ -1,60 +1,60 @@
/**
* Triggers FSLightbox refresh after dynamic content changes.
*/
const refreshFsLightbox = () => {
if (typeof window.requestFsLightboxRefresh === 'function') {
return window.requestFsLightboxRefresh();
}
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
window.__fsLightboxRefreshPending = false;
return true;
}
window.__fsLightboxRefreshPending = true;
return false;
};
import { flushPendingFsLightboxRefresh, requestFsLightboxRefresh } from '../core/app-fslightbox-bridge.js';
import { resolveHost } from '../core/app-dom.js';
let refreshTimer = null;
const scheduleRefresh = () => {
if (refreshTimer) {return;}
refreshTimer = window.setTimeout(() => {
refreshTimer = null;
refreshFsLightbox();
}, 30);
};
export function initFsLightboxRefresh(root = document) {
const host = resolveHost(root);
const observerRoot = host === document ? document.body : host;
const initObserver = () => {
if (!document.body || typeof MutationObserver === 'undefined') {
return;
let refreshTimer = 0;
let observer = null;
const scheduleRefresh = () => {
if (refreshTimer) {
return;
}
refreshTimer = window.setTimeout(() => {
refreshTimer = 0;
requestFsLightboxRefresh();
}, 30);
};
if (!requestFsLightboxRefresh()) {
scheduleRefresh();
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) {continue;}
if (node.matches?.('[data-fslightbox]') || node.querySelector?.('[data-fslightbox]')) {
scheduleRefresh();
return;
requestFsLightboxRefresh();
if (observerRoot && typeof MutationObserver !== 'undefined') {
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) {
continue;
}
if (node.matches?.('[data-fslightbox]') || node.querySelector?.('[data-fslightbox]')) {
scheduleRefresh();
return;
}
}
}
});
observer.observe(observerRoot, { childList: true, subtree: true });
}
flushPendingFsLightboxRefresh();
const destroy = () => {
if (refreshTimer) {
window.clearTimeout(refreshTimer);
refreshTimer = 0;
}
});
observer.observe(document.body, { childList: true, subtree: true });
};
if (observer) {
observer.disconnect();
observer = null;
}
};
if (!refreshFsLightbox()) {
scheduleRefresh();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
refreshFsLightbox();
initObserver();
});
} else {
refreshFsLightbox();
initObserver();
}
if (window.__fsLightboxRefreshPending && typeof window.refreshFsLightbox === 'function') {
refreshFsLightbox();
return { destroy };
}

View File

@@ -1,23 +1,30 @@
/**
* Global search sidebar — debounced fetch with live result counts and Ctrl+K shortcut.
*/
import { requireEl, optionalEl, warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
import { isEditableTarget } from '../core/app-form-utils.js';
import { requestAsidePanelOpen } from '../core/app-ui-channels.js';
export function initGlobalSearch() {
const input = requireEl('#side-search', { module: 'global-search' });
const resultsEl = requireEl('[data-global-search-results]', { module: 'global-search' });
if (!input || !resultsEl) {return null;}
if (input.dataset.globalSearchBound === '1') {return null;}
export function initGlobalSearch(root = document, options = {}) {
const host = resolveHost(root);
const inputSelector = String(options.inputSelector || '#side-search').trim() || '#side-search';
const resultsSelector = String(options.resultsSelector || '[data-global-search-results]').trim() || '[data-global-search-results]';
const input = host.matches?.(inputSelector) ? host : host.querySelector(inputSelector);
const resultsEl = host.matches?.(resultsSelector) ? host : host.querySelector(resultsSelector);
if (!input || !resultsEl) {return { destroy: () => {} };}
if (input.dataset.globalSearchBound === '1' && input._globalSearchApi) {
return input._globalSearchApi;
}
input.dataset.globalSearchBound = '1';
const emptyStateEl = optionalEl('[data-global-search-empty]');
const emptyHintEl = optionalEl('[data-search-empty-hint-template]');
const shortcutEl = optionalEl('[data-search-shortcut]');
const panel = optionalEl('#aside-panel-search');
const toggleButton = optionalEl('[data-search-details-toggle]');
const toggleIcon = optionalEl('[data-search-details-icon]');
const appBase = window.APP_BASE || document.baseURI;
const emptyStateEl = host.querySelector('[data-global-search-empty]');
const emptyHintEl = host.querySelector('[data-search-empty-hint-template]');
const shortcutEl = host.querySelector('[data-search-shortcut]');
const panel = host.matches?.('#aside-panel-search') ? host : host.querySelector('#aside-panel-search');
const toggleButton = host.querySelector('[data-search-details-toggle]');
const toggleIcon = host.querySelector('[data-search-details-icon]');
const appBase = String(options.appBase || document.baseURI || window.location.origin).trim() || document.baseURI;
const endpoint = new URL('admin/search/data', appBase);
const resultsPage = new URL('search', appBase);
const minLength = 2;
@@ -181,11 +188,7 @@ export function initGlobalSearch() {
};
const openSearch = () => {
if (window.AppAsidePanels?.open) {
window.AppAsidePanels.open('search');
} else {
document.querySelector('[data-aside-target="search"]')?.click();
}
requestAsidePanelOpen('search', { fromUser: true });
focusSearch();
};
@@ -253,7 +256,7 @@ export function initGlobalSearch() {
form.addEventListener('submit', onFormSubmit);
}
if (toggleButton && panel) {
if (toggleButton && panel instanceof HTMLElement) {
toggleButton.addEventListener('click', onToggleClick);
}
@@ -287,18 +290,14 @@ export function initGlobalSearch() {
if (form) {
form.removeEventListener('submit', onFormSubmit);
}
if (toggleButton) {
if (toggleButton && panel instanceof HTMLElement) {
toggleButton.removeEventListener('click', onToggleClick);
}
delete input.dataset.globalSearchBound;
delete input._globalSearchApi;
};
return { destroy, openSearch, focusSearch };
}
// Auto-initialize on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initGlobalSearch());
} else {
initGlobalSearch();
const api = { destroy, openSearch, focusSearch };
input._globalSearchApi = api;
return api;
}

View File

@@ -2,7 +2,7 @@
* Cascading multi-select — filters child options based on parent selection.
*/
import { getMultiSelectInstance } from './app-multiselect-init.js';
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const parseValueList = (value) =>
String(value || '')
@@ -15,19 +15,6 @@ const setDisabledState = (option, disabled) => {
option.classList.toggle('is-disabled', disabled);
option.setAttribute('aria-disabled', disabled ? 'true' : 'false');
option.style.pointerEvents = disabled ? 'none' : '';
if (!option._cascadeGuard) {
option.addEventListener(
'click',
(event) => {
if (option.dataset.disabled === '1') {
event.preventDefault();
event.stopImmediatePropagation();
}
},
true
);
option._cascadeGuard = true;
}
};
const toggleSelectAll = (instance, disabled) => {
@@ -44,9 +31,11 @@ export const initMultiSelectCascade = ({
map = {},
mode = 'disable',
clearInvalid = true,
}) => {
const parentInput = typeof parent === 'string' ? document.querySelector(parent) : parent;
const childInput = typeof child === 'string' ? document.querySelector(child) : child;
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' });
@@ -54,13 +43,13 @@ export const initMultiSelectCascade = ({
if (!childInput) {
warnOnce('UI_EL_MISSING', `Missing multiselect child: ${child}`, { module: 'multiselect-cascade' });
}
return;
return { destroy: () => {} };
}
const childInstance = getMultiSelectInstance(childInput);
const childInstance = getMultiSelectInstance(childInput, host);
if (!childInstance) {
warnOnce('UI_INIT_MISSING', 'Missing multiselect instance for child', { module: 'multiselect-cascade' });
return;
return { destroy: () => {} };
}
const apply = () => {
@@ -98,4 +87,13 @@ export const initMultiSelectCascade = ({
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);
},
};
};

View File

@@ -1,13 +1,16 @@
/**
* Initializes MultiSelect.js instances from select[multiple] elements.
*/
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
let multiSelectLoader = null;
const resolveScriptUrl = (script) => {
if (script) {return script;}
const assetBase = window.APP_ASSET_BASE || document.baseURI;
if (script) {
return script;
}
const root = document.documentElement;
const assetBase = String(root?.dataset?.assetBase || '').trim() || document.baseURI;
return new URL('vendor/multi-select/MultiSelect.js', assetBase).toString();
};
@@ -28,19 +31,59 @@ const ensureMultiSelect = async (script) => {
return multiSelectLoader;
};
export const initMultiSelect = async (target = '[data-multi-select]') => {
const elements = typeof target === 'string' ? document.querySelectorAll(target) : [target];
if (!elements.length) {return [];}
const resolveElements = (target, host) => {
if (typeof target === 'string') {
return Array.from(host.querySelectorAll(target));
}
if (target instanceof Element) {
return [target];
}
if (target && typeof target.length === 'number') {
return Array.from(target);
}
return [];
};
const destroyElementInstance = (element, instance = null) => {
if (!(element instanceof HTMLElement)) {
return;
}
const cleanup = element._multiSelectCleanup;
if (typeof cleanup === 'function') {
cleanup();
}
delete element._multiSelectCleanup;
delete element.dataset.multiSelectInit;
const resolvedInstance = instance || element._multiSelectInstance || null;
if (resolvedInstance && typeof resolvedInstance.destroy === 'function') {
resolvedInstance.destroy();
}
delete element._multiSelectInstance;
};
export const initMultiSelect = async (target = '[data-multi-select]', root = document) => {
const host = resolveHost(root);
const elements = resolveElements(target, host);
if (!elements.length) {
return [];
}
const MultiSelect = await ensureMultiSelect();
const instances = [];
elements.forEach((element) => {
if (!element || element.dataset.multiSelectInit === '1') {return;}
if (!(element instanceof HTMLElement) || element.dataset.multiSelectInit === '1') {
return;
}
const cleanupFns = [];
const syncTarget = element.dataset.syncTarget;
const syncInput = syncTarget ? document.querySelector(syncTarget) : null;
const options = {};
const placeholder = element.dataset.placeholder;
if (placeholder) {options.placeholder = placeholder;}
if (placeholder) {
options.placeholder = placeholder;
}
if (element.dataset.search !== undefined) {
options.search = element.dataset.search === 'true';
}
@@ -65,6 +108,7 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
if (element.dataset.selectAllLabel) {
options.selectAllLabel = element.dataset.selectAllLabel;
}
let isSyncing = false;
let instance;
try {
@@ -73,16 +117,20 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
warnOnce('UI_INIT_FAIL', 'multi-select init failed', { module: 'multi-select', element, error });
return;
}
if (element.dataset.disabled === 'true' || element.disabled) {
instance.disable();
}
element.dataset.multiSelectInit = '1';
element._multiSelectInstance = instance;
instances.push(instance);
if (syncInput) {
if (syncInput instanceof HTMLInputElement) {
syncInput._multiSelectInstance = instance;
const syncValues = () => {
if (!syncInput || isSyncing) {return;}
if (isSyncing) {
return;
}
isSyncing = true;
const values = instance.selectedValues || [];
syncInput.value = Array.isArray(values) ? values.join(',') : '';
@@ -95,9 +143,11 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
return;
}
instance.element.addEventListener('multi-select:change', syncValues);
cleanupFns.push(() => instance.element?.removeEventListener('multi-select:change', syncValues));
boundElement = instance.element;
};
bindSyncListener();
if (typeof instance.refresh === 'function' && instance._syncRefreshWrapped !== true) {
const originalRefresh = instance.refresh.bind(instance);
instance.refresh = (...args) => {
@@ -107,40 +157,86 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
};
instance._syncRefreshWrapped = true;
}
const values = instance.selectedValues || [];
syncInput.value = Array.isArray(values) ? values.join(',') : '';
syncInput.addEventListener('change', () => {
if (isSyncing) {return;}
const onSyncInputChange = () => {
if (isSyncing) {
return;
}
const nextValues = syncInput.value
? syncInput.value.split(',').map((item) => item.trim()).filter(Boolean)
: [];
instance.setValues(nextValues);
});
};
syncInput.addEventListener('change', onSyncInputChange);
cleanupFns.push(() => syncInput.removeEventListener('change', onSyncInputChange));
}
element._multiSelectCleanup = () => {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns.length = 0;
if (syncInput instanceof HTMLInputElement && syncInput._multiSelectInstance === instance) {
delete syncInput._multiSelectInstance;
}
};
});
return instances;
};
export const getMultiSelectInstance = (target) => {
const element = typeof target === 'string' ? document.querySelector(target) : target;
if (!element) {return null;}
if (element._multiSelectInstance) {return element._multiSelectInstance;}
export const getMultiSelectInstance = (target, root = document) => {
const host = resolveHost(root);
const element = typeof target === 'string' ? host.querySelector(target) : target;
if (!element) {
return null;
}
if (element._multiSelectInstance) {
return element._multiSelectInstance;
}
const syncTarget = element.dataset?.syncTarget;
if (syncTarget) {
const syncElement = document.querySelector(syncTarget);
if (syncElement && syncElement._multiSelectInstance) {return syncElement._multiSelectInstance;}
if (syncElement && syncElement._multiSelectInstance) {
return syncElement._multiSelectInstance;
}
}
return null;
};
const autoInit = () => {
initMultiSelect().catch((error) => {
warnOnce('UI_INIT_FAIL', 'multi-select auto init failed', { module: 'multi-select', error });
});
};
export function initMultiSelectComponent(root = document, options = {}) {
const selector = String(options.selector || '[data-multi-select]').trim() || '[data-multi-select]';
const host = resolveHost(root);
let destroyed = false;
let instances = [];
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit);
} else {
autoInit();
initMultiSelect(selector, host)
.then((initialized) => {
if (destroyed) {
initialized.forEach((instance) => {
const element = instance?.element;
if (element instanceof HTMLElement) {
destroyElementInstance(element, instance);
}
});
return;
}
instances = initialized;
})
.catch((error) => {
warnOnce('UI_INIT_FAIL', 'multi-select runtime init failed', { module: 'multi-select', error });
});
return {
destroy: () => {
destroyed = true;
instances.forEach((instance) => {
const element = instance?.element;
if (element instanceof HTMLElement) {
destroyElementInstance(element, instance);
}
});
instances = [];
},
};
}

View File

@@ -2,13 +2,20 @@
* Browser history integration — handles back/forward navigation state.
*/
import { isEditableTarget } from '../core/app-form-utils.js';
import { resolveHost } from '../core/app-dom.js';
const updateHistoryButtons = () => {
const back = document.querySelector('#global-back');
const forward = document.querySelector('#global-forward');
if (!back && !forward) {return;}
export function initNavHistory(root = document, options = {}) {
const {
backSelector = '#global-back',
forwardSelector = '#global-forward',
} = options;
const host = resolveHost(root);
const back = host.querySelector(backSelector);
const forward = host.querySelector(forwardSelector);
if (!back && !forward) {
return { destroy: () => {} };
}
const hasHistory = window.history.length > 1;
const setDisabledState = (link, disabled) => {
link.setAttribute('aria-disabled', disabled ? 'true' : 'false');
link.classList.toggle('is-disabled', disabled);
@@ -19,21 +26,23 @@ const updateHistoryButtons = () => {
}
};
if (back) {
setDisabledState(back, !hasHistory);
}
if (forward) {
setDisabledState(forward, !hasHistory);
}
};
const updateHistoryButtons = () => {
const hasHistory = window.history.length > 1;
if (back instanceof HTMLElement) {
setDisabledState(back, !hasHistory);
}
if (forward instanceof HTMLElement) {
setDisabledState(forward, !hasHistory);
}
};
const initHistoryButtons = () => {
const back = document.querySelector('#global-back');
const forward = document.querySelector('#global-forward');
document.addEventListener('keydown', (event) => {
if (!event.altKey || event.metaKey || event.ctrlKey) {return;}
if (isEditableTarget(event.target)) {return;}
const onKeyDown = (event) => {
if (!event.altKey || event.metaKey || event.ctrlKey) {
return;
}
if (isEditableTarget(event.target)) {
return;
}
if (event.key === 'ArrowLeft') {
if (window.history.length > 1) {
event.preventDefault();
@@ -45,32 +54,43 @@ const initHistoryButtons = () => {
window.history.forward();
}
}
});
};
if (back) {
back.addEventListener('click', (event) => {
event.preventDefault();
if (window.history.length > 1) {
window.history.back();
}
});
document.addEventListener('keydown', onKeyDown);
const onBackClick = (event) => {
event.preventDefault();
if (window.history.length > 1) {
window.history.back();
}
};
if (back instanceof HTMLElement) {
back.addEventListener('click', onBackClick);
}
if (forward) {
forward.addEventListener('click', (event) => {
event.preventDefault();
if (window.history.length > 1) {
window.history.forward();
}
});
const onForwardClick = (event) => {
event.preventDefault();
if (window.history.length > 1) {
window.history.forward();
}
};
if (forward instanceof HTMLElement) {
forward.addEventListener('click', onForwardClick);
}
updateHistoryButtons();
window.addEventListener('popstate', updateHistoryButtons);
};
updateHistoryButtons();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initHistoryButtons);
} else {
initHistoryButtons();
const destroy = () => {
document.removeEventListener('keydown', onKeyDown);
if (back instanceof HTMLElement) {
back.removeEventListener('click', onBackClick);
}
if (forward instanceof HTMLElement) {
forward.removeEventListener('click', onForwardClick);
}
window.removeEventListener('popstate', updateHistoryButtons);
};
return { destroy, update: updateHistoryButtons };
}

View File

@@ -1,192 +1,250 @@
/**
* Editor.js integration — initializes block editor for CMS page content.
* Editor.js integration — lifecycle-managed page editor module.
*/
import EditorJS from '../../vendor/editorjs/editorjs.mjs';
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const form = document.querySelector('[data-page-editor]');
if (form) {
const holder = form.querySelector('[data-editor-holder]');
if (!holder) {
warnOnce('UI_EL_MISSING', 'Missing editor holder', { module: 'page-editor' });
} else {
const contentField = form.querySelector('#page-content');
if (!contentField) {
warnOnce('UI_EL_MISSING', 'Missing #page-content input', { module: 'page-editor' });
const parseData = (value) => {
if (!value) {
return { blocks: [] };
}
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === 'object') {
if (!Array.isArray(parsed.blocks)) {
parsed.blocks = [];
}
return parsed;
}
const toggleButton = document.querySelector('[data-editor-toggle]');
const saveButton = document.querySelector('[data-editor-save]');
const canEdit = form.dataset.canEdit === '1';
} catch {
// ignore invalid JSON
}
return { blocks: [] };
};
const parseData = (value) => {
if (!value) {
return { blocks: [] };
}
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === 'object') {
if (!Array.isArray(parsed.blocks)) {
parsed.blocks = [];
}
return parsed;
}
} catch (error) {
// ignore invalid JSON
}
return { blocks: [] };
const resolveTools = (form) => {
const HeaderTool = window.Header || window.EditorJSHeader || null;
const ListTool = window.EditorjsList || window.List || null;
const ChecklistTool = window.Checklist || window.EditorJSChecklist || null;
const TableTool = window.Table || window.EditorJSTable || null;
const ColumnsTool = window.editorjsColumns || window.Columns || null;
const MarkerTool = window.Marker || null;
const tools = {};
if (HeaderTool) {
tools.header = {
class: HeaderTool,
inlineToolbar: true,
config: {
placeholder: form.dataset.headerPlaceholder || 'Heading',
levels: [1, 2, 3, 4],
defaultLevel: 1,
},
};
const setToggleLabel = (mode) => {
if (!toggleButton) {
return;
}
const viewLabel = toggleButton.dataset.viewLabel || '';
const editLabel = toggleButton.dataset.editLabel || '';
const text = mode === 'edit' ? viewLabel : editLabel;
const icon = toggleButton.querySelector('[data-editor-icon]');
if (icon) {
icon.className = mode === 'edit' ? 'bi bi-eye-fill' : 'bi bi-pencil-square';
}
toggleButton.setAttribute('aria-label', text);
toggleButton.dataset.tooltip = text;
}
if (ListTool) {
tools.list = {
class: ListTool,
inlineToolbar: true,
config: {
defaultStyle: 'unordered',
},
};
}
if (ChecklistTool) {
tools.checklist = {
class: ChecklistTool,
inlineToolbar: true,
};
}
if (TableTool) {
tools.table = {
class: TableTool,
inlineToolbar: true,
config: {
rows: 2,
cols: 2,
},
};
}
if (ColumnsTool) {
const innerTools = { ...tools };
tools.columns = {
class: ColumnsTool,
config: {
EditorJsLibrary: EditorJS,
tools: innerTools,
},
};
}
if (MarkerTool) {
tools.marker = {
class: MarkerTool,
shortcut: 'CMD+SHIFT+M',
};
}
let mode = form.dataset.editMode === 'edit' && canEdit ? 'edit' : 'view';
return {
tools,
inlineTools: MarkerTool ? ['marker', 'bold', 'italic', 'link'] : ['bold', 'italic', 'link'],
};
};
export function initPageEditor(root = document, config = {}) {
const host = resolveHost(root);
const formSelector = String(config.selector || '[data-page-editor]').trim() || '[data-page-editor]';
const form = host.matches?.(formSelector) ? host : host.querySelector(formSelector);
if (!(form instanceof HTMLFormElement)) {
return { destroy: () => {} };
}
if (form.dataset.pageEditorBound === '1' && form._pageEditorApi) {
return form._pageEditorApi;
}
const holderSelector = String(config.holderSelector || '[data-editor-holder]').trim() || '[data-editor-holder]';
const contentFieldSelector = String(config.contentFieldSelector || '#page-content').trim() || '#page-content';
const controlsRootSelector = String(config.controlsRootSelector || '').trim();
const toggleSelector = String(config.toggleSelector || '[data-editor-toggle]').trim() || '[data-editor-toggle]';
const saveSelector = String(config.saveSelector || '[data-editor-save]').trim() || '[data-editor-save]';
const holder = form.querySelector(holderSelector);
if (!(holder instanceof HTMLElement)) {
warnOnce('UI_EL_MISSING', 'Missing editor holder', { module: 'page-editor' });
return { destroy: () => {} };
}
const contentField = form.querySelector(contentFieldSelector);
if (!(contentField instanceof HTMLTextAreaElement)) {
warnOnce('UI_EL_MISSING', `Missing page editor content field: ${contentFieldSelector}`, { module: 'page-editor' });
return { destroy: () => {} };
}
const controlsRoot = controlsRootSelector !== ''
? document.querySelector(controlsRootSelector)
: document;
const controlsHost = controlsRoot && typeof controlsRoot.querySelector === 'function'
? controlsRoot
: document;
const toggleButton = controlsHost.querySelector(toggleSelector);
const saveButton = controlsHost.querySelector(saveSelector);
const canEdit = form.dataset.canEdit === '1';
let mode = form.dataset.editMode === 'edit' && canEdit ? 'edit' : 'view';
form.dataset.editMode = mode;
form.dataset.pageEditorBound = '1';
const setToggleLabel = (nextMode) => {
if (!(toggleButton instanceof HTMLElement)) {
return;
}
const viewLabel = toggleButton.dataset.viewLabel || '';
const editLabel = toggleButton.dataset.editLabel || '';
const text = nextMode === 'edit' ? viewLabel : editLabel;
const icon = toggleButton.querySelector('[data-editor-icon]');
if (icon instanceof HTMLElement) {
icon.className = nextMode === 'edit' ? 'bi bi-eye-fill' : 'bi bi-pencil-square';
}
toggleButton.setAttribute('aria-label', text);
toggleButton.dataset.tooltip = text;
};
setToggleLabel(mode);
if (saveButton instanceof HTMLButtonElement) {
saveButton.disabled = mode !== 'edit';
}
const toolConfig = resolveTools(form);
const editor = new EditorJS({
holder,
data: parseData(contentField.value),
readOnly: mode !== 'edit',
autofocus: mode === 'edit',
placeholder: form.dataset.placeholder || undefined,
tools: toolConfig.tools,
inlineToolbar: toolConfig.inlineTools,
});
const toggleMode = async (nextMode) => {
if (!canEdit || mode === nextMode) {
return;
}
mode = nextMode;
form.dataset.editMode = mode;
setToggleLabel(mode);
if (editor.readOnly && typeof editor.readOnly.toggle === 'function') {
await editor.readOnly.toggle(mode !== 'edit');
}
if (saveButton instanceof HTMLButtonElement) {
saveButton.disabled = mode !== 'edit';
}
};
const HeaderTool = window.Header || window.EditorJSHeader || null;
const ListTool = window.EditorjsList || window.List || null;
const ChecklistTool = window.Checklist || window.EditorJSChecklist || null;
const TableTool = window.Table || window.EditorJSTable || null;
const ColumnsTool = window.editorjsColumns || window.Columns || null;
const MarkerTool = window.Marker || null;
const onToggleClick = () => {
void toggleMode(mode === 'edit' ? 'view' : 'edit');
};
const tools = {};
if (HeaderTool) {
tools.header = {
class: HeaderTool,
inlineToolbar: true,
config: {
placeholder: form.dataset.headerPlaceholder || 'Heading',
levels: [1, 2, 3, 4],
defaultLevel: 1,
const onSubmit = async (event) => {
if (form.dataset.submitting === '1') {
return;
}
event.preventDefault();
const output = await editor.save();
contentField.value = JSON.stringify(output);
form.dataset.submitting = '1';
try {
const formData = new FormData(form);
const response = await fetch(form.action || window.location.pathname, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
};
}
if (ListTool) {
tools.list = {
class: ListTool,
inlineToolbar: true,
config: {
defaultStyle: 'unordered',
},
};
}
if (ChecklistTool) {
tools.checklist = {
class: ChecklistTool,
inlineToolbar: true,
};
}
if (TableTool) {
tools.table = {
class: TableTool,
inlineToolbar: true,
config: {
rows: 2,
cols: 2,
},
};
}
if (ColumnsTool) {
const innerTools = { ...tools };
tools.columns = {
class: ColumnsTool,
config: {
EditorJsLibrary: EditorJS,
tools: innerTools,
},
};
}
if (MarkerTool) {
tools.marker = {
class: MarkerTool,
shortcut: 'CMD+SHIFT+M',
};
}
const inlineTools = ['bold', 'italic', 'link'];
if (MarkerTool) {
inlineTools.unshift('marker');
}
const editor = new EditorJS({
holder,
data: parseData(contentField ? contentField.value : ''),
readOnly: mode !== 'edit',
autofocus: mode === 'edit',
placeholder: form.dataset.placeholder || undefined,
tools,
inlineToolbar: inlineTools,
});
const toggleMode = async (nextMode) => {
if (!canEdit || mode === nextMode) {
body: formData,
});
if (response.redirected) {
window.location.href = response.url;
return;
}
mode = nextMode;
form.dataset.editMode = mode;
setToggleLabel(mode);
if (editor.readOnly && editor.readOnly.toggle) {
await editor.readOnly.toggle(mode !== 'edit');
const data = await response.json().catch(() => null);
if (data && data.redirect) {
window.location.href = data.redirect;
return;
}
if (saveButton) {
saveButton.disabled = mode !== 'edit';
}
};
if (toggleButton && canEdit) {
toggleButton.addEventListener('click', () => {
toggleMode(mode === 'edit' ? 'view' : 'edit');
});
window.location.reload();
} catch {
form.submit();
}
};
if (form && contentField && canEdit) {
form.addEventListener('submit', async (event) => {
if (form.dataset.submitting === '1') {
return;
}
event.preventDefault();
const output = await editor.save();
contentField.value = JSON.stringify(output);
form.dataset.submitting = '1';
try {
const formData = new FormData(form);
const response = await fetch(form.action || window.location.pathname, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: formData,
});
if (response.redirected) {
window.location.href = response.url;
return;
}
const data = await response.json().catch(() => null);
if (data && data.redirect) {
window.location.href = data.redirect;
return;
}
window.location.reload();
} catch (error) {
form.submit();
}
});
}
if (toggleButton instanceof HTMLElement && canEdit) {
toggleButton.addEventListener('click', onToggleClick);
}
if (canEdit) {
form.addEventListener('submit', onSubmit);
}
let destroyed = false;
const destroy = () => {
if (destroyed) {
return;
}
destroyed = true;
if (toggleButton instanceof HTMLElement && canEdit) {
toggleButton.removeEventListener('click', onToggleClick);
}
if (canEdit) {
form.removeEventListener('submit', onSubmit);
}
if (typeof editor.destroy === 'function') {
editor.destroy();
}
delete form.dataset.pageEditorBound;
delete form._pageEditorApi;
};
const api = { destroy };
form._pageEditorApi = api;
return api;
}

View File

@@ -1,7 +1,7 @@
/**
* Real-time password validation hints — checks length, uppercase, digit, special char.
*/
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const rules = {
min: (value, min) => value.length >= min,
@@ -19,9 +19,15 @@ const rules = {
}
};
export function initPasswordHints() {
const containers = document.querySelectorAll('[data-password-hints]');
if (!containers.length) {return;}
export function initPasswordHints(root = document, options = {}) {
const selector = String(options.selector || '[data-password-hints]').trim() || '[data-password-hints]';
const host = resolveHost(root);
const containers = Array.from(host.querySelectorAll(selector));
if (!containers.length) {
return { destroy: () => {} };
}
const cleanupFns = [];
containers.forEach((container) => {
if (!(container instanceof HTMLElement)) {return;}
@@ -30,9 +36,10 @@ export function initPasswordHints() {
const passwordSelector = container.dataset.passwordInput;
const confirmSelector = container.dataset.confirmInput;
const emailSelector = container.dataset.emailInput;
const passwordInput = passwordSelector ? document.querySelector(passwordSelector) : null;
const confirmInput = confirmSelector ? document.querySelector(confirmSelector) : null;
const emailInput = emailSelector ? document.querySelector(emailSelector) : null;
const scope = document;
const passwordInput = passwordSelector ? scope.querySelector(passwordSelector) : null;
const confirmInput = confirmSelector ? scope.querySelector(confirmSelector) : null;
const emailInput = emailSelector ? scope.querySelector(emailSelector) : null;
if (passwordSelector && !passwordInput) {
warnOnce('UI_EL_MISSING', `Missing password input: ${passwordSelector}`, { module: 'password-hints' });
}
@@ -79,16 +86,28 @@ export function initPasswordHints() {
});
};
if (passwordInput) {passwordInput.addEventListener('input', evaluate);}
if (confirmInput) {confirmInput.addEventListener('input', evaluate);}
if (emailInput) {emailInput.addEventListener('input', evaluate);}
if (passwordInput) {
passwordInput.addEventListener('input', evaluate);
cleanupFns.push(() => passwordInput.removeEventListener('input', evaluate));
}
if (confirmInput) {
confirmInput.addEventListener('input', evaluate);
cleanupFns.push(() => confirmInput.removeEventListener('input', evaluate));
}
if (emailInput) {
emailInput.addEventListener('input', evaluate);
cleanupFns.push(() => emailInput.removeEventListener('input', evaluate));
}
evaluate();
container.dataset.passwordHintsBound = '1';
cleanupFns.push(() => {
delete container.dataset.passwordHintsBound;
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPasswordHints);
} else {
initPasswordHints();
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
};
return { destroy };
}

View File

@@ -1,13 +1,23 @@
/**
* Show/hide password toggle button injected next to password inputs.
*/
export function initPasswordToggles() {
const inputs = document.querySelectorAll('input[type="password"]');
if (!inputs.length) {return;}
import { resolveHost } from '../core/app-dom.js';
export function initPasswordToggles(root = document, options = {}) {
const selector = String(options.selector || 'input[type="password"]').trim() || 'input[type="password"]';
const host = resolveHost(root);
const inputs = Array.from(host.querySelectorAll(selector));
if (!inputs.length) {
return { destroy: () => {} };
}
const showLabel = (document.documentElement.dataset.passwordToggleShowLabel || '').trim();
const hideLabel = (document.documentElement.dataset.passwordToggleHideLabel || '').trim();
if (!showLabel || !hideLabel) {return;}
if (!showLabel || !hideLabel) {
return { destroy: () => {} };
}
const cleanupFns = [];
inputs.forEach((input) => {
if (!(input instanceof HTMLInputElement)) {return;}
@@ -23,6 +33,10 @@ export function initPasswordToggles() {
input.dataset.passwordToggleBound = '1';
existingToggle.dataset.passwordToggleBound = '1';
existingToggle.setAttribute('aria-label', input.type === 'password' ? showLabel : hideLabel);
cleanupFns.push(() => {
delete input.dataset.passwordToggleBound;
delete existingToggle.dataset.passwordToggleBound;
});
return;
}
}
@@ -46,18 +60,24 @@ export function initPasswordToggles() {
input.dataset.passwordToggleBound = '1';
toggle.dataset.passwordToggleBound = '1';
toggle.addEventListener('click', () => {
const onToggleClick = () => {
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
icon.className = isPassword ? 'bi bi-eye-slash' : 'bi bi-eye';
toggle.setAttribute('aria-label', isPassword ? hideLabel : showLabel);
input.focus();
};
toggle.addEventListener('click', onToggleClick);
cleanupFns.push(() => {
toggle.removeEventListener('click', onToggleClick);
delete input.dataset.passwordToggleBound;
delete toggle.dataset.passwordToggleBound;
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPasswordToggles);
} else {
initPasswordToggles();
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
};
return { destroy };
}

View File

@@ -1,51 +1,67 @@
/**
* Session expiry warning dialog.
*
* Reads the idle timeout from the <html> element's data-session-idle-seconds
* attribute and shows a modal dialog ~2 minutes before the session expires.
* The user can extend the session (POST /admin/session-ping/data) or log out.
*
* Timer resets on user interaction (click, keypress, scroll) to stay in sync
* with the server-side idle timer that is refreshed on every page request.
*/
import { warnOnce, resolveHost } from '../core/app-dom.js';
const WARNING_LEAD_SECONDS = 120;
const TICK_INTERVAL_MS = 1000;
const MIN_IDLE_TIMEOUT = 180; // Don't show warning if timeout < 3 min
const KEEPALIVE_INTERVAL_MS = 300_000; // Sync with server every 5 min (if active)
const INTERACTION_EVENTS = ['click', 'keydown', 'scroll', 'mousemove', 'touchstart'];
const INTERACTION_THROTTLE_MS = 30_000; // Only reset every 30s to limit overhead
/**
* Read configuration from DOM.
* @returns {{idleSeconds: number, pingUrl: string, logoutUrl: string, csrfKey: string, csrfToken: string}|null}
*/
const readConfig = () => {
const root = document.documentElement;
const idleSeconds = parseInt(root.dataset.sessionIdleSeconds || '', 10);
if (!idleSeconds || idleSeconds < MIN_IDLE_TIMEOUT) {
return null;
const toPositiveInt = (value, fallback = 0) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
const pingUrl = root.dataset.sessionPingUrl || '';
const logoutUrl = root.dataset.sessionLogoutUrl || '';
const csrfKey = root.dataset.csrfKey || '';
const csrfToken = root.dataset.csrfToken || '';
if (!pingUrl || !logoutUrl || !csrfKey || !csrfToken) {
return null;
}
return { idleSeconds, pingUrl, logoutUrl, csrfKey, csrfToken };
return parsed;
};
/**
* Resolve the session warning dialog and its elements.
* @returns {object|null}
*/
const resolveDialog = () => {
const dialog = document.querySelector('[data-app-session-warning-dialog]');
const toTrimmed = (value) => String(value ?? '').trim();
const resolveConfig = (runtimeConfig = {}) => {
const root = document.documentElement;
const config = runtimeConfig && typeof runtimeConfig === 'object' && !Array.isArray(runtimeConfig)
? runtimeConfig
: {};
const enabled = config.enabled !== false;
if (!enabled) {
return null;
}
const idleSeconds = toPositiveInt(config.idleSeconds ?? root.dataset.sessionIdleSeconds, 0);
const pingUrl = toTrimmed(config.pingUrl ?? root.dataset.sessionPingUrl);
const logoutUrl = toTrimmed(config.logoutUrl ?? root.dataset.sessionLogoutUrl);
const csrfKey = toTrimmed(config.csrfKey ?? root.dataset.csrfKey);
const csrfToken = toTrimmed(config.csrfToken ?? root.dataset.csrfToken);
const selector = toTrimmed(config.selector || '[data-app-session-warning-dialog]') || '[data-app-session-warning-dialog]';
if (idleSeconds < MIN_IDLE_TIMEOUT) {
return null;
}
if (!pingUrl || !logoutUrl || !csrfKey || !csrfToken) {
warnOnce('UI_CONFIG_INVALID', 'Session warning config is incomplete', {
module: 'session-warning',
});
return null;
}
return {
idleSeconds,
pingUrl,
logoutUrl,
csrfKey,
csrfToken,
selector,
};
};
const resolveDialog = (host, selector) => {
const dialog = host instanceof HTMLDialogElement && host.matches(selector)
? host
: host.querySelector(selector);
if (!(dialog instanceof HTMLDialogElement)) {
return null;
}
@@ -63,11 +79,6 @@ const resolveDialog = () => {
return { dialog, messageEl, extendButton, logoutButton };
};
/**
* Format remaining seconds into a human-readable countdown string.
* @param {number} seconds
* @returns {string}
*/
const formatCountdown = (seconds) => {
if (seconds <= 0) {
return '0:00';
@@ -77,30 +88,40 @@ const formatCountdown = (seconds) => {
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
/**
* Initialize the session warning system.
*/
const init = () => {
const config = readConfig();
export function initSessionWarning(root = document, runtimeConfig = {}) {
const host = resolveHost(root);
const config = resolveConfig(runtimeConfig);
if (!config) {
return;
return { destroy: () => {} };
}
const elements = resolveDialog();
const elements = resolveDialog(host, config.selector);
if (!elements) {
return;
warnOnce('UI_EL_MISSING', `Missing session warning dialog: ${config.selector}`, {
module: 'session-warning',
});
return { destroy: () => {} };
}
const { dialog, messageEl, extendButton, logoutButton } = elements;
const messageTemplate = messageEl.dataset.template || '';
if (dialog.dataset.sessionWarningBound === '1' && dialog._sessionWarningApi) {
return dialog._sessionWarningApi;
}
const messageTemplate = messageEl.dataset.template || '';
const cleanupFns = [];
const bind = (target, eventName, handler, options = undefined) => {
target.addEventListener(eventName, handler, options);
cleanupFns.push(() => target.removeEventListener(eventName, handler, options));
};
let lastActivity = Date.now();
let warningTimerId = 0;
let countdownTimerId = 0;
let keepaliveTimerId = 0;
let remainingSeconds = 0;
let isDialogOpen = false;
// ── Countdown ──────────────────────────────────────────────
let lastInteractionReset = Date.now();
let hasInteractionSinceLastSync = false;
const updateCountdownDisplay = () => {
const text = messageTemplate
@@ -110,10 +131,19 @@ const init = () => {
};
const stopCountdown = () => {
if (countdownTimerId) {
clearInterval(countdownTimerId);
countdownTimerId = 0;
if (!countdownTimerId) {
return;
}
clearInterval(countdownTimerId);
countdownTimerId = 0;
};
const clearWarningTimer = () => {
if (!warningTimerId) {
return;
}
clearTimeout(warningTimerId);
warningTimerId = 0;
};
const closeDialog = () => {
@@ -141,29 +171,11 @@ const init = () => {
if (remainingSeconds <= 0) {
stopCountdown();
closeDialog();
// Session expired — reload triggers the server-side timeout flow.
window.location.reload();
}
}, TICK_INTERVAL_MS);
};
// ── Warning timer ──────────────────────────────────────────
const scheduleWarning = () => {
if (warningTimerId) {
clearTimeout(warningTimerId);
}
const delayMs = (config.idleSeconds - WARNING_LEAD_SECONDS) * 1000;
if (delayMs <= 0) {
return;
}
warningTimerId = setTimeout(() => {
showWarningDialog();
}, delayMs);
};
const showWarningDialog = () => {
if (isDialogOpen) {
return;
@@ -184,7 +196,16 @@ const init = () => {
extendButton.focus();
};
// ── Extend session (AJAX ping) ─────────────────────────────
const scheduleWarning = () => {
clearWarningTimer();
const delayMs = (config.idleSeconds - WARNING_LEAD_SECONDS) * 1000;
if (delayMs <= 0) {
return;
}
warningTimerId = setTimeout(() => {
showWarningDialog();
}, delayMs);
};
const extendSession = async () => {
extendButton.disabled = true;
@@ -203,63 +224,43 @@ const init = () => {
});
if (!response.ok) {
// Session already expired — reload.
window.location.reload();
return;
}
const data = await response.json();
// Server may return an updated idle timeout.
if (typeof data.idle_timeout_seconds === 'number' && data.idle_timeout_seconds > 0) {
config.idleSeconds = data.idle_timeout_seconds;
}
lastActivity = Date.now();
closeDialog();
scheduleWarning();
} catch {
// Network error — reload to let server handle it.
window.location.reload();
} finally {
extendButton.disabled = false;
}
};
// ── Logout ─────────────────────────────────────────────────
const doLogout = () => {
closeDialog();
window.location.href = config.logoutUrl;
};
// ── User interaction tracking ──────────────────────────────
let lastInteractionReset = Date.now();
let hasInteractionSinceLastSync = false;
const onUserInteraction = () => {
const now = Date.now();
if ((now - lastInteractionReset) < INTERACTION_THROTTLE_MS) {
return;
}
lastInteractionReset = now;
// Only reset timer when dialog is NOT showing.
if (!isDialogOpen) {
lastActivity = now;
hasInteractionSinceLastSync = true;
scheduleWarning();
if (isDialogOpen) {
return;
}
hasInteractionSinceLastSync = true;
scheduleWarning();
};
// ── Background keepalive ────────────────────────────────────
// Periodically syncs the server-side idle timer when user interaction
// was detected since the last sync. Without this, a user who stays on
// a single page (scrolling, reading, moving the mouse) would have
// their server-side timer expire even though the frontend shows "active".
const keepaliveTimerId = setInterval(async () => {
const onKeepalive = async () => {
if (!hasInteractionSinceLastSync || isDialogOpen) {
return;
}
@@ -272,7 +273,9 @@ const init = () => {
const response = await fetch(config.pingUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
body,
});
@@ -285,15 +288,12 @@ const init = () => {
config.idleSeconds = data.idle_timeout_seconds;
}
} catch {
// Network error — silently ignore, warning dialog will handle expiry.
// Network error — warning dialog still handles timeout/expiry.
}
}, KEEPALIVE_INTERVAL_MS);
// ── Focus trap (keyboard) ──────────────────────────────────
};
const onKeyDown = (event) => {
if (event.key === 'Escape') {
// Prevent default ESC close — user must choose extend or logout.
event.preventDefault();
return;
}
@@ -302,9 +302,7 @@ const init = () => {
return;
}
const focusable = [extendButton, logoutButton].filter(
(el) => !el.disabled && !el.hidden
);
const focusable = [extendButton, logoutButton].filter((element) => !element.disabled && !element.hidden);
if (!focusable.length) {
event.preventDefault();
return;
@@ -319,52 +317,60 @@ const init = () => {
event.preventDefault();
last.focus();
}
} else if (active === last) {
return;
}
if (active === last) {
event.preventDefault();
first.focus();
}
};
// Prevent closing via backdrop click
const onBackdropClick = (event) => {
if (event.target === dialog) {
event.preventDefault();
}
};
// Prevent native cancel (ESC)
const onCancel = (event) => {
event.preventDefault();
};
// ── Wire up events ─────────────────────────────────────────
extendButton.addEventListener('click', (e) => {
e.preventDefault();
bind(extendButton, 'click', (event) => {
event.preventDefault();
extendSession();
});
logoutButton.addEventListener('click', (e) => {
e.preventDefault();
bind(logoutButton, 'click', (event) => {
event.preventDefault();
doLogout();
});
dialog.addEventListener('keydown', onKeyDown);
dialog.addEventListener('click', onBackdropClick);
dialog.addEventListener('cancel', onCancel);
bind(dialog, 'keydown', onKeyDown);
bind(dialog, 'click', onBackdropClick);
bind(dialog, 'cancel', onCancel);
for (const eventName of INTERACTION_EVENTS) {
document.addEventListener(eventName, onUserInteraction, { passive: true });
bind(document, eventName, onUserInteraction, { passive: true });
}
// ── Start ──────────────────────────────────────────────────
keepaliveTimerId = setInterval(onKeepalive, KEEPALIVE_INTERVAL_MS);
scheduleWarning();
};
// Auto-init when module loads.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns.length = 0;
clearWarningTimer();
stopCountdown();
if (keepaliveTimerId) {
clearInterval(keepaliveTimerId);
keepaliveTimerId = 0;
}
closeDialog();
delete dialog.dataset.sessionWarningBound;
delete dialog._sessionWarningApi;
};
const api = { destroy };
dialog.dataset.sessionWarningBound = '1';
dialog._sessionWarningApi = api;
return api;
}

View File

@@ -1,14 +1,21 @@
/**
* Tracks settings page interactions for telemetry.
*/
const initTelemetrySettings = () => {
const toggle = document.querySelector('[data-telemetry-enabled-toggle]');
const samplingFieldset = document.querySelector('[data-telemetry-sampling-fieldset]');
const samplingRow = document.querySelector('[data-telemetry-sampling-row]');
const samplingSelect = document.querySelector('[data-telemetry-sampling-select]');
import { resolveHost } from '../core/app-dom.js';
export const initTelemetrySettings = (root = document, options = {}) => {
const host = resolveHost(root);
const toggleSelector = String(options.toggleSelector || '[data-telemetry-enabled-toggle]').trim() || '[data-telemetry-enabled-toggle]';
const samplingFieldsetSelector = String(options.samplingFieldsetSelector || '[data-telemetry-sampling-fieldset]').trim() || '[data-telemetry-sampling-fieldset]';
const samplingRowSelector = String(options.samplingRowSelector || '[data-telemetry-sampling-row]').trim() || '[data-telemetry-sampling-row]';
const samplingSelectSelector = String(options.samplingSelectSelector || '[data-telemetry-sampling-select]').trim() || '[data-telemetry-sampling-select]';
const toggle = host.querySelector(toggleSelector);
const samplingFieldset = host.querySelector(samplingFieldsetSelector);
const samplingRow = host.querySelector(samplingRowSelector);
const samplingSelect = host.querySelector(samplingSelectSelector);
if (!toggle || !samplingFieldset) {
return;
return { destroy: () => {} };
}
const syncSamplingVisibility = () => {
@@ -24,10 +31,9 @@ const initTelemetrySettings = () => {
toggle.addEventListener('change', syncSamplingVisibility);
syncSamplingVisibility();
return {
destroy: () => {
toggle.removeEventListener('change', syncSamplingVisibility);
},
};
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTelemetrySettings);
} else {
initTelemetrySettings();
}

View File

@@ -4,11 +4,14 @@
import { autoInitStandardDetailPages } from '../core/app-detail-page-factory.js';
export function initStandardDetailPages(root = document) {
return autoInitStandardDetailPages(root);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initStandardDetailPages());
} else {
initStandardDetailPages();
const apis = autoInitStandardDetailPages(root);
return {
destroy: () => {
apis.forEach((api) => {
if (api && typeof api.destroy === 'function') {
api.destroy();
}
});
},
};
}

View File

@@ -2,7 +2,7 @@
* Initializes tab navigation with chevron-based overflow scrolling.
*
* Markup (author writes):
* <div data-tabs>
* <div data-app-component="tabs" data-tabs>
* <div class="app-tabs-nav">
* <button data-tab="system" data-tab-default>System</button>
* ...
@@ -13,106 +13,164 @@
* JS wraps the nav in `.app-tabs-nav-wrap` and injects chevron buttons when
* the strip overflows. No-JS fallback: native horizontal scrollbar stays.
*/
export function initTabs(root = document) {
const containers = root.querySelectorAll('[data-tabs]');
import { createUiStorage } from '../core/app-ui-storage.js';
import { resolveHost } from '../core/app-dom.js';
import { belongsToForm } from '../core/app-form-utils.js';
const SCROLL_STEP = 160; // px per click
const SCROLL_THRESHOLD = 2; // tolerance for "at edge" detection
const resolveContainers = (host, selector) => {
const containers = [];
if (host instanceof HTMLElement && host.matches(selector)) {
containers.push(host);
}
containers.push(...Array.from(host.querySelectorAll(selector)));
return containers;
};
export function initTabs(root = document, config = {}) {
const host = resolveHost(root);
const selector = String(config.selector || '[data-app-component="tabs"]').trim() || '[data-app-component="tabs"]';
const storage = createUiStorage({
namespace: config.storageNamespace || 'app-ui',
version: config.storageVersion || 'v1',
scope: 'tabs',
});
const containers = resolveContainers(host, selector);
const instances = [];
containers.forEach((container) => {
if (container.dataset.tabsBound === '1') {return;}
if (!(container instanceof HTMLElement)) {
return;
}
if (container.dataset.tabsBound === '1' && container._tabsApi) {
instances.push(container._tabsApi);
return;
}
const cleanupFns = [];
const addCleanup = (cleanup) => {
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
};
container.dataset.tabsBound = '1';
container.dataset.tabsReady = '0';
const cleanupFns = [];
const tabs = Array.from(container.querySelectorAll('[data-tab]'));
const panels = Array.from(container.querySelectorAll('[data-tab-panel]'));
if (!tabs.length || !panels.length) {return;}
if (!tabs.length || !panels.length) {
const api = {
destroy: () => {
delete container.dataset.tabsBound;
delete container.dataset.tabsReady;
delete container._tabsApi;
},
};
container._tabsApi = api;
instances.push(api);
return;
}
const storageKey = container.dataset.tabsStorageKey || (container.id ? `tabs:${container.id}` : null);
const urlParamName = container.dataset.tabsParam || 'tab';
const storageKey = String(container.dataset.tabsStorageKey || (container.id ? `tabs:${container.id}` : '')).trim();
const scopedStorageKey = storage.buildKey('state', storageKey);
const urlParamName = String(container.dataset.tabsParam || config.urlParam || 'tab').trim() || 'tab';
// Helper to get URL parameter
const getUrlParam = (name) => {
const params = new URLSearchParams(window.location.search);
return params.get(name);
};
// Helper to set URL parameter
const setUrlParam = (name, value) => {
const url = new URL(window.location);
const url = new URL(window.location.href);
url.searchParams.set(name, value);
window.history.replaceState({}, '', url);
};
// Get initial active tab from URL param, localStorage, or data-tab-default
const readStoredTab = () => {
if (storageKey === '' || container.hasAttribute('data-tabs-ignore-storage')) {
return '';
}
const scopedValue = scopedStorageKey ? storage.getItem(scopedStorageKey) : null;
if (typeof scopedValue === 'string' && scopedValue.trim() !== '') {
return scopedValue.trim();
}
return '';
};
const persistTab = (tabName) => {
if (storageKey === '' || container.hasAttribute('data-tabs-ignore-storage')) {
return;
}
if (scopedStorageKey) {
storage.setItem(scopedStorageKey, tabName);
}
};
let activeTab = null;
const urlTab = getUrlParam(urlParamName);
if (urlTab && panels.some(p => p.dataset.tabPanel === urlTab)) {
if (urlTab && panels.some((panel) => panel.dataset.tabPanel === urlTab)) {
activeTab = urlTab;
} else if (storageKey && !container.hasAttribute('data-tabs-ignore-storage')) {
try {
const stored = localStorage.getItem(storageKey);
if (stored && panels.some(p => p.dataset.tabPanel === stored)) {
activeTab = stored;
}
} catch (e) {
// localStorage not available
} else {
const storedTab = readStoredTab();
if (storedTab && panels.some((panel) => panel.dataset.tabPanel === storedTab)) {
activeTab = storedTab;
}
}
if (!activeTab) {
const defaultTab = container.querySelector('[data-tab-default]');
activeTab = defaultTab?.dataset.tab || tabs[0]?.dataset.tab;
activeTab = defaultTab?.dataset.tab || tabs[0]?.dataset.tab || null;
}
const nav = container.querySelector('.app-tabs-nav');
const tabIdPrefix = container.id || `tabs-${Math.random().toString(36).slice(2, 8)}`;
const scrollTabIntoView = (tabEl) => {
if (!nav || !(tabEl instanceof HTMLElement)) {
return;
}
tabEl.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
};
const activateTab = (tabName) => {
tabs.forEach(tab => {
tabs.forEach((tab) => {
const isActive = tab.dataset.tab === tabName;
tab.classList.toggle('is-active', isActive);
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
tab.setAttribute('tabindex', isActive ? '0' : '-1');
});
panels.forEach(panel => {
panels.forEach((panel) => {
const isVisible = panel.dataset.tabPanel === tabName;
panel.hidden = !isVisible;
panel.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
});
// Update URL parameter
setUrlParam(urlParamName, tabName);
// Save to localStorage
if (storageKey) {
try {
localStorage.setItem(storageKey, tabName);
} catch (e) {
// localStorage not available
}
}
persistTab(tabName);
};
// ----------------------------------------------------------------
// ARIA: role, aria-controls, aria-labelledby (WAI-ARIA Tabs pattern)
// ----------------------------------------------------------------
const nav = container.querySelector('.app-tabs-nav');
const tabIdPrefix = container.id || `tabs-${Math.random().toString(36).slice(2, 8)}`;
tabs.forEach(tab => {
tabs.forEach((tab) => {
const tabName = tab.dataset.tab;
const tabId = `${tabIdPrefix}-tab-${tabName}`;
const panelId = `${tabIdPrefix}-panel-${tabName}`;
tab.setAttribute('role', 'tab');
tab.id = tabId;
tab.setAttribute('aria-controls', panelId);
tab.addEventListener('click', (e) => {
e.preventDefault();
const onClick = (event) => {
event.preventDefault();
activateTab(tab.dataset.tab);
// Scroll the active tab into view within the nav strip
scrollTabIntoView(tab);
});
};
tab.addEventListener('click', onClick);
addCleanup(() => tab.removeEventListener('click', onClick));
});
panels.forEach(panel => {
panels.forEach((panel) => {
const panelName = panel.dataset.tabPanel;
const panelId = `${tabIdPrefix}-panel-${panelName}`;
const tabId = `${tabIdPrefix}-tab-${panelName}`;
@@ -123,152 +181,135 @@ export function initTabs(root = document) {
if (nav) {
nav.setAttribute('role', 'tablist');
}
// ----------------------------------------------------------------
// Arrow-key navigation (WAI-ARIA Tabs pattern)
// ----------------------------------------------------------------
if (nav) {
nav.addEventListener('keydown', (e) => {
const currentIndex = tabs.indexOf(e.target);
if (currentIndex === -1) {return;}
const onNavKeyDown = (event) => {
const currentIndex = tabs.indexOf(event.target);
if (currentIndex === -1) {
return;
}
let nextIndex = -1;
if (e.key === 'ArrowRight') {
if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length;
} else if (e.key === 'ArrowLeft') {
} else if (event.key === 'ArrowLeft') {
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
} else if (event.key === 'Home') {
nextIndex = 0;
} else if (e.key === 'End') {
} else if (event.key === 'End') {
nextIndex = tabs.length - 1;
}
if (nextIndex >= 0) {
e.preventDefault();
event.preventDefault();
tabs[nextIndex].focus();
activateTab(tabs[nextIndex].dataset.tab);
scrollTabIntoView(tabs[nextIndex]);
}
});
};
nav.addEventListener('keydown', onNavKeyDown);
addCleanup(() => nav.removeEventListener('keydown', onNavKeyDown));
const destroyChevron = initChevronScroll(nav);
addCleanup(destroyChevron);
}
// Activate initial tab
if (activeTab) {
activateTab(activeTab);
}
container.dataset.tabsReady = '1';
// ----------------------------------------------------------------
// Chevron overflow scrolling
// ----------------------------------------------------------------
/** @param {HTMLElement} tabEl */
function scrollTabIntoView(tabEl) {
if (!nav) {return;}
tabEl.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
if (nav) {
initChevronScroll(nav);
// Once the first tab is activated, ensure it's visible
const activeBtn = nav.querySelector('.is-active');
if (activeBtn) {
// Defer so layout has settled
requestAnimationFrame(() => scrollTabIntoView(activeBtn));
}
}
// --- Form validation awareness ---
const form = container.closest('form');
if (form) {
if (form instanceof HTMLFormElement) {
const inputSelector = 'input,select,textarea';
const formId = form.id;
const belongsToForm = (field) => {
const f = field.getAttribute('form');
if (!f) return true; // kein form-Attr → gehört zum Ancestor-Form
if (!formId) return false; // Feld hat form-Attr, unser Form hat keine ID
return f === formId;
};
const clearInvalidMarkers = () => {
tabs.forEach(tab => tab.classList.remove('has-invalid'));
tabs.forEach((tab) => tab.classList.remove('has-invalid'));
};
const findInvalidPanels = () => {
clearInvalidMarkers();
let firstInvalidTab = null;
panels.forEach(panel => {
panels.forEach((panel) => {
const fields = panel.querySelectorAll(inputSelector);
const hasInvalid = Array.from(fields).some(f => belongsToForm(f) && !f.disabled && !f.checkValidity());
if (hasInvalid) {
const tabName = panel.dataset.tabPanel;
const tab = tabs.find(t => t.dataset.tab === tabName);
if (tab) {
tab.classList.add('has-invalid');
}
if (!firstInvalidTab) {
firstInvalidTab = tabName;
}
const hasInvalid = Array.from(fields).some(
(field) => belongsToForm(field, form) && !field.disabled && !field.checkValidity()
);
if (!hasInvalid) {
return;
}
const tabName = panel.dataset.tabPanel;
const tab = tabs.find((candidate) => candidate.dataset.tab === tabName);
if (tab) {
tab.classList.add('has-invalid');
}
if (!firstInvalidTab) {
firstInvalidTab = tabName;
}
});
return firstInvalidTab;
};
// Intercept submit buttons — both inside the form and external ones linked via form="id".
// We use document-level click (capture) so we catch ALL submit triggers before native validation.
const onFormClickCapture = (e) => {
const btn = e.target.closest('button, [type="submit"]');
if (!btn) {return;}
const onFormClickCapture = (event) => {
const trigger = event.target.closest('button, [type="submit"]');
if (!trigger) {
return;
}
// Check if this button submits OUR form
const btnFormAttr = btn.getAttribute('form');
const isInternalSubmit = !btnFormAttr && form.contains(btn) && (btn.type === 'submit' || (!btn.type && btn.tagName === 'BUTTON'));
const isExternalSubmit = btnFormAttr && formId && btnFormAttr === formId && btn.type === 'submit';
if (!isInternalSubmit && !isExternalSubmit) {return;}
const triggerFormAttr = trigger.getAttribute('form');
const isInternalSubmit = !triggerFormAttr
&& form.contains(trigger)
&& (trigger.type === 'submit' || (!trigger.type && trigger.tagName === 'BUTTON'));
const isExternalSubmit = Boolean(triggerFormAttr) && Boolean(formId) && triggerFormAttr === formId && trigger.type === 'submit';
if (!isInternalSubmit && !isExternalSubmit) {
return;
}
const firstInvalidTab = findInvalidPanels();
if (!firstInvalidTab) {return;}
if (!firstInvalidTab) {
return;
}
// Active panel already shows the first invalid tab — let native validation handle it
const activePanel = panels.find(p => !p.hidden);
const activePanel = panels.find((panel) => !panel.hidden);
const needsTabSwitch = activePanel?.dataset.tabPanel !== firstInvalidTab;
// Open any closed <details> ancestors of invalid fields so the browser can focus them
const invalidFields = form.querySelectorAll(inputSelector);
for (const field of invalidFields) {
if (!belongsToForm(field) || field.disabled || field.checkValidity()) {continue;}
let el = field.parentElement;
while (el && el !== form) {
if (el.tagName === 'DETAILS' && !el.open) {
el.open = true;
if (!belongsToForm(field) || field.disabled || field.checkValidity()) {
continue;
}
let element = field.parentElement;
while (element && element !== form) {
if (element.tagName === 'DETAILS' && !element.open) {
element.open = true;
}
el = el.parentElement;
element = element.parentElement;
}
}
if (!needsTabSwitch) {return;}
if (!needsTabSwitch) {
return;
}
// Prevent native submit so the browser doesn't try to focus a hidden field
e.preventDefault();
event.preventDefault();
activateTab(firstInvalidTab);
requestAnimationFrame(() => {
form.reportValidity();
});
};
const onFormInput = (e) => {
const panel = e.target.closest('[data-tab-panel]');
if (!panel) {return;}
const onFormInput = (event) => {
const panel = event.target.closest('[data-tab-panel]');
if (!panel) {
return;
}
const tabName = panel.dataset.tabPanel;
const tab = tabs.find(t => t.dataset.tab === tabName);
if (!tab) {return;}
const tab = tabs.find((candidate) => candidate.dataset.tab === tabName);
if (!tab) {
return;
}
const fields = panel.querySelectorAll(inputSelector);
if (Array.from(fields).every(f => !belongsToForm(f) || f.disabled || f.checkValidity())) {
if (Array.from(fields).every((field) => !belongsToForm(field) || field.disabled || field.checkValidity())) {
tab.classList.remove('has-invalid');
}
};
@@ -276,124 +317,148 @@ export function initTabs(root = document) {
document.addEventListener('click', onFormClickCapture, true);
form.addEventListener('input', onFormInput);
form.addEventListener('change', onFormInput);
cleanupFns.push(() => {
addCleanup(() => {
document.removeEventListener('click', onFormClickCapture, true);
form.removeEventListener('input', onFormInput);
form.removeEventListener('change', onFormInput);
});
}
// Listen for popstate (back/forward navigation)
const onPopState = () => {
const newTab = getUrlParam(urlParamName);
if (newTab && panels.some(p => p.dataset.tabPanel === newTab)) {
activateTab(newTab);
const nextTab = getUrlParam(urlParamName);
if (nextTab && panels.some((panel) => panel.dataset.tabPanel === nextTab)) {
activateTab(nextTab);
}
};
window.addEventListener('popstate', onPopState);
cleanupFns.push(() => window.removeEventListener('popstate', onPopState));
addCleanup(() => window.removeEventListener('popstate', onPopState));
const destroy = () => {
cleanupFns.forEach((fn) => fn());
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns.length = 0;
delete container.dataset.tabsBound;
delete container.dataset.tabsReady;
delete container._tabsApi;
};
container._tabsApi = { activateTab, destroy };
instances.push(container._tabsApi);
const api = { activateTab, destroy };
container._tabsApi = api;
instances.push(api);
});
return instances;
return {
instances,
destroy: () => {
instances.forEach((instance) => {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
},
};
}
// ------------------------------------------------------------------
// Chevron-scroll helper — wraps .app-tabs-nav in .app-tabs-nav-wrap
// and injects / buttons that appear only when the strip overflows.
// ------------------------------------------------------------------
const SCROLL_STEP = 160; // px per click
const SCROLL_THRESHOLD = 2; // tolerance for "at edge" detection
/** @param {HTMLElement} nav The .app-tabs-nav element */
function initChevronScroll(nav) {
// Wrap: nav → wrap > [chevronStart, nav, chevronEnd]
if (!(nav instanceof HTMLElement) || !nav.parentNode) {
return () => {};
}
const wrap = document.createElement('div');
wrap.className = 'app-tabs-nav-wrap';
nav.parentNode.insertBefore(wrap, nav);
wrap.appendChild(nav);
// Create chevron buttons
const btnStart = createChevronBtn('start', 'bi-chevron-left');
const btnEnd = createChevronBtn('end', 'bi-chevron-right');
wrap.appendChild(btnStart);
wrap.appendChild(btnEnd);
// Scroll handlers
btnStart.addEventListener('click', () => {
nav.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' });
});
btnEnd.addEventListener('click', () => {
nav.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' });
});
const cleanupFns = [];
const bind = (target, eventName, handler, options = undefined) => {
target.addEventListener(eventName, handler, options);
cleanupFns.push(() => target.removeEventListener(eventName, handler, options));
};
// Continuous scroll on long-press
addLongPress(btnStart, () => nav.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' }));
addLongPress(btnEnd, () => nav.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' }));
const scrollStart = () => nav.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' });
const scrollEnd = () => nav.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' });
bind(btnStart, 'click', scrollStart);
bind(btnEnd, 'click', scrollEnd);
const destroyLongPressStart = addLongPress(btnStart, scrollStart);
const destroyLongPressEnd = addLongPress(btnEnd, scrollEnd);
cleanupFns.push(destroyLongPressStart, destroyLongPressEnd);
// Observe overflow
const update = () => {
const hasOverflow = nav.scrollWidth > nav.clientWidth + SCROLL_THRESHOLD;
wrap.classList.toggle('has-overflow', hasOverflow);
if (hasOverflow) {
const atStart = nav.scrollLeft <= SCROLL_THRESHOLD;
const atEnd = nav.scrollLeft + nav.clientWidth >= nav.scrollWidth - SCROLL_THRESHOLD;
btnStart.disabled = atStart;
btnEnd.disabled = atEnd;
if (!hasOverflow) {
btnStart.disabled = true;
btnEnd.disabled = true;
return;
}
const atStart = nav.scrollLeft <= SCROLL_THRESHOLD;
const atEnd = nav.scrollLeft + nav.clientWidth >= nav.scrollWidth - SCROLL_THRESHOLD;
btnStart.disabled = atStart;
btnEnd.disabled = atEnd;
};
nav.addEventListener('scroll', update, { passive: true });
// ResizeObserver to detect container width changes
bind(nav, 'scroll', update, { passive: true });
let resizeObserver = null;
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(update);
ro.observe(nav);
resizeObserver = new ResizeObserver(update);
resizeObserver.observe(nav);
}
// Initial check (deferred so layout is settled)
requestAnimationFrame(update);
return () => {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns.length = 0;
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (wrap.parentNode) {
wrap.parentNode.insertBefore(nav, wrap);
wrap.remove();
}
};
}
/** @returns {HTMLButtonElement} */
function createChevronBtn(direction, iconClass) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `app-tabs-chevron app-tabs-chevron--${direction}`;
btn.setAttribute('aria-hidden', 'true');
btn.setAttribute('tabindex', '-1');
const button = document.createElement('button');
button.type = 'button';
button.className = `app-tabs-chevron app-tabs-chevron--${direction}`;
button.setAttribute('aria-hidden', 'true');
button.setAttribute('tabindex', '-1');
const icon = document.createElement('i');
icon.className = `bi ${iconClass}`;
icon.setAttribute('aria-hidden', 'true');
btn.appendChild(icon);
return btn;
button.appendChild(icon);
return button;
}
/** Repeats callback while button is held down */
function addLongPress(btn, callback) {
function addLongPress(button, callback) {
let interval = null;
const start = () => { interval = setInterval(callback, 180); };
const stop = () => { clearInterval(interval); interval = null; };
btn.addEventListener('pointerdown', start);
btn.addEventListener('pointerup', stop);
btn.addEventListener('pointerleave', stop);
btn.addEventListener('pointercancel', stop);
}
const start = () => {
interval = setInterval(callback, 180);
};
const stop = () => {
if (interval) {
clearInterval(interval);
interval = null;
}
};
// Auto-initialize on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initTabs());
} else {
initTabs();
button.addEventListener('pointerdown', start);
button.addEventListener('pointerup', stop);
button.addEventListener('pointerleave', stop);
button.addEventListener('pointercancel', stop);
return () => {
stop();
button.removeEventListener('pointerdown', start);
button.removeEventListener('pointerup', stop);
button.removeEventListener('pointerleave', stop);
button.removeEventListener('pointercancel', stop);
};
}

View File

@@ -1,15 +1,14 @@
/**
* Shows/hides SSO configuration fields based on tenant SSO toggle.
*/
const roots = document.querySelectorAll('[data-tenant-sso-root]');
import { resolveHost } from '../core/app-dom.js';
const syncControlState = (container, show) => {
if (!container) {
if (!(container instanceof HTMLElement)) {
return;
}
container.hidden = !show;
container.querySelectorAll('input, select, textarea, button').forEach((control) => {
if ((control instanceof HTMLInputElement) && control.type === 'hidden') {
return;
@@ -36,7 +35,7 @@ const syncControlState = (container, show) => {
const initRoot = (root) => {
const enabledInput = root.querySelector('[data-tenant-sso-enabled]');
if (!(enabledInput instanceof HTMLInputElement)) {
return;
return null;
}
const syncToggle = root.querySelector('[data-tenant-sso-sync-toggle]');
@@ -73,6 +72,43 @@ const initRoot = (root) => {
}
updateUi();
return () => {
enabledInput.removeEventListener('change', updateUi);
if (syncToggle instanceof HTMLInputElement) {
syncToggle.removeEventListener('change', updateUi);
}
if (sharedToggle instanceof HTMLInputElement) {
sharedToggle.removeEventListener('change', updateUi);
}
};
};
roots.forEach(initRoot);
export function initTenantSsoToggle(root = document, options = {}) {
const host = resolveHost(root);
const selector = String(options.selector || '[data-tenant-sso-root]').trim() || '[data-tenant-sso-root]';
const roots = Array.from(host.querySelectorAll(selector));
if (!roots.length) {
return { destroy: () => {} };
}
const cleanupFns = [];
roots.forEach((item) => {
if (!(item instanceof HTMLElement) || item.dataset.tenantSsoToggleBound === '1') {
return;
}
item.dataset.tenantSsoToggleBound = '1';
const cleanup = initRoot(item);
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
cleanupFns.push(() => {
delete item.dataset.tenantSsoToggleBound;
});
});
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
};
return { destroy };
}

View File

@@ -2,7 +2,7 @@
* Tenant context switcher dropdown with logo display.
*/
import { showAsyncFlash } from './app-async-flash.js';
import { optionalEl, warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const SWITCHER_ROOT_SELECTOR = '[data-tenant-switcher]';
const TENANT_LINK_SELECTOR = '[data-switch-tenant]';
@@ -71,29 +71,35 @@ const switchTenant = async (link) => {
}
};
const initTenantSwitcher = () => {
const switcherRoot = optionalEl(SWITCHER_ROOT_SELECTOR);
export const initTenantSwitcher = (root = document, options = {}) => {
const host = resolveHost(root);
const switcherSelector = String(options.rootSelector || SWITCHER_ROOT_SELECTOR).trim() || SWITCHER_ROOT_SELECTOR;
const tenantLinkSelector = String(options.linkSelector || TENANT_LINK_SELECTOR).trim() || TENANT_LINK_SELECTOR;
const switcherRoot = host.matches?.(switcherSelector) ? host : host.querySelector(switcherSelector);
if (!switcherRoot) {
// Single-tenant users do not render a switcher; this is expected.
return;
return { destroy: () => {} };
}
const tenantLinks = Array.from(switcherRoot.querySelectorAll(TENANT_LINK_SELECTOR));
const tenantLinks = Array.from(switcherRoot.querySelectorAll(tenantLinkSelector));
if (!tenantLinks.length) {
warnOnce('UI_EL_MISSING', 'Tenant switcher root exists but contains no switch links', { module: 'tenant-switcher' });
return;
return { destroy: () => {} };
}
const cleanupFns = [];
tenantLinks.forEach(link => {
link.addEventListener('click', (e) => {
const onClick = (e) => {
e.preventDefault();
switchTenant(link);
});
};
link.addEventListener('click', onClick);
cleanupFns.push(() => link.removeEventListener('click', onClick));
});
return {
destroy: () => {
cleanupFns.forEach((cleanup) => cleanup());
},
};
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTenantSwitcher);
} else {
initTenantSwitcher();
}

View File

@@ -1,7 +1,7 @@
/**
* Theme switcher — light/dark toggle and menu with server-side persistence.
*/
import { optionalEl, warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const setTheme = (theme) => {
document.documentElement.dataset.theme = theme;
@@ -10,15 +10,17 @@ const setTheme = (theme) => {
const isDarkTheme = (theme) => theme && theme.startsWith('dark');
const setIcon = (iconEl, theme) => {
if (!iconEl) {return;}
if (!iconEl) {
return;
}
iconEl.classList.remove('bi-sun-fill', 'bi-moon-stars-fill');
iconEl.classList.add(isDarkTheme(theme) ? 'bi-moon-stars-fill' : 'bi-sun-fill');
};
const updateTheme = async (menu, theme) => {
const url = menu.dataset.themeUrl;
const csrfKey = menu.dataset.csrfKey;
const csrfToken = menu.dataset.csrfToken;
const updateTheme = async (source, theme) => {
const url = source.dataset.themeUrl;
const csrfKey = source.dataset.csrfKey;
const csrfToken = source.dataset.csrfToken;
if (!url || !csrfKey || !csrfToken) {
return true;
}
@@ -28,93 +30,107 @@ const updateTheme = async (menu, theme) => {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'X-Requested-With': 'fetch'
'X-Requested-With': 'fetch',
},
body
body,
});
return response.ok;
};
const initThemeMenu = () => {
const menu = optionalEl('[data-theme-menu]');
if (!menu) {return;}
const icon = menu.querySelector('[data-theme-icon]');
const options = Array.from(menu.querySelectorAll('[data-theme-option]'));
if (!options.length) {
warnOnce('UI_EL_MISSING', 'Missing theme options', { module: 'theme-toggle' });
return;
export function initThemeControls(root = document, options = {}) {
const host = resolveHost(root);
const menuSelector = String(options.menuSelector || '[data-theme-menu]').trim() || '[data-theme-menu]';
const toggleSelector = String(options.toggleSelector || '[data-theme-toggle]').trim() || '[data-theme-toggle]';
const cleanupFns = [];
const menu = host.matches?.(menuSelector) ? host : host.querySelector(menuSelector);
if (menu instanceof HTMLElement) {
const icon = menu.querySelector('[data-theme-icon]');
const optionsEls = Array.from(menu.querySelectorAll('[data-theme-option]'));
if (!optionsEls.length) {
warnOnce('UI_EL_MISSING', 'Missing theme options', { module: 'theme-toggle' });
} else {
let pending = false;
const getCurrent = () => document.documentElement.dataset.theme || '';
const setActive = (theme) => {
optionsEls.forEach((optionEl) => {
const isActive = optionEl.dataset.themeValue === theme;
optionEl.classList.toggle('active', isActive);
if (isActive) {
optionEl.setAttribute('aria-current', 'true');
} else {
optionEl.removeAttribute('aria-current');
}
});
};
setIcon(icon, getCurrent());
setActive(getCurrent());
optionsEls.forEach((optionEl) => {
const onOptionClick = async (event) => {
event.preventDefault();
if (pending) {
return;
}
const next = optionEl.dataset.themeValue || '';
if (!next) {
return;
}
const current = getCurrent();
if (next === current) {
return;
}
pending = true;
setTheme(next);
setIcon(icon, next);
setActive(next);
const ok = await updateTheme(menu, next);
if (!ok) {
setTheme(current);
setIcon(icon, current);
setActive(current);
}
pending = false;
};
optionEl.addEventListener('click', onOptionClick);
cleanupFns.push(() => optionEl.removeEventListener('click', onOptionClick));
});
}
}
let pending = false;
const getCurrent = () => document.documentElement.dataset.theme || '';
const setActive = (theme) => {
options.forEach((option) => {
const isActive = option.dataset.themeValue === theme;
option.classList.toggle('active', isActive);
if (isActive) {
option.setAttribute('aria-current', 'true');
} else {
option.removeAttribute('aria-current');
const button = host.matches?.(toggleSelector) ? host : host.querySelector(toggleSelector);
if (button instanceof HTMLButtonElement) {
const icon = button.querySelector('i');
let pending = false;
const getCurrent = () => document.documentElement.dataset.theme || '';
setIcon(icon, getCurrent());
const onButtonClick = async () => {
if (pending) {
return;
}
});
};
setIcon(icon, getCurrent());
setActive(getCurrent());
options.forEach((option) => {
option.addEventListener('click', async (event) => {
event.preventDefault();
if (pending) {return;}
const next = option.dataset.themeValue || '';
if (!next) {return;}
const current = getCurrent();
if (next === current) {return;}
const next = isDarkTheme(current) ? 'light' : 'dark';
pending = true;
setTheme(next);
setIcon(icon, next);
setActive(next);
const ok = await updateTheme(menu, next);
const ok = await updateTheme(button, next);
if (!ok) {
setTheme(current);
setIcon(icon, current);
setActive(current);
}
pending = false;
});
});
};
};
button.addEventListener('click', onButtonClick);
cleanupFns.push(() => button.removeEventListener('click', onButtonClick));
}
const initThemeToggle = () => {
const button = optionalEl('[data-theme-toggle]');
if (!button) {return;}
const icon = button.querySelector('i');
let pending = false;
const getCurrent = () => document.documentElement.dataset.theme || '';
setIcon(icon, getCurrent());
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
};
button.addEventListener('click', async () => {
if (pending) {return;}
const current = getCurrent();
const next = isDarkTheme(current) ? 'light' : 'dark';
pending = true;
setTheme(next);
setIcon(icon, next);
const ok = await updateTheme(button, next);
if (!ok) {
setTheme(current);
setIcon(icon, current);
}
pending = false;
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initThemeMenu();
initThemeToggle();
});
} else {
initThemeMenu();
initThemeToggle();
return { destroy };
}
export const initThemeToggle = initThemeControls;

View File

@@ -1,119 +1,156 @@
/**
* Aside panel switcher — opens/closes named sidebar panels.
*/
import { requireEl, warnOnce } from '../core/app-dom.js';
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(options = {}) {
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
persistSidebarStateOnActivate = true,
storageNamespace = 'app-ui',
storageVersion = 'v1',
storageScope = 'aside-panels',
detailsStorageScope = 'aside-details',
} = options;
const bar = requireEl(barSelector, { module: 'aside-panels' });
if (!bar) {return;}
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;
return { destroy: () => {} };
}
const shortcutItems = Array.from(bar.querySelectorAll('[data-aside-target], [data-aside-shortcut]'));
const panels = Array.from(document.querySelectorAll(panelSelector));
const panels = Array.from(host.querySelectorAll(panelSelector));
if (!panels.length) {
warnOnce('UI_EL_MISSING', 'No aside panels found', { module: 'aside-panels' });
}
const titleEl = document.querySelector(titleSelector);
const toolsHost = document.querySelector(toolsSelector);
const sidebarController = window.AppSidebar || null;
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 panelDetailsRegistry = new WeakMap();
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) {return;}
if (!(panel instanceof HTMLElement)) {
return;
}
const existing = panelDetailsRegistry.get(panel);
if (existing) {
existing.apply();
return;
}
const storageKey = panel.dataset.asideDetailsStorage || '';
if (!storageKey) {return;}
const panelStorageKey = panel.dataset.asideDetailsStorage || '';
if (!panelStorageKey) {
return;
}
const openActive = panel.dataset.asideDetailsOpenActive === '1';
const detailsState = initPersistedDetailsGroup({
root: panel,
storageKey,
ensureOpenKeys: openActive ? () => findActiveDetailsKeys(panel) : []
storageKey: panelStorageKey,
ensureOpenKeys: openActive ? () => findActiveDetailsKeys(panel) : [],
storageNamespace,
storageVersion,
storageScope: detailsStorageScope,
});
if (!detailsState) {
return;
if (detailsState) {
panelDetailsRegistry.set(panel, detailsState);
panelDetailsInstances.add(detailsState);
}
panelDetailsRegistry.set(panel, detailsState);
};
const readStored = () => {
if (!storageKey) {return null;}
try {
return window.localStorage.getItem(storageKey);
} catch (error) {
if (!scopedStorageKey) {
return null;
}
return storage.getItem(scopedStorageKey);
};
const writeStored = (key) => {
if (!storageKey) {return;}
try {
window.localStorage.setItem(storageKey, key);
} catch (error) {
// ignore storage errors
if (!scopedStorageKey) {
return;
}
storage.setItem(scopedStorageKey, key);
};
const syncTools = (panel) => {
if (!toolsHost) {return;}
if (!(toolsHost instanceof HTMLElement)) {
return;
}
toolsHost.innerHTML = '';
const tools = getPanelTools(panel);
if (!tools) {return;}
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 || !sidebarController) {return;}
if (typeof sidebarController.isHidden === 'function' && sidebarController.isHidden()) {
sidebarController.show({ persist });
if (!revealSidebarOnActivate) {
return;
}
if (isSidebarHidden()) {
requestSidebarAction('show', { persist });
}
if (isSidebarCollapsed()) {
requestSidebarAction('open', { persist });
}
if (!sidebarController.isCollapsed()) {return;}
sidebarController.open({ persist });
};
const setActive = (key, { fromUser = false } = {}) => {
if (!key) {return;}
if (!key) {
return;
}
let activePanel = null;
panels.forEach((panel) => {
const isActive = getPanelKey(panel) === key;
if (isActive) {activePanel = panel;}
if (isActive) {
activePanel = panel;
}
panel.hidden = !isActive;
panel.setAttribute('aria-hidden', isActive ? 'false' : 'true');
});
@@ -125,13 +162,13 @@ export function initAsidePanels(options = {}) {
tab.setAttribute('tabindex', isActive ? '0' : '-1');
});
if (titleEl) {
if (titleEl instanceof HTMLElement) {
const nextTitle = activePanel?.dataset?.asideTitle || defaultTitle;
if (nextTitle) {
titleEl.textContent = nextTitle;
}
}
if (activePanel) {
if (activePanel instanceof HTMLElement) {
syncTools(activePanel);
initPanelDetails(activePanel);
}
@@ -147,33 +184,39 @@ export function initAsidePanels(options = {}) {
return stored;
}
const activeTab = tabs.find((tab) => tab.classList.contains('active'));
if (activeTab) {return getTabKey(activeTab);}
if (activeTab) {
return getTabKey(activeTab);
}
const visiblePanel = panels.find((panel) => !panel.hidden);
if (visiblePanel) {return getPanelKey(visiblePanel);}
if (visiblePanel) {
return getPanelKey(visiblePanel);
}
return getTabKey(tabs[0]);
};
setActive(getInitialKey(), { fromUser: false });
document.documentElement.classList.remove('aside-pending');
window.AppAsidePanels = {
setActive,
open: (key) => setActive(key, { fromUser: true })
};
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;}
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) => {
tab.addEventListener('click', (event) => {
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);
@@ -185,45 +228,83 @@ export function initAsidePanels(options = {}) {
return;
}
}
if (sidebarController && sidebarController.isCollapsed()) {
sidebarController.open({ persist: persistSidebarStateOnActivate });
if (isSidebarCollapsed()) {
requestSidebarAction('open', { persist: persistSidebarStateOnActivate });
setActive(key, { fromUser: true });
return;
}
if (isActive && sidebarController) {
if (sidebarController.isHidden?.()) {
sidebarController.show({ persist: true });
} else if (typeof sidebarController.hide === 'function') {
sidebarController.hide({ persist: true });
}
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;}
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;}
if (!/^[0-9]$/.test(key)) {
return;
}
const index = key === '0' ? 9 : (Number.parseInt(key, 10) - 1);
if (index < 0 || index >= shortcutItems.length) {return;}
if (index < 0 || index >= actionMenus().length) {
return;
}
event.preventDefault();
shortcutItems[index].click();
actionMenus()[index].click();
};
document.addEventListener('keydown', onKeyDown);
cleanupFns.push(() => document.removeEventListener('keydown', onKeyDown));
const destroy = () => {
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;
},
};
return { setActive, destroy };
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initAsidePanels());
} else {
initAsidePanels();
bar.dataset.asidePanelsBound = '1';
bar._asidePanelsApi = api;
return api;
}

View File

@@ -1,44 +1,59 @@
/**
* Toggles visibility of the detail page aside column.
*/
import { optionalEl, warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
import { createUiStorage } from '../core/app-ui-storage.js';
export function initDetailsAsideToggle(options = {}) {
export function initDetailsAsideToggle(root = document, options = {}) {
const {
buttonSelector = '#toggle-main-content-aside',
asideSelector = '#app-details-aside-section',
containerSelector = '.app-details-container',
storageKey = 'app-details-aside-collapsed'
storageNamespace = 'app-ui',
storageVersion = 'v1',
storageScope = 'details-aside',
storageKey = 'details-aside-collapsed'
} = options;
const button = optionalEl(buttonSelector);
const aside = optionalEl(asideSelector);
if (!button || !aside) {return;}
const host = resolveHost(root);
const button = host.querySelector(buttonSelector);
const aside = host.querySelector(asideSelector);
if (!button || !aside) {
return { destroy: () => {} };
}
const container = aside.closest(containerSelector) || document.querySelector(containerSelector);
const container = aside.closest(containerSelector) || host.querySelector(containerSelector);
if (!container) {
warnOnce('UI_EL_MISSING', `Missing details container: ${containerSelector}`, { module: 'details-aside' });
return;
return { destroy: () => {} };
}
if (button.dataset.detailsAsideToggleBound === '1') {
return { destroy: () => {} };
}
button.dataset.detailsAsideToggleBound = '1';
if (!button.getAttribute('aria-controls')) {
button.setAttribute('aria-controls', aside.id || 'app-details-aside-section');
}
const storage = createUiStorage({
namespace: storageNamespace,
version: storageVersion,
scope: storageScope,
});
const scopedStorageKey = storage.buildKey('state', storageKey);
const readStored = () => {
try {
return window.localStorage.getItem(storageKey);
} catch (error) {
if (!scopedStorageKey) {
return null;
}
return storage.getItem(scopedStorageKey);
};
const writeStored = (collapsed) => {
try {
window.localStorage.setItem(storageKey, collapsed ? '1' : '0');
} catch (error) {
// ignore storage errors
if (!scopedStorageKey) {
return;
}
storage.setItem(scopedStorageKey, collapsed ? '1' : '0');
};
const applyState = (collapsed, persist = false) => {
@@ -60,15 +75,17 @@ export function initDetailsAsideToggle(options = {}) {
applyState(getInitialState(), false);
button.addEventListener('click', (event) => {
const onClick = (event) => {
event.preventDefault();
const collapsed = !container.classList.contains('is-aside-collapsed');
applyState(collapsed, true);
});
}
};
button.addEventListener('click', onClick);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initDetailsAsideToggle());
} else {
initDetailsAsideToggle();
const destroy = () => {
button.removeEventListener('click', onClick);
delete button.dataset.detailsAsideToggleBound;
};
return { destroy };
}

View File

@@ -1,68 +1,80 @@
/**
* Sidebar collapse/show toggle with localStorage persistence and Ctrl+B shortcut.
*/
import { requireEl, warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
import { isEditableTarget } from '../core/app-form-utils.js';
import { createUiStorage } from '../core/app-ui-storage.js';
export function initSidebarToggle(options = {}) {
const COLLAPSED_STORAGE_KEY = 'sidebar-collapsed';
const HIDDEN_STORAGE_KEY = 'sidebar-hidden';
export function initSidebarToggle(root = document, options = {}) {
const {
buttonSelector = '[data-sidebar-toggle]',
asideSelector = '#app-sidebar',
containerSelector = '.app-container',
storageKey = 'app-sidebar-collapsed',
hiddenStorageKey = 'app-sidebar-hidden'
storageNamespace = 'app-ui',
storageVersion = 'v1',
storageScope = 'sidebar',
collapsedStorageKey = COLLAPSED_STORAGE_KEY,
hiddenStorageKey = HIDDEN_STORAGE_KEY,
} = options;
const aside = requireEl(asideSelector, { module: 'sidebar-toggle' });
if (!aside) {return null;}
const host = resolveHost(root);
const aside = host.matches?.(asideSelector) ? host : host.querySelector(asideSelector);
if (!(aside instanceof HTMLElement)) {
warnOnce('UI_EL_MISSING', `Missing sidebar: ${asideSelector}`, { module: 'sidebar-toggle' });
return { destroy: () => {} };
}
if (aside.dataset.sidebarToggleBound === '1' && aside._sidebarToggleApi) {
return aside._sidebarToggleApi;
}
const buttons = Array.from(document.querySelectorAll(buttonSelector));
const storage = createUiStorage({
namespace: storageNamespace,
version: storageVersion,
scope: storageScope,
});
const scopedCollapsedKey = storage.buildKey('state', collapsedStorageKey);
const scopedHiddenKey = storage.buildKey('state', hiddenStorageKey);
const buttons = Array.from(host.querySelectorAll(buttonSelector));
if (!buttons.length) {
warnOnce('UI_EL_MISSING', `Missing sidebar toggle buttons: ${buttonSelector}`, { module: 'sidebar-toggle' });
}
const container = document.querySelector(containerSelector);
const root = document.documentElement;
const container = host.querySelector(containerSelector);
const rootEl = document.documentElement;
const readStored = () => {
try {
return window.localStorage.getItem(storageKey);
} catch (error) {
return null;
const scopedValue = storage.getItem(scopedCollapsedKey);
if (scopedValue === '1' || scopedValue === '0') {
return scopedValue;
}
return null;
};
const readHiddenStored = () => {
try {
return window.localStorage.getItem(hiddenStorageKey);
} catch (error) {
return null;
const scopedValue = storage.getItem(scopedHiddenKey);
if (scopedValue === '1' || scopedValue === '0') {
return scopedValue;
}
return null;
};
const writeStored = (collapsed) => {
try {
window.localStorage.setItem(storageKey, collapsed ? '1' : '0');
} catch (error) {
// ignore storage errors
}
storage.setItem(scopedCollapsedKey, collapsed ? '1' : '0');
};
const writeHiddenStored = (hidden) => {
try {
window.localStorage.setItem(hiddenStorageKey, hidden ? '1' : '0');
} catch (error) {
// ignore storage errors
}
storage.setItem(scopedHiddenKey, hidden ? '1' : '0');
};
const applyState = (collapsed, { persist = false } = {}) => {
aside.classList.toggle('is-collapsed', collapsed);
if (container) {
if (container instanceof HTMLElement) {
container.classList.toggle('is-sidebar-collapsed', collapsed);
}
if (root) {
root.classList.toggle('sidebar-collapsed', collapsed);
}
rootEl.classList.toggle('sidebar-collapsed', collapsed);
buttons.forEach((button) => {
if (!button.getAttribute('aria-controls')) {
button.setAttribute('aria-controls', aside.id || 'app-sidebar');
@@ -76,12 +88,10 @@ export function initSidebarToggle(options = {}) {
const applyHidden = (hidden, { persist = false } = {}) => {
aside.classList.toggle('is-hidden', hidden);
if (container) {
if (container instanceof HTMLElement) {
container.classList.toggle('is-sidebar-hidden', hidden);
}
if (root) {
root.classList.toggle('sidebar-hidden', hidden);
}
rootEl.classList.toggle('sidebar-hidden', hidden);
buttons.forEach((button) => {
if (!button.getAttribute('aria-controls')) {
button.setAttribute('aria-controls', aside.id || 'app-sidebar');
@@ -101,7 +111,7 @@ export function initSidebarToggle(options = {}) {
if (stored === '1' || stored === '0') {
return stored === '1';
}
return root?.classList.contains('sidebar-collapsed') ?? false;
return rootEl.classList.contains('sidebar-collapsed');
};
const controller = {
@@ -110,11 +120,11 @@ export function initSidebarToggle(options = {}) {
open: ({ persist = true } = {}) => applyState(false, { persist }),
close: ({ persist = true } = {}) => applyState(true, { persist }),
toggle: ({ persist = true } = {}) => applyState(!isCollapsed(), { persist }),
apply: (collapsed, { persist = false } = {}) => applyState(!!collapsed, { persist }),
apply: (collapsed, { persist = false } = {}) => applyState(Boolean(collapsed), { persist }),
show: ({ persist = true } = {}) => applyHidden(false, { persist }),
hide: ({ persist = true } = {}) => applyHidden(true, { persist }),
toggleVisibility: ({ persist = true } = {}) => applyHidden(!isHidden(), { persist }),
applyVisibility: (hidden, { persist = false } = {}) => applyHidden(!!hidden, { persist })
applyVisibility: (hidden, { persist = false } = {}) => applyHidden(Boolean(hidden), { persist }),
};
applyState(getInitialState(), { persist: false });
@@ -123,8 +133,9 @@ export function initSidebarToggle(options = {}) {
applyHidden(hiddenStored === '1', { persist: false });
}
const cleanupFns = [];
buttons.forEach((button) => {
button.addEventListener('click', (event) => {
const onClick = (event) => {
event.preventDefault();
const action = (button.dataset.sidebarAction || '').toLowerCase();
if (action === 'visibility') {
@@ -132,37 +143,52 @@ export function initSidebarToggle(options = {}) {
return;
}
controller.toggle({ persist: true });
});
};
button.addEventListener('click', onClick);
cleanupFns.push(() => button.removeEventListener('click', onClick));
});
document.addEventListener('app:sidebar', (event) => {
const onSidebarEvent = (event) => {
const detail = event.detail || {};
const action = detail.action;
const persist = detail.persist ?? false;
if (action === 'open') {controller.open({ persist });}
if (action === 'close') {controller.close({ persist });}
if (action === 'toggle') {controller.toggle({ persist });}
if (action === 'show') {controller.show({ persist });}
if (action === 'hide') {controller.hide({ persist });}
if (action === 'toggle-visibility') {controller.toggleVisibility({ persist });}
});
if (action === 'open') { controller.open({ persist }); }
if (action === 'close') { controller.close({ persist }); }
if (action === 'toggle') { controller.toggle({ persist }); }
if (action === 'show') { controller.show({ persist }); }
if (action === 'hide') { controller.hide({ persist }); }
if (action === 'toggle-visibility') { controller.toggleVisibility({ persist }); }
};
document.addEventListener('app:sidebar', onSidebarEvent);
cleanupFns.push(() => document.removeEventListener('app:sidebar', onSidebarEvent));
window.AppSidebar = controller;
return controller;
}
document.addEventListener('keydown', (event) => {
if (!(event.metaKey || event.ctrlKey)) {return;}
if (event.altKey) {return;}
if (event.key.toLowerCase() !== 'b') {return;}
if (isEditableTarget(event.target)) {return;}
if (!window.AppSidebar || typeof window.AppSidebar.toggleVisibility !== 'function') {return;}
event.preventDefault();
window.AppSidebar.toggleVisibility({ persist: true });
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initSidebarToggle());
} else {
initSidebarToggle();
const onShortcut = (event) => {
if (!(event.metaKey || event.ctrlKey)) {
return;
}
if (event.altKey) {
return;
}
if (event.key.toLowerCase() !== 'b') {
return;
}
if (isEditableTarget(event.target)) {
return;
}
event.preventDefault();
controller.toggleVisibility({ persist: true });
};
document.addEventListener('keydown', onShortcut);
cleanupFns.push(() => document.removeEventListener('keydown', onShortcut));
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
delete aside.dataset.sidebarToggleBound;
delete aside._sidebarToggleApi;
};
const api = { ...controller, destroy };
aside.dataset.sidebarToggleBound = '1';
aside._sidebarToggleApi = api;
return api;
}

View File

@@ -0,0 +1,168 @@
/**
* Component runtime for lifecycle-managed UI modules.
*
* Contract:
* - component init signature: init(root, config)
* - init result must expose destroy(): void
*/
import { warnOnce, resolveHost } from './app-dom.js';
import { readPageConfig } from './app-page-config.js';
const toObject = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value;
};
const readByPath = (value, path) => {
const normalizedPath = String(path || '').trim();
if (normalizedPath === '') {
return undefined;
}
const segments = normalizedPath.split('.').map((item) => item.trim()).filter(Boolean);
let current = value;
for (const segment of segments) {
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return undefined;
}
current = current[segment];
}
return current;
};
export function createComponentRuntime(options = {}) {
const {
root = document,
configId = 'app-components',
} = options;
const host = resolveHost(root);
const pageConfig = toObject(readPageConfig(configId, document) || {});
const componentRegistry = new Map();
const register = (name, init, componentOptions = {}) => {
const key = String(name || '').trim();
if (key === '') {
warnOnce('UI_COMPONENT_INVALID', 'Cannot register component without name', { module: 'component-runtime' });
return false;
}
if (typeof init !== 'function') {
warnOnce('UI_COMPONENT_INVALID', `Component "${key}" init is not a function`, { module: 'component-runtime', component: key });
return false;
}
componentRegistry.set(key, {
name: key,
init,
options: toObject(componentOptions),
api: null,
});
return true;
};
const mount = (name) => {
const key = String(name || '').trim();
const definition = componentRegistry.get(key);
if (!definition) {
return null;
}
const destroyDefinition = () => {
if (Array.isArray(definition.api)) {
definition.api.forEach((instance) => {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
definition.api = null;
return;
}
if (definition.api && typeof definition.api.destroy === 'function') {
definition.api.destroy();
}
definition.api = null;
};
destroyDefinition();
const configPath = String(definition.options.configPath || '').trim();
const fallbackConfig = toObject(definition.options.defaultConfig || {});
const resolvedConfig = toObject(
configPath !== '' ? readByPath(pageConfig, configPath) : undefined
);
const mergedConfig = { ...fallbackConfig, ...resolvedConfig };
const selector = String(definition.options.selector || '').trim();
const scope = String(definition.options.scope || '').trim().toLowerCase();
const targets = scope === 'global' || selector === ''
? [host]
: Array.from(host.querySelectorAll(selector));
if (targets.length === 0) {
return null;
}
const instances = [];
targets.forEach((target) => {
const api = definition.init(target, mergedConfig);
if (!api || typeof api.destroy !== 'function') {
warnOnce(
'UI_COMPONENT_CONTRACT',
`Component "${key}" must return an API with destroy()`,
{ module: 'component-runtime', component: key }
);
return;
}
instances.push(api);
});
if (instances.length === 0) {
return null;
}
definition.api = instances;
return instances.length === 1 ? instances[0] : instances;
};
const mountAll = () => {
componentRegistry.forEach((_, key) => {
mount(key);
});
};
const destroy = (name = null) => {
const destroyDefinition = (definition) => {
if (Array.isArray(definition.api)) {
definition.api.forEach((instance) => {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
} else if (definition.api && typeof definition.api.destroy === 'function') {
definition.api.destroy();
}
definition.api = null;
};
if (name !== null) {
const key = String(name || '').trim();
const definition = componentRegistry.get(key);
if (definition) {
destroyDefinition(definition);
}
return;
}
componentRegistry.forEach((definition) => {
destroyDefinition(definition);
});
};
return {
register,
mount,
mountAll,
destroy,
getPageConfig: () => pageConfig,
};
}

View File

@@ -1,6 +1,8 @@
/**
* 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;
@@ -17,12 +19,12 @@ const normalizeKeys = (value) => Array.from(new Set(
.filter(Boolean)
));
const readStoredKeys = (storageKey) => {
if (!storageKey) {
const readStoredKeys = (storage, scopedStorageKey) => {
if (!scopedStorageKey) {
return [];
}
try {
const raw = window.localStorage.getItem(storageKey);
const raw = storage.getItem(scopedStorageKey);
if (!raw) {
return [];
}
@@ -33,12 +35,12 @@ const readStoredKeys = (storageKey) => {
}
};
const writeStoredKeys = (storageKey, keys) => {
if (!storageKey) {
const writeStoredKeys = (storage, scopedStorageKey, keys) => {
if (!scopedStorageKey) {
return;
}
try {
window.localStorage.setItem(storageKey, JSON.stringify(normalizeKeys(keys)));
storage.setItem(scopedStorageKey, JSON.stringify(normalizeKeys(keys)));
} catch (error) {
// ignore storage errors
}
@@ -61,6 +63,15 @@ export function initPersistedDetailsGroup(options = {}) {
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();
@@ -119,7 +130,7 @@ export function initPersistedDetailsGroup(options = {}) {
};
const writeOpenKeys = (keys) => {
writeStoredKeys(storageKey, mergeOpenKeys(keys));
writeStoredKeys(storage, scopedStorageKey, mergeOpenKeys(keys));
};
const persistCurrentState = () => {
@@ -142,7 +153,7 @@ export function initPersistedDetailsGroup(options = {}) {
};
const apply = () => {
const storedKeys = readStoredKeys(storageKey);
const storedKeys = readStoredKeys(storage, scopedStorageKey);
const ensureKeys = resolveEnsureKeys(ensureOpenKeysSource);
if (storedKeys.length > 0) {
@@ -224,7 +235,7 @@ export function initPersistedDetailsGroup(options = {}) {
details.removeEventListener('toggle', onAlwaysOpenToggle);
});
},
readOpenKeys: () => readStoredKeys(storageKey),
readOpenKeys: () => readStoredKeys(storage, scopedStorageKey),
writeOpenKeys,
};
}

View File

@@ -55,3 +55,11 @@ export const requireAll = (selector, details = {}) => {
};
export const optionalEl = (selector) => document.querySelector(selector);
/**
* Resolve a mount root to a valid query host.
* Components receive a `root` parameter that may be document, an element, or undefined.
* Returns the root if it supports querySelectorAll, otherwise falls back to document.
*/
export const resolveHost = (root) =>
root && typeof root.querySelectorAll === 'function' ? root : document;

View File

@@ -0,0 +1,21 @@
/**
* Shared bridge for scheduling fsLightbox refresh calls without global window APIs.
*/
let refreshPending = false;
export const requestFsLightboxRefresh = () => {
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
refreshPending = false;
return true;
}
refreshPending = true;
return false;
};
export const flushPendingFsLightboxRefresh = () => {
if (!refreshPending) {
return false;
}
return requestFsLightboxRefresh();
};

View File

@@ -1,5 +1,7 @@
import { warnOnce } from './app-dom.js';
import { confirmDialog } from './app-confirm-dialog.js';
import { requestFsLightboxRefresh } from './app-fslightbox-bridge.js';
import { createUiStorage } from './app-ui-storage.js';
import { gridFiltersFromSchema, withCurrentListReturn } from '../pages/app-list-utils.js';
import { initListFilterExperience } from '../pages/app-list-filter-experience.js';
import { buildChipsFromMeta, removeChipFromMetaState, clearMetaState } from '../pages/app-list-filter-state.js';
@@ -29,30 +31,6 @@ const normalizePageSizeOptions = (options) => {
return normalized.length ? normalized : [...DEFAULT_PAGE_SIZE_OPTIONS];
};
const safeStorageGet = (key) => {
const storageKey = String(key || '').trim();
if (storageKey === '') {
return null;
}
try {
return window.localStorage.getItem(storageKey);
} catch {
return null;
}
};
const safeStorageSet = (key, value) => {
const storageKey = String(key || '').trim();
if (storageKey === '') {
return;
}
try {
window.localStorage.setItem(storageKey, value);
} catch {
// localStorage can be unavailable; fail safe.
}
};
const escapeHtmlAttr = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
@@ -217,6 +195,14 @@ export function createServerGrid(options) {
const pageSizeStorageKey = pageSizeEnabled
? (String(pageSizeConfig.storageKey || autoPageSizeStorageKey).trim() || autoPageSizeStorageKey)
: '';
const pageSizeStorage = createUiStorage({
namespace: pageSizeConfig.storageNamespace || 'app-ui',
version: pageSizeConfig.storageVersion || 'v1',
scope: pageSizeConfig.storageScope || 'grid-page-size',
});
const scopedPageSizeStorageKey = pageSizeEnabled
? pageSizeStorage.buildKey('state', pageSizeStorageKey)
: '';
const parseAllowedLimit = (value) => {
if (!pageSizeEnabled) {
return parsePositiveInt(value);
@@ -243,13 +229,15 @@ export function createServerGrid(options) {
if (urlLimit !== null) {
currentLimit = urlLimit;
} else if (pageSizeEnabled) {
const storedLimit = parseAllowedLimit(safeStorageGet(pageSizeStorageKey));
const storedLimit = parseAllowedLimit(
scopedPageSizeStorageKey ? pageSizeStorage.getItem(scopedPageSizeStorageKey) : null
);
if (storedLimit !== null) {
currentLimit = storedLimit;
}
}
if (pageSizeEnabled) {
safeStorageSet(pageSizeStorageKey, String(currentLimit));
if (scopedPageSizeStorageKey) {
pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit));
}
let currentPage = urlStateConfig.syncPage ? parsePage(params.get(pageParam)) : 1;
let currentSort = null;
@@ -680,8 +668,8 @@ export function createServerGrid(options) {
const nextPage = Number.isFinite(parsedPage) && parsedPage >= 0 ? parsedPage : 0;
const nextLimit = parsedLimit ?? currentLimit;
currentLimit = nextLimit;
if (pageSizeEnabled) {
safeStorageSet(pageSizeStorageKey, String(currentLimit));
if (scopedPageSizeStorageKey) {
pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit));
}
currentPage = nextPage + 1;
if (urlSync && (urlStateConfig.syncPage || syncLimit)) {
@@ -1060,7 +1048,9 @@ export function createServerGrid(options) {
}
currentLimit = nextLimit;
currentPage = 1;
safeStorageSet(pageSizeStorageKey, String(currentLimit));
if (scopedPageSizeStorageKey) {
pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit));
}
syncPageSizeControl();
updateGrid({ resetPage: true, urlHistoryMode: 'push' });
});
@@ -1258,7 +1248,9 @@ export function createServerGrid(options) {
const nextLimit = parseAllowedLimit(state?.limit);
if (nextLimit !== null) {
currentLimit = nextLimit;
safeStorageSet(pageSizeStorageKey, String(currentLimit));
if (scopedPageSizeStorageKey) {
pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit));
}
syncPageSizeControl();
}
};
@@ -1312,15 +1304,7 @@ export function createServerGrid(options) {
grid.on('ready', () => {
initSelectAll();
setTimeout(() => {
if (typeof window.requestFsLightboxRefresh === 'function') {
window.requestFsLightboxRefresh();
return;
}
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
} else {
window.__fsLightboxRefreshPending = true;
}
requestFsLightboxRefresh();
}, 0);
});

View File

@@ -0,0 +1,45 @@
/**
* Shared UI event channels for cross-component communication.
*/
const SIDEBAR_EVENT = 'app:sidebar';
const ASIDE_PANELS_EVENT = 'app:aside-panels';
const dispatch = (eventName, detail = {}) => {
document.dispatchEvent(new CustomEvent(eventName, { detail }));
};
export const requestSidebarAction = (action, options = {}) => {
const normalizedAction = String(action || '').trim();
if (normalizedAction === '') {
return;
}
dispatch(SIDEBAR_EVENT, {
action: normalizedAction,
persist: Boolean(options.persist),
});
};
export const requestAsidePanelOpen = (panel, options = {}) => {
const normalizedPanel = String(panel || '').trim();
if (normalizedPanel === '') {
return;
}
dispatch(ASIDE_PANELS_EVENT, {
action: 'open',
panel: normalizedPanel,
fromUser: options.fromUser !== false,
});
};
export const bindAsidePanelChannel = (handler) => {
if (typeof handler !== 'function') {
return () => {};
}
const listener = (event) => {
handler(event instanceof CustomEvent ? event.detail || {} : {});
};
document.addEventListener(ASIDE_PANELS_EVENT, listener);
return () => {
document.removeEventListener(ASIDE_PANELS_EVENT, listener);
};
};

View File

@@ -0,0 +1,67 @@
/**
* Namespaced localStorage helper for UI components.
*/
const safeStorageGet = (key) => {
const normalized = String(key || '').trim();
if (normalized === '') {
return null;
}
try {
return window.localStorage.getItem(normalized);
} catch {
return null;
}
};
const safeStorageSet = (key, value) => {
const normalized = String(key || '').trim();
if (normalized === '') {
return;
}
try {
window.localStorage.setItem(normalized, value);
} catch {
// localStorage may be unavailable; fail safe.
}
};
const safeStorageRemove = (key) => {
const normalized = String(key || '').trim();
if (normalized === '') {
return;
}
try {
window.localStorage.removeItem(normalized);
} catch {
// localStorage may be unavailable; fail safe.
}
};
const normalizeToken = (value) => String(value ?? '')
.trim()
.toLowerCase()
.replaceAll(/[^a-z0-9._-]+/g, '-')
.replaceAll(/^-+|-+$/g, '');
export function createUiStorage(options = {}) {
const namespace = normalizeToken(options.namespace || 'app-ui') || 'app-ui';
const version = normalizeToken(options.version || 'v1') || 'v1';
const scope = normalizeToken(options.scope || '') || 'global';
const basePrefix = `${namespace}:${version}:${scope}`;
const buildKey = (bucket, key) => {
const normalizedBucket = normalizeToken(bucket || 'component') || 'component';
const normalizedKey = normalizeToken(key || '');
if (normalizedKey === '') {
return '';
}
return `${basePrefix}:${normalizedBucket}:${normalizedKey}`;
};
return {
buildKey,
getItem: safeStorageGet,
setItem: safeStorageSet,
removeItem: safeStorageRemove,
};
}

View File

@@ -31,6 +31,12 @@ export function initListFilterExperience(options = {}) {
let openSnapshot = null;
let drawerDirtyDebounce = null;
const cleanupFns = [];
const addCleanup = (cleanup) => {
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
};
const announceUpdated = () => {
document.dispatchEvent(new CustomEvent('app:filters-updated'));
};
@@ -271,8 +277,15 @@ export function initListFilterExperience(options = {}) {
drawerFields.forEach((field) => {
if (field.dataset.drawerDirtyBound === '1') {return;}
field.dataset.drawerDirtyBound = '1';
field.addEventListener('input', () => applyDirtyUi(false));
field.addEventListener('change', () => applyDirtyUi(false));
const onFieldInput = () => applyDirtyUi(false);
const onFieldChange = () => applyDirtyUi(false);
field.addEventListener('input', onFieldInput);
field.addEventListener('change', onFieldChange);
addCleanup(() => {
field.removeEventListener('input', onFieldInput);
field.removeEventListener('change', onFieldChange);
delete field.dataset.drawerDirtyBound;
});
});
}
@@ -280,22 +293,46 @@ export function initListFilterExperience(options = {}) {
.map((input) => (typeof input === 'string' ? document.querySelector(input) : input))
.filter(Boolean)
.forEach((input) => {
input.addEventListener('input', debounce(() => {
const onInput = debounce(() => {
renderChips();
}, 300));
input.addEventListener('change', () => {
}, 300);
const onChange = () => {
renderChips();
};
input.addEventListener('input', onInput);
input.addEventListener('change', onChange);
addCleanup(() => {
input.removeEventListener('input', onInput);
input.removeEventListener('change', onChange);
});
});
document.addEventListener('app:filters-updated', () => {
const onFiltersUpdated = () => {
renderChips();
});
};
document.addEventListener('app:filters-updated', onFiltersUpdated);
addCleanup(() => document.removeEventListener('app:filters-updated', onFiltersUpdated));
renderChips();
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns.length = 0;
if (drawerDirtyDebounce) {
clearTimeout(drawerDirtyDebounce);
drawerDirtyDebounce = null;
}
if (drawerController && typeof drawerController.destroy === 'function') {
drawerController.destroy();
}
if (chipsController && typeof chipsController.destroy === 'function') {
chipsController.destroy();
}
};
return {
drawer: drawerController,
renderChips
renderChips,
destroy,
};
}

View File

@@ -0,0 +1,18 @@
import { initPageEditor } from '../components/app-page-editor.js';
import { readPageConfig } from '../core/app-page-config.js';
const pageConfig = readPageConfig('page-editor') || {};
const selector = String(pageConfig.selector || '[data-page-editor]').trim() || '[data-page-editor]';
const forms = Array.from(document.querySelectorAll(selector)).filter(
(item) => item instanceof HTMLFormElement
);
const apis = forms.map((form) => initPageEditor(form, pageConfig)).filter(
(api) => api && typeof api.destroy === 'function'
);
export const destroyPageEditorEntry = () => {
apis.forEach((api) => {
api.destroy();
});
};