2026-02-04 23:31:53 +01:00
|
|
|
|
/**
|
2026-03-13 21:59:09 +01:00
|
|
|
|
* Initializes tab navigation with chevron-based overflow scrolling.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Markup (author writes):
|
|
|
|
|
|
* <div data-tabs>
|
|
|
|
|
|
* <div class="app-tabs-nav">
|
|
|
|
|
|
* <button data-tab="system" data-tab-default>System</button>
|
|
|
|
|
|
* ...
|
|
|
|
|
|
* </div>
|
|
|
|
|
|
* <div data-tab-panel="system">...</div>
|
|
|
|
|
|
* </div>
|
|
|
|
|
|
*
|
|
|
|
|
|
* JS wraps the nav in `.app-tabs-nav-wrap` and injects chevron buttons when
|
|
|
|
|
|
* the strip overflows. No-JS fallback: native horizontal scrollbar stays.
|
2026-02-04 23:31:53 +01:00
|
|
|
|
*/
|
|
|
|
|
|
export function initTabs(root = document) {
|
|
|
|
|
|
const containers = root.querySelectorAll('[data-tabs]');
|
2026-03-13 22:35:01 +01:00
|
|
|
|
const instances = [];
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
|
|
containers.forEach((container) => {
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
if (container.dataset.tabsBound === '1') {return;}
|
2026-02-04 23:31:53 +01:00
|
|
|
|
container.dataset.tabsBound = '1';
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
container.dataset.tabsReady = '0';
|
2026-03-13 22:35:01 +01:00
|
|
|
|
const cleanupFns = [];
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
|
|
const tabs = Array.from(container.querySelectorAll('[data-tab]'));
|
|
|
|
|
|
const panels = Array.from(container.querySelectorAll('[data-tab-panel]'));
|
|
|
|
|
|
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
if (!tabs.length || !panels.length) {return;}
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
|
|
const storageKey = container.dataset.tabsStorageKey || (container.id ? `tabs:${container.id}` : null);
|
|
|
|
|
|
const urlParamName = container.dataset.tabsParam || '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);
|
|
|
|
|
|
url.searchParams.set(name, value);
|
|
|
|
|
|
window.history.replaceState({}, '', url);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Get initial active tab from URL param, localStorage, or data-tab-default
|
|
|
|
|
|
let activeTab = null;
|
|
|
|
|
|
const urlTab = getUrlParam(urlParamName);
|
|
|
|
|
|
if (urlTab && panels.some(p => p.dataset.tabPanel === urlTab)) {
|
|
|
|
|
|
activeTab = urlTab;
|
|
|
|
|
|
} else if (storageKey) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const stored = localStorage.getItem(storageKey);
|
|
|
|
|
|
if (stored && panels.some(p => p.dataset.tabPanel === stored)) {
|
|
|
|
|
|
activeTab = stored;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// localStorage not available
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!activeTab) {
|
|
|
|
|
|
const defaultTab = container.querySelector('[data-tab-default]');
|
|
|
|
|
|
activeTab = defaultTab?.dataset.tab || tabs[0]?.dataset.tab;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const activateTab = (tabName) => {
|
|
|
|
|
|
tabs.forEach(tab => {
|
|
|
|
|
|
const isActive = tab.dataset.tab === tabName;
|
|
|
|
|
|
tab.classList.toggle('is-active', isActive);
|
|
|
|
|
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
2026-03-13 21:59:09 +01:00
|
|
|
|
tab.setAttribute('tabindex', isActive ? '0' : '-1');
|
2026-02-04 23:31:53 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 21:59:09 +01:00
|
|
|
|
// ----------------------------------------------------------------
|
2026-03-13 22:21:37 +01:00
|
|
|
|
// ARIA: role, aria-controls, aria-labelledby (WAI-ARIA Tabs pattern)
|
2026-03-13 21:59:09 +01:00
|
|
|
|
// ----------------------------------------------------------------
|
|
|
|
|
|
const nav = container.querySelector('.app-tabs-nav');
|
2026-03-13 22:21:37 +01:00
|
|
|
|
const tabIdPrefix = container.id || `tabs-${Math.random().toString(36).slice(2, 8)}`;
|
2026-03-13 21:59:09 +01:00
|
|
|
|
|
2026-02-04 23:31:53 +01:00
|
|
|
|
tabs.forEach(tab => {
|
2026-03-13 22:21:37 +01:00
|
|
|
|
const tabName = tab.dataset.tab;
|
|
|
|
|
|
const tabId = `${tabIdPrefix}-tab-${tabName}`;
|
|
|
|
|
|
const panelId = `${tabIdPrefix}-panel-${tabName}`;
|
2026-02-04 23:31:53 +01:00
|
|
|
|
tab.setAttribute('role', 'tab');
|
2026-03-13 22:21:37 +01:00
|
|
|
|
tab.id = tabId;
|
|
|
|
|
|
tab.setAttribute('aria-controls', panelId);
|
2026-02-04 23:31:53 +01:00
|
|
|
|
tab.addEventListener('click', (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
activateTab(tab.dataset.tab);
|
2026-03-13 21:59:09 +01:00
|
|
|
|
// Scroll the active tab into view within the nav strip
|
|
|
|
|
|
scrollTabIntoView(tab);
|
2026-02-04 23:31:53 +01:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
panels.forEach(panel => {
|
2026-03-13 22:21:37 +01:00
|
|
|
|
const panelName = panel.dataset.tabPanel;
|
|
|
|
|
|
const panelId = `${tabIdPrefix}-panel-${panelName}`;
|
|
|
|
|
|
const tabId = `${tabIdPrefix}-tab-${panelName}`;
|
2026-02-04 23:31:53 +01:00
|
|
|
|
panel.setAttribute('role', 'tabpanel');
|
2026-03-13 22:21:37 +01:00
|
|
|
|
panel.id = panelId;
|
|
|
|
|
|
panel.setAttribute('aria-labelledby', tabId);
|
2026-02-04 23:31:53 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-13 21:59:09 +01:00
|
|
|
|
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;}
|
|
|
|
|
|
|
|
|
|
|
|
let nextIndex = -1;
|
|
|
|
|
|
if (e.key === 'ArrowRight') {
|
|
|
|
|
|
nextIndex = (currentIndex + 1) % tabs.length;
|
|
|
|
|
|
} else if (e.key === 'ArrowLeft') {
|
|
|
|
|
|
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
|
|
|
|
|
} else if (e.key === 'Home') {
|
|
|
|
|
|
nextIndex = 0;
|
|
|
|
|
|
} else if (e.key === 'End') {
|
|
|
|
|
|
nextIndex = tabs.length - 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (nextIndex >= 0) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
tabs[nextIndex].focus();
|
|
|
|
|
|
activateTab(tabs[nextIndex].dataset.tab);
|
|
|
|
|
|
scrollTabIntoView(tabs[nextIndex]);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
|
|
// Activate initial tab
|
|
|
|
|
|
if (activeTab) {
|
|
|
|
|
|
activateTab(activeTab);
|
|
|
|
|
|
}
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
container.dataset.tabsReady = '1';
|
|
|
|
|
|
|
2026-03-13 21:59:09 +01:00
|
|
|
|
// ----------------------------------------------------------------
|
|
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
// --- Form validation awareness ---
|
|
|
|
|
|
const form = container.closest('form');
|
|
|
|
|
|
if (form) {
|
|
|
|
|
|
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'));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const findInvalidPanels = () => {
|
|
|
|
|
|
clearInvalidMarkers();
|
|
|
|
|
|
let firstInvalidTab = null;
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
2026-03-13 22:35:01 +01:00
|
|
|
|
const onFormClickCapture = (e) => {
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
const btn = e.target.closest('button, [type="submit"]');
|
|
|
|
|
|
if (!btn) {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 firstInvalidTab = findInvalidPanels();
|
|
|
|
|
|
if (!firstInvalidTab) {return;}
|
|
|
|
|
|
|
|
|
|
|
|
// Active panel already shows the first invalid tab — let native validation handle it
|
|
|
|
|
|
const activePanel = panels.find(p => !p.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
el = el.parentElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!needsTabSwitch) {return;}
|
|
|
|
|
|
|
|
|
|
|
|
// Prevent native submit so the browser doesn't try to focus a hidden field
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
activateTab(firstInvalidTab);
|
|
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
|
form.reportValidity();
|
|
|
|
|
|
});
|
2026-03-13 22:35:01 +01:00
|
|
|
|
};
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
|
2026-03-13 22:35:01 +01:00
|
|
|
|
const onFormInput = (e) => {
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
const panel = e.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 fields = panel.querySelectorAll(inputSelector);
|
|
|
|
|
|
if (Array.from(fields).every(f => !belongsToForm(f) || f.disabled || f.checkValidity())) {
|
|
|
|
|
|
tab.classList.remove('has-invalid');
|
|
|
|
|
|
}
|
2026-03-13 22:35:01 +01:00
|
|
|
|
};
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
|
2026-03-13 22:35:01 +01:00
|
|
|
|
document.addEventListener('click', onFormClickCapture, true);
|
|
|
|
|
|
form.addEventListener('input', onFormInput);
|
|
|
|
|
|
form.addEventListener('change', onFormInput);
|
|
|
|
|
|
|
|
|
|
|
|
cleanupFns.push(() => {
|
|
|
|
|
|
document.removeEventListener('click', onFormClickCapture, true);
|
|
|
|
|
|
form.removeEventListener('input', onFormInput);
|
|
|
|
|
|
form.removeEventListener('change', onFormInput);
|
add composer-unused, comprehensive docs, and project restructure
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 15:27:35 +01:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
|
|
// Listen for popstate (back/forward navigation)
|
2026-03-13 22:35:01 +01:00
|
|
|
|
const onPopState = () => {
|
2026-02-04 23:31:53 +01:00
|
|
|
|
const newTab = getUrlParam(urlParamName);
|
|
|
|
|
|
if (newTab && panels.some(p => p.dataset.tabPanel === newTab)) {
|
|
|
|
|
|
activateTab(newTab);
|
|
|
|
|
|
}
|
2026-03-13 22:35:01 +01:00
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener('popstate', onPopState);
|
|
|
|
|
|
cleanupFns.push(() => window.removeEventListener('popstate', onPopState));
|
|
|
|
|
|
|
|
|
|
|
|
const destroy = () => {
|
|
|
|
|
|
cleanupFns.forEach((fn) => fn());
|
|
|
|
|
|
cleanupFns.length = 0;
|
|
|
|
|
|
delete container.dataset.tabsBound;
|
|
|
|
|
|
delete container.dataset.tabsReady;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
container._tabsApi = { activateTab, destroy };
|
|
|
|
|
|
instances.push(container._tabsApi);
|
2026-02-04 23:31:53 +01:00
|
|
|
|
});
|
2026-03-13 22:35:01 +01:00
|
|
|
|
|
|
|
|
|
|
return instances;
|
2026-02-04 23:31:53 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 21:59:09 +01:00
|
|
|
|
// ------------------------------------------------------------------
|
|
|
|
|
|
// 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]
|
|
|
|
|
|
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' });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Continuous scroll on long-press
|
|
|
|
|
|
addLongPress(btnStart, () => nav.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' }));
|
|
|
|
|
|
addLongPress(btnEnd, () => nav.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' }));
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
nav.addEventListener('scroll', update, { passive: true });
|
|
|
|
|
|
|
|
|
|
|
|
// ResizeObserver to detect container width changes
|
|
|
|
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
|
|
|
|
const ro = new ResizeObserver(update);
|
|
|
|
|
|
ro.observe(nav);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Initial check (deferred so layout is settled)
|
|
|
|
|
|
requestAnimationFrame(update);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @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');
|
2026-03-13 22:35:01 +01:00
|
|
|
|
const icon = document.createElement('i');
|
|
|
|
|
|
icon.className = `bi ${iconClass}`;
|
|
|
|
|
|
icon.setAttribute('aria-hidden', 'true');
|
|
|
|
|
|
btn.appendChild(icon);
|
2026-03-13 21:59:09 +01:00
|
|
|
|
return btn;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Repeats callback while button is held down */
|
|
|
|
|
|
function addLongPress(btn, 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 23:31:53 +01:00
|
|
|
|
// Auto-initialize on load
|
|
|
|
|
|
if (document.readyState === 'loading') {
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => initTabs());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
initTabs();
|
|
|
|
|
|
}
|