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

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