1
0
Files
breadcrumb-the-shire/web/js/components/app-tabs.js
fs d9f07dcd63 fix: web/ quick wins — CSS cleanup, ARIA tabs, and file header comments
CSS:
- Remove duplicate li::before block in app-search.css
- Fix typo: search-reuslt → search-result in app-search.css
- Remove commented-out CSS rules in app-flash.css
- Add descriptive header comment to all 27 CSS component files

JS:
- Complete WAI-ARIA Tabs pattern: generate IDs, add aria-controls on
  tab buttons and aria-labelledby on tab panels (app-tabs.js)
- Add JSDoc header comments to ~33 JS files (core + components)
- Add explanatory block comment to app-boot.js (why classic IIFE)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 22:21:37 +01:00

383 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.
*/
export function initTabs(root = document) {
const containers = root.querySelectorAll('[data-tabs]');
containers.forEach((container) => {
if (container.dataset.tabsBound === '1') {return;}
container.dataset.tabsBound = '1';
container.dataset.tabsReady = '0';
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.
document.addEventListener('click', (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 <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();
});
}, true);
form.addEventListener('input', (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');
}
});
form.addEventListener('change', (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');
}
});
}
// Listen for popstate (back/forward navigation)
window.addEventListener('popstate', () => {
const newTab = getUrlParam(urlParamName);
if (newTab && panels.some(p => p.dataset.tabPanel === newTab)) {
activateTab(newTab);
}
});
});
}
// ------------------------------------------------------------------
// 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');
btn.innerHTML = `<i class="bi ${iconClass}"></i>`;
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();
}