Files
breadcrumb-the-shire/web/js/components/app-tabs.js
fs 35a36f1144 fix(security): replace innerHTML with safe DOM APIs, add destroy() cleanup
Replace innerHTML assignments in app-tabs.js and app-password-toggle.js
with createElement/appendChild to prevent potential XSS vectors. Add
destroy() methods to app-tabs, app-filter-drawer, and
app-toggle-aside-panels so document/window-level listeners can be
properly removed when components are torn down.

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

400 lines
14 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]');
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 <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();
});
};
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();
}