/** * 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. */ 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 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 tabs = Array.from(container.querySelectorAll('[data-tab]')); const panels = Array.from(container.querySelectorAll('[data-tab-panel]')); 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 = 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'; const getUrlParam = (name) => { const params = new URLSearchParams(window.location.search); return params.get(name); }; const setUrlParam = (name, value) => { const url = new URL(window.location.href); url.searchParams.set(name, value); window.history.replaceState({}, '', url); }; 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((panel) => panel.dataset.tabPanel === urlTab)) { activeTab = urlTab; } 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 || 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) => { 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'); }); setUrlParam(urlParamName, tabName); persistTab(tabName); }; 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); const onClick = (event) => { event.preventDefault(); activateTab(tab.dataset.tab); scrollTabIntoView(tab); }; tab.addEventListener('click', onClick); addCleanup(() => tab.removeEventListener('click', onClick)); }); 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'); const onNavKeyDown = (event) => { const currentIndex = tabs.indexOf(event.target); if (currentIndex === -1) { return; } let nextIndex = -1; if (event.key === 'ArrowRight') { nextIndex = (currentIndex + 1) % tabs.length; } else if (event.key === 'ArrowLeft') { nextIndex = (currentIndex - 1 + tabs.length) % tabs.length; } else if (event.key === 'Home') { nextIndex = 0; } else if (event.key === 'End') { nextIndex = tabs.length - 1; } if (nextIndex >= 0) { 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); } if (activeTab) { activateTab(activeTab); } container.dataset.tabsReady = '1'; const form = container.closest('form'); if (form instanceof HTMLFormElement) { const formId = String(form.id || '').trim(); const inputSelector = 'input,select,textarea'; 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( (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; }; const onFormClickCapture = (event) => { const target = event.target; if (!(target instanceof Element)) { return; } const trigger = target.closest('button, [type="submit"]'); if (!trigger) { 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; } const activePanel = panels.find((panel) => !panel.hidden); const needsTabSwitch = activePanel?.dataset.tabPanel !== firstInvalidTab; const invalidFields = form.querySelectorAll(inputSelector); for (const field of invalidFields) { if (!belongsToForm(field, form) || field.disabled || field.checkValidity()) { continue; } let element = field.parentElement; while (element && element !== form) { if (element.tagName === 'DETAILS' && !element.open) { element.open = true; } element = element.parentElement; } } if (!needsTabSwitch) { return; } event.preventDefault(); activateTab(firstInvalidTab); requestAnimationFrame(() => { form.reportValidity(); }); }; const onFormInput = (event) => { const panel = event.target.closest('[data-tab-panel]'); if (!panel) { return; } const tabName = panel.dataset.tabPanel; const tab = tabs.find((candidate) => candidate.dataset.tab === tabName); if (!tab) { return; } const fields = panel.querySelectorAll(inputSelector); if (Array.from(fields).every((field) => !belongsToForm(field, form) || field.disabled || field.checkValidity())) { tab.classList.remove('has-invalid'); } }; document.addEventListener('click', onFormClickCapture, true); form.addEventListener('input', onFormInput); form.addEventListener('change', onFormInput); addCleanup(() => { document.removeEventListener('click', onFormClickCapture, true); form.removeEventListener('input', onFormInput); form.removeEventListener('change', onFormInput); }); } const onPopState = () => { const nextTab = getUrlParam(urlParamName); if (nextTab && panels.some((panel) => panel.dataset.tabPanel === nextTab)) { activateTab(nextTab); } }; window.addEventListener('popstate', onPopState); addCleanup(() => window.removeEventListener('popstate', onPopState)); const destroy = () => { cleanupFns.forEach((cleanup) => cleanup()); cleanupFns.length = 0; delete container.dataset.tabsBound; delete container.dataset.tabsReady; delete container._tabsApi; }; const api = { activateTab, destroy }; container._tabsApi = api; instances.push(api); }); return { instances, destroy: () => { instances.forEach((instance) => { if (instance && typeof instance.destroy === 'function') { instance.destroy(); } }); }, }; } function initChevronScroll(nav) { 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); const btnStart = createChevronBtn('start', 'bi-chevron-left'); const btnEnd = createChevronBtn('end', 'bi-chevron-right'); wrap.appendChild(btnStart); wrap.appendChild(btnEnd); const cleanupFns = []; const bind = (target, eventName, handler, options = undefined) => { target.addEventListener(eventName, handler, options); cleanupFns.push(() => target.removeEventListener(eventName, handler, options)); }; 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); const update = () => { const hasOverflow = nav.scrollWidth > nav.clientWidth + SCROLL_THRESHOLD; wrap.classList.toggle('has-overflow', hasOverflow); 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; wrap.classList.toggle('at-start', atStart); wrap.classList.toggle('at-end', atEnd); }; bind(nav, 'scroll', update, { passive: true }); let resizeObserver = null; if (typeof ResizeObserver !== 'undefined') { resizeObserver = new ResizeObserver(update); resizeObserver.observe(nav); } 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(); } }; } function createChevronBtn(direction, iconClass) { 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'); button.appendChild(icon); return button; } function addLongPress(button, callback) { let interval = null; const start = () => { interval = setInterval(callback, 180); }; const stop = () => { if (interval) { clearInterval(interval); interval = null; } }; 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); }; }