| | |
| | | * Handles the tabs functionality, given the html requirements are met |
| | | */ |
| | | class TabsContainer{ |
| | | constructor(container, callbacks = {}, parent = null){ |
| | | this.tabs = container.querySelector('.tabs'); |
| | | this.a11y = window.jvbA11y; |
| | | constructor() { |
| | | this.a11y = window.jvbA11y; |
| | | this.error = window.jvbError; |
| | | |
| | | this.updateURL = true; |
| | | this.subscribers = new Set(); |
| | | this.tabs = new Map(); |
| | | |
| | | this.parent = parent; |
| | | this.childTabs = new Map(); |
| | | if ('updateURL' in callbacks && callbacks.updateURL === false) { |
| | | this.updateURL = false; |
| | | } |
| | | this.callbacks = callbacks; |
| | | |
| | | |
| | | this.activeTab = (this.updateURL) ? this.getInitialTabFromHash() : |
| | | container.querySelector('button.tab.active')?.dataset.tab; |
| | | |
| | | this.container = container; |
| | | |
| | | if (this.tabs) { |
| | | this.tabs.addEventListener('click', e => { |
| | | const tab = e.target.closest('[data-tab]'); |
| | | if (tab) { |
| | | let update = ('updateURL' in this.callbacks) ? this.callbacks.updateURL : true; |
| | | this.switchTab(tab.dataset.tab, update); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // Check for and initialize child tab containers |
| | | this.initializeChildTabs(); |
| | | |
| | | // Find and set up select dropdown listeners |
| | | this.selectDropdown = document.querySelector('select.tab-list'); |
| | | if (this.selectDropdown) { |
| | | this.selectDropdown.addEventListener('change', e => { |
| | | |
| | | let update = ('updateURL' in this.callbacks) ? this.callbacks.updateURL : true; |
| | | this.switchTab(e.target.value, update); |
| | | }); |
| | | } |
| | | |
| | | let update = ('updateURL' in this.callbacks) ? this.callbacks.updateURL : true; |
| | | |
| | | if (!this.activeTab){ |
| | | this.activeTab = document.querySelector('button.tab')?.dataset.tab; |
| | | } |
| | | |
| | | this.switchTab(this.activeTab, update); |
| | | } |
| | | |
| | | /** |
| | | * Auto-detect and initialize child tab containers |
| | | */ |
| | | initializeChildTabs() { |
| | | |
| | | this.tabs.querySelectorAll('button').forEach(tab => { |
| | | let hasChildren = this.container.querySelector(`.tab-content[data-tab="${tab.dataset['tab']}"]`); |
| | | |
| | | if(hasChildren && hasChildren.querySelector('.tabs')){ |
| | | let container = this.container.querySelector(`.tab-content[data-tab="${tab.dataset['tab']}"]`); |
| | | let tabs = new window.jvbTabs(container, {updateURL: false}, this); |
| | | this.childTabs.set(tab.dataset.tab, tabs); |
| | | } |
| | | }); |
| | | // Find all tab content panels in this container |
| | | // const tabPanels = this.container.querySelectorAll('.tab-content'); |
| | | // tabPanels.forEach(panel => { |
| | | // const tabId = panel.dataset.tab; |
| | | // if (!tabId) return; |
| | | // |
| | | // // Look for nested tab containers within this panel |
| | | // const nestedTabContainers = panel.querySelectorAll('.tabs, [data-tabs]'); |
| | | // |
| | | // nestedTabContainers.forEach(nestedContainer => { |
| | | // // Create a new TabsContainer for this nested container |
| | | // const childContainer = new TabsContainer(nestedContainer, {}, this); |
| | | // |
| | | // // Store the child container |
| | | // this.childTabs.set(tabId, childContainer); |
| | | // }); |
| | | // }); |
| | | } |
| | | |
| | | /** |
| | | * Get initial tab from URL hash |
| | | * @returns {string|false} Tab identifier from hash or null if not present |
| | | */ |
| | | getInitialTabFromHash() { |
| | | if (!window.location.hash) return false; |
| | | |
| | | const hash = window.location.hash.substring(1); // Remove the # character |
| | | const pathParts = hash.split('/'); |
| | | |
| | | // If this is a root container, check first path part |
| | | if (!this.parent) { |
| | | const rootTabId = pathParts[0]; |
| | | // Check if this hash corresponds to a valid tab |
| | | const matchingTab = this.tabs.querySelector(`[data-tab="${rootTabId}"]`); |
| | | if (matchingTab) { |
| | | return rootTabId; |
| | | } |
| | | } |
| | | // If this is a child container, check if our parent's active tab matches the path |
| | | else if (this.parent && pathParts.length > 1) { |
| | | const parentIdx = this.getParentDepth(); |
| | | if (parentIdx < pathParts.length) { |
| | | const childTabId = pathParts[parentIdx]; |
| | | const matchingTab = this.tabs.querySelector(`[data-tab="${childTabId}"]`); |
| | | if (matchingTab) { |
| | | return childTabId; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Calculate the depth of this tabs container in the hierarchy |
| | | * @returns {number} The depth (0 for root, 1 for first level child, etc.) |
| | | */ |
| | | getParentDepth() { |
| | | let depth = 0; |
| | | let parent = this.parent; |
| | | |
| | | while (parent) { |
| | | depth++; |
| | | parent = parent.parent; |
| | | } |
| | | |
| | | return depth; |
| | | } |
| | | |
| | | /** |
| | | * Build the full path for this tab including all parent tabs |
| | | * @param {string} tabId - The current tab ID |
| | | * @returns {string} The full path including parent tabs |
| | | */ |
| | | getFullTabPath(tabId) { |
| | | if (!this.parent) { |
| | | return tabId; |
| | | } |
| | | |
| | | // Get parent's active tab path and append this tab |
| | | const parentPath = this.parent.getFullTabPath(this.parent.activeTab); |
| | | return `${parentPath}/${tabId}`; |
| | | } |
| | | |
| | | /** |
| | | * Switch between tabs |
| | | * @param {string} tab - Tab to switch to ('items' or 'lists') |
| | | * @param {boolean} updateHistory - Whether to push the state to the url |
| | | */ |
| | | switchTab(tab, updateHistory = false) { |
| | | document.activeElement?.blur(); |
| | | |
| | | // Update tab buttons |
| | | this.tabs.querySelectorAll('[data-tab]').forEach(tabBtn => { |
| | | tabBtn.classList.toggle('active', tabBtn.dataset.tab === tab); |
| | | tabBtn.setAttribute('aria-selected', tabBtn.dataset.tab === tab); |
| | | }); |
| | | |
| | | // Update tab panels |
| | | this.container.querySelectorAll('.tab-content').forEach(content => { |
| | | content.classList.toggle('active', content.dataset.tab === tab); |
| | | content.setAttribute('aria-hidden', content.dataset.tab !== tab); |
| | | content.hidden = content.dataset.tab !== tab; |
| | | }); |
| | | |
| | | // Update state |
| | | this.activeTab = tab; |
| | | if (this.callbacks[tab]) { |
| | | this.callbacks[tab](); |
| | | } |
| | | |
| | | // Activate first child tab if this tab has children |
| | | const childContainer = this.childTabs.get(tab); |
| | | if (childContainer) { |
| | | const firstTab = childContainer.container.querySelector('button.tab')?.dataset.tab; |
| | | if (firstTab) { |
| | | childContainer.switchTab(firstTab, false); |
| | | } |
| | | } |
| | | |
| | | // Update URL hash with full path (only from root container) |
| | | if (updateHistory) { |
| | | if (!this.parent) { |
| | | window.history.pushState({ tab: tab }, '', `#${tab}`); |
| | | } else { |
| | | // This is a child container, notify parent to update URL |
| | | this.parent.updateUrlFromChild(); |
| | | } |
| | | } |
| | | |
| | | // Update select dropdown if it exists |
| | | if (this.selectDropdown && this.selectDropdown.querySelector(`option[value="${tab}"]`)) { |
| | | this.selectDropdown.value = tab; |
| | | } |
| | | |
| | | // Announce to screen readers |
| | | this.a11y.announce(`Switched to ${tab} tab`); |
| | | this.hasHash = false; |
| | | this.init(); |
| | | } |
| | | init() { |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | } |
| | | |
| | | /** |
| | | * Update URL when a child tab changes |
| | | */ |
| | | updateUrlFromChild() { |
| | | console.log('Updating URL'); |
| | | if (('updateURL' in this.callbacks) ? this.callbacks.updateURL : true) { |
| | | if (!this.parent) { |
| | | // Only the root container should update the URL |
| | | const fullPath = this.getFullTabPath(this.activeTab); |
| | | window.history.pushState({ tab: fullPath }, '', `#${fullPath}`); |
| | | } else { |
| | | // Propagate up to the root |
| | | this.parent.updateUrlFromChild(); |
| | | 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); |
| | | }); |
| | | } |
| | | } |
| | | |
| | | window.jvbTabs = TabsContainer; |
| | | |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | window.jvbTabs = new TabsContainer(); |
| | | }); |