/** * Handles the tabs functionality, given the html requirements are met */ class TabsContainer{ constructor() { this.a11y = window.jvbA11y; this.error = window.jvbError; this.subscribers = new Set(); this.tabs = new Map(); this.hasHash = false; this.init(); } init() { this.initElements(); this.initListeners(); } initElements() { this.selectors = { nav: '.tabs', tab: '[data-tab]', active: 'button.tab.active', section: '.tab-content', button: 'button.tab', select: 'select.tab-list', }; } initListeners() { this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); } handleClick(e) { let config = this.getConfig(e.target); if (!config) return; const tab = e.target.closest(this.selectors.tab); if (tab) { this.switchTab(tab.dataset.tab, config); } } handleChange(e) { let config = this.getConfig(e.target); if (!config) return; if (!config) return; this.switchTab(e.target.value, config); } /** * * @param {HTMLElement} container * @param {object} options */ registerTab(container, options = {}) { if (!container) return false; let ui = window.uiFromSelectors(this.selectors, container); if (!ui.nav || !ui.section) { console.error('No tab navigation or section found'); return false; } let tabsId = window.generateID('tab'); container.dataset.tabsId = tabsId; ui.buttons = Array.from(container.querySelectorAll(this.selectors.button)); ui.sections = Array.from(container.querySelectorAll(this.selectors.section)); ui.sections.forEach(section => { if (section.querySelector('.tabs')) { options.hasChildren = true; this.registerTab(section, {parent: tabsId}); } }); if (ui.select) { ui.options = Array.from(ui.select.querySelectorAll('option')); } let config = { id: tabsId, ui: ui, updateURL: options.updateURL ?? true }; //Add listeners ui.nav.addEventListener('click', this.clickHandler); ui.select?.addEventListener('change', this.changeHandler); this.tabs.set(tabsId, config); this.determineActiveTab(config); return config; } determineActiveTab(config) { if (this.getInitialTabFromHash()) { let updated = this.tabs.get(config.id); if (updated.activeTab && config.activeTab && updated.activeTab === config.activeTab) return; } let tab = config.ui.buttons[0].dataset.tab??false; if (tab) { this.switchTab(tab, config); } } getInitialTabFromHash() { if (this.hasHash || !window.location.search) return false; const params = new URLSearchParams(window.location.search); const hash = params.get('tab'); if (!hash) return false; const parts = hash.split('|'); parts.forEach(part => { // Find the config that has a button matching this part const conf = Array.from(this.tabs.values()).find(tab => tab.ui.buttons.some(btn => btn.dataset.tab === part) ); if (conf) { this.switchTab(part, conf); } }); this.hasHash = true; return true; } /** * * @param {HTMLElement} container */ removeTab(container) { if (!container || !container.dataset.tabsId) return; let config = this.tabs.get(container.dataset.tabsId); if (!config) return; config.ui.nav.removeEventListener('click', this.clickHandler); config.ui.select?.removeEventListener('change', this.changeHandler); this.tabs.delete(container.dataset.tabsId); } /** * * @param {string} tab * @param {string|Object} config Either the key of the tabs instance, or a tab config object */ switchTab(tab, config) { config = (typeof config === 'string') ? this.tabs.get(config) : config; if (!config) return; let activeTab = config.ui.sections.filter(section => section.classList.contains('active')); if (Object.hasOwn(config, 'preCheck') && !config.preCheck(activeTab[0], config)) return; if (document.activeElement && this.isInTabs(document.activeElement, config)) document.activeElement.blur(); config.ui.buttons.forEach((btn, index) => { btn.classList.remove('active', 'previous', 'next'); let isActive = btn.dataset.tab === tab; btn.setAttribute('aria-selected', isActive); if (isActive) { btn.classList.add('active'); let prv = Math.max(index - 1, 0); let next = Math.min(index+1, config.ui.buttons.length -1); if (prv !== index) { config.ui.buttons[prv]?.classList.add('previous'); } if (next !== index) { config.ui.buttons[next]?.classList.add('next'); } } }); config.ui.sections.forEach((section, index) => { let isActive = section.dataset.tab === tab; section.classList.toggle('active', isActive); section.setAttribute('aria-hidden', !isActive); section.hidden = !isActive; }); this.notify('tab-switched', { previous: config.activeTab, current: tab, config: config }); config.activeTab = tab; this.tabs.set(config.id, config); if (config?.hasChildren) this.updateChildTabs(config.id); if (config?.updateURL) this.updateURL(config); if (config.ui.select) this.maybeUpdateSelect(tab, config); this.a11y.announce(`Switched to ${tab} tab`); } updateChildTabs(id) { Array.from(this.tabs.values()).filter(conf => conf.parent === id).forEach(inst => { let firstBtn = inst.ui.buttons[0].dataset.tab??false; if (firstBtn) { this.switchTab(firstBtn, inst); } }); } updateURL(config) { if (!config.updateURL) return; let hash = this.checkAncestorsHash(config); if (hash) { window.history.pushState({tab:config.activeTab},'',`?tab=${hash}`); } } checkAncestorsHash(conf) { const parts = []; let current = conf; while (current) { parts.unshift(current.activeTab); current = current.parent ? this.tabs.get(current.parent) : null; } return parts.join('|'); } maybeUpdateSelect(tab, config) { if (!config.ui.select || !Object.hasOwn(config, 'options')) return; config.options.forEach(option => { if (option.value === tab) { config.ui.select.value = tab; return; } }); } /** * Event system */ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data) { this.subscribers.forEach(cb => cb(event, data)); } /************************************************************** UTILITY **************************************************************/ /** * * @param {HTMLElement} target * @returns {object|boolean} */ getConfig(target) { const instance = target.closest('[data-tabs-id]'); if (!instance) return false; const config = this.tabs.get(instance.dataset.tabsId); if (!config) return false; return config; } isInTabs(target, config) { return config.ui.sections.some(section => section.contains(target)); } /************************************************************** CLEANUP **************************************************************/ destroy() { this.subscribers.clear(); Array.from(this.tabs.values()).forEach(tab => { tab.ui.nav.removeEventListener('click', this.clickHandler); tab.ui.select?.removeEventListener('change', this.changeHandler); }); } } document.addEventListener('DOMContentLoaded', function() { window.jvbTabs = new TabsContainer(); });