/**
|
* 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();
|
});
|