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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user