/** * Initializes tab navigation with chevron-based overflow scrolling. * * Markup (author writes): *
*
* * ... *
*
...
*
* * 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]'); const instances = []; containers.forEach((container) => { if (container.dataset.tabsBound === '1') {return;} 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;} 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'); tab.setAttribute('tabindex', isActive ? '0' : '-1'); }); 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 } } }; // ---------------------------------------------------------------- // 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 => { 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(); activateTab(tab.dataset.tab); // Scroll the active tab into view within the nav strip scrollTabIntoView(tab); }); }); panels.forEach(panel => { const panelName = panel.dataset.tabPanel; const panelId = `${tabIdPrefix}-panel-${panelName}`; const tabId = `${tabIdPrefix}-tab-${panelName}`; panel.setAttribute('role', 'tabpanel'); panel.id = panelId; panel.setAttribute('aria-labelledby', tabId); }); 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]); } }); } // 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) { 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. const onFormClickCapture = (e) => { 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
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(); }); }; const onFormInput = (e) => { 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'); } }; 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); }); } // Listen for popstate (back/forward navigation) const onPopState = () => { const newTab = getUrlParam(urlParamName); if (newTab && panels.some(p => p.dataset.tabPanel === newTab)) { activateTab(newTab); } }; 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); }); return instances; } // ------------------------------------------------------------------ // 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'); const icon = document.createElement('i'); icon.className = `bi ${iconClass}`; icon.setAttribute('aria-hidden', 'true'); btn.appendChild(icon); 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); } // Auto-initialize on load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => initTabs()); } else { initTabs(); }