Jake Vanderwerf
2026-05-31 d7e7d248cbe41cd7a9ef9c2fb022b6c4831f99a3
assets/js/concise/Tabs.js
@@ -2,227 +2,275 @@
 * 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();
});