import { queryOne, queryAll } from '@ecl/dom-utils';
import EventManager from '@ecl/event-manager';
/**
* @param {HTMLElement} element DOM element for component instantiation and scope
* @param {Object} options
* @param {String} options.containerSelector Selector for container element
* @param {String} options.listSelector Selector for list element
* @param {String} options.listItemsSelector Selector for tabs element
* @param {String} options.moreButtonSelector Selector for more button element
* @param {String} options.moreLabelSelector Selector for more button label element
* @param {String} options.prevSelector Selector for prev element
* @param {String} options.nextSelector Selector for next element
* @param {Boolean} options.attachClickListener
* @param {Boolean} options.attachResizeListener
*/
export class Tabs {
/**
* @static
* Shorthand for instance creation and initialisation.
*
* @param {HTMLElement} root DOM element for component instantiation and scope
*
* @return {Tabs} An instance of Tabs.
*/
static autoInit(root, { TABS: defaultOptions = {} } = {}) {
const tabs = new Tabs(root, defaultOptions);
tabs.init();
root.ECLTabs = tabs;
return tabs;
}
/**
* An array of supported events for this component.
*
* @type {Array<string>}
* @memberof Select
*/
supportedEvents = ['onToggle'];
constructor(
element,
{
containerSelector = '.ecl-tabs__container',
listSelector = '.ecl-tabs__list',
listItemsSelector = '.ecl-tabs__item:not(.ecl-tabs__item--more)',
moreItemSelector = '.ecl-tabs__item--more',
moreButtonSelector = '.ecl-tabs__toggle',
moreLabelSelector = '.ecl-tabs__toggle .ecl-button__label',
prevSelector = '.ecl-tabs__prev',
nextSelector = '.ecl-tabs__next',
attachClickListener = true,
attachResizeListener = true,
} = {},
) {
// Check element
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
throw new TypeError(
'DOM element should be given to initialize this widget.',
);
}
this.element = element;
this.eventManager = new EventManager();
// Options
this.containerSelector = containerSelector;
this.listSelector = listSelector;
this.listItemsSelector = listItemsSelector;
this.moreItemSelector = moreItemSelector;
this.moreButtonSelector = moreButtonSelector;
this.moreLabelSelector = moreLabelSelector;
this.prevSelector = prevSelector;
this.nextSelector = nextSelector;
this.attachClickListener = attachClickListener;
this.attachResizeListener = attachResizeListener;
// Private variables
this.container = null;
this.list = null;
this.listItems = null;
this.moreItem = null;
this.moreButton = null;
this.moreButtonActive = false;
this.moreLabel = null;
this.moreLabelValue = null;
this.dropdown = null;
this.dropdownList = null;
this.dropdownItems = null;
this.allowShift = true;
this.buttonNextSize = 0;
this.index = 0;
this.total = 0;
this.tabsKey = [];
this.firstTab = null;
this.lastTab = null;
this.direction = 'ltr';
this.isMobile = false;
this.resizeTimer = null;
// Bind `this` for use in callbacks
this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
this.handleResize = this.handleResize.bind(this);
this.closeMoreDropdown = this.closeMoreDropdown.bind(this);
this.shiftTabs = this.shiftTabs.bind(this);
this.handleKeyboardOnTabs = this.handleKeyboardOnTabs.bind(this);
this.moveFocus = this.moveFocus.bind(this);
this.arrowFocusToTab = this.arrowFocusToTab.bind(this);
this.tabsKeyEvents = this.tabsKeyEvents.bind(this);
}
/**
* Initialise component.
*/
init() {
if (!ECL) {
throw new TypeError('Called init but ECL is not present');
}
ECL.components = ECL.components || new Map();
this.container = queryOne(this.containerSelector, this.element);
this.list = queryOne(this.listSelector, this.element);
this.listItems = queryAll(this.listItemsSelector, this.element);
this.moreItem = queryOne(this.moreItemSelector, this.element);
this.moreButton = queryOne(this.moreButtonSelector, this.element);
this.moreLabel = queryOne(this.moreLabelSelector, this.element);
this.moreLabelValue = this.moreLabel.innerText;
this.btnPrev = queryOne(this.prevSelector, this.element);
this.btnNext = queryOne(this.nextSelector, this.element);
this.total = this.listItems.length;
if (this.moreButton) {
// Create the "more" dropdown and clone existing list items
this.dropdown = document.createElement('div');
this.dropdown.classList.add('ecl-tabs__dropdown');
this.dropdownList = document.createElement('div');
this.dropdownList.classList.add('ecl-tabs__dropdown-list');
this.listItems.forEach((item) => {
this.dropdownList.appendChild(item.cloneNode(true));
});
this.dropdown.appendChild(this.dropdownList);
this.moreItem.appendChild(this.dropdown);
this.dropdownItems = queryAll(
'.ecl-tabs__dropdown .ecl-tabs__item',
this.element,
);
}
if (this.btnNext) {
this.buttonNextSize = this.btnNext.getBoundingClientRect().width;
}
this.handleResize();
// Bind events
if (this.attachClickListener && this.moreButton) {
this.moreButton.addEventListener('click', this.handleClickOnToggle);
}
if (this.attachClickListener && document && this.moreButton) {
document.addEventListener('click', this.closeMoreDropdown);
}
if (this.attachClickListener && this.btnNext) {
this.btnNext.addEventListener(
'click',
this.shiftTabs.bind(this, 'next', true),
);
}
if (this.attachClickListener && this.btnPrev) {
this.btnPrev.addEventListener(
'click',
this.shiftTabs.bind(this, 'prev', true),
);
}
if (this.attachResizeListener) {
window.addEventListener('resize', this.handleResize);
}
// Set ecl initialized attribute
this.element.setAttribute('data-ecl-auto-initialized', 'true');
ECL.components.set(this.element, this);
}
/**
* Register a callback function for a specific event.
*
* @param {string} eventName - The name of the event to listen for.
* @param {Function} callback - The callback function to be invoked when the event occurs.
* @returns {void}
* @memberof Tabs
* @instance
*
* @example
* // Registering a callback for the 'onToggle' event
* inpage.on('onToggle', (event) => {
* console.log('Toggle event occurred!', event);
* });
*/
on(eventName, callback) {
this.eventManager.on(eventName, callback);
}
/**
* Trigger a component event.
*
* @param {string} eventName - The name of the event to trigger.
* @param {any} eventData - Data associated with the event.
*
* @memberof Tabs
*/
trigger(eventName, eventData) {
this.eventManager.trigger(eventName, eventData);
}
/**
* Destroy component.
*/
destroy() {
if (this.dropdown) {
this.dropdown.remove();
}
if (this.moreButton) {
this.moreLabel.textContent = this.moreLabelValue;
this.moreButton.replaceWith(this.moreButton.cloneNode(true));
}
if (this.btnNext) {
this.btnNext.replaceWith(this.btnNext.cloneNode(true));
}
if (this.btnPrev) {
this.btnPrev.replaceWith(this.btnPrev.cloneNode(true));
}
if (this.attachClickListener && document && this.moreButton) {
document.removeEventListener('click', this.closeMoreDropdown);
}
if (this.attachResizeListener) {
window.removeEventListener('resize', this.handleResize);
}
if (this.tabsKey) {
this.tabsKey.forEach((item) => {
item.addEventListener('keydown', this.handleKeyboardOnTabs);
});
}
if (this.element) {
this.element.removeAttribute('data-ecl-auto-initialized');
ECL.components.delete(this.element);
}
}
/**
* Action to shift next or previous tabs on mobile format.
* @param {int|string} dir
*/
shiftTabs(dir) {
this.index = dir === 'next' ? this.index + 1 : this.index - 1;
// Show or hide prev or next button based on tab index
if (this.index >= 1) {
this.btnPrev.style.display = 'flex';
this.container.classList.add('ecl-tabs__container--left');
} else {
this.btnPrev.style.display = 'none';
this.container.classList.remove('ecl-tabs__container--left');
}
if (this.index >= this.total - 1) {
this.btnNext.style.display = 'none';
this.container.classList.remove('ecl-tabs__container--right');
} else {
this.btnNext.style.display = 'flex';
this.container.classList.add('ecl-tabs__container--right');
}
// Slide tabs
let newOffset = 0;
this.direction = getComputedStyle(this.element).direction;
if (this.direction === 'rtl') {
newOffset = Math.ceil(
this.list.offsetWidth -
this.listItems[this.index].offsetLeft -
this.listItems[this.index].offsetWidth,
);
} else {
newOffset = Math.ceil(this.listItems[this.index].offsetLeft);
}
const maxScroll = Math.ceil(
this.list.getBoundingClientRect().width -
this.element.getBoundingClientRect().width,
);
if (newOffset > maxScroll) {
this.btnNext.style.display = 'none';
this.container.classList.remove('ecl-tabs__container--right');
newOffset = maxScroll;
}
this.list.style.transitionDuration = '0.4s';
if (this.direction === 'rtl') {
this.list.style.transform = `translate3d(${newOffset}px, 0px, 0px)`;
} else {
this.list.style.transform = `translate3d(-${newOffset}px, 0px, 0px)`;
}
}
/**
* Toggle the "more" dropdown.
*/
handleClickOnToggle(e) {
this.dropdown.classList.toggle('ecl-tabs__dropdown--show');
this.moreButton.setAttribute(
'aria-expanded',
this.dropdown.classList.contains('ecl-tabs__dropdown--show'),
);
this.trigger('onToggle', e);
}
/**
* Sets the callback function to be executed on toggle.
* @param {Function} callback - The callback function to be set.
*/
set onToggle(callback) {
this.onToggleCallback = callback;
}
/**
* Gets the callback function set for toggle events.
* @returns {Function|null} - The callback function, or null if not set.
*/
get onToggle() {
return this.onToggleCallback;
}
/**
* Trigger events on resize.
*/
handleResize() {
// Close dropdown if more button is not displayed
if (window.getComputedStyle(this.moreButton).display === 'none') {
this.closeMoreDropdown(this);
}
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
this.list.style.transform = `translate3d(0px, 0px, 0px)`;
// Behaviors for mobile format
const vw = Math.max(
document.documentElement.clientWidth || 0,
window.innerWidth || 0,
);
if (vw <= 480) {
this.isMobile = true;
this.index = 1;
this.list.style.transitionDuration = '0.4s';
this.shiftTabs(this.index);
if (this.moreItem) {
this.moreItem.classList.add('ecl-tabs__item--hidden');
}
if (this.moreButton) {
this.moreButton.classList.add('ecl-tabs__toggle--hidden');
}
let listWidth = 0;
this.listItems.forEach((item) => {
item.classList.remove('ecl-tabs__item--hidden');
listWidth += Math.ceil(item.getBoundingClientRect().width);
});
this.list.style.width = `${listWidth}px`;
this.btnNext.style.display = 'flex';
this.container.classList.add('ecl-tabs__container--right');
this.btnPrev.style.display = 'none';
this.container.classList.remove('ecl-tabs__container--left');
this.tabsKeyEvents();
return;
}
this.isMobile = false;
// Behaviors for Tablet and desktop format (More button)
this.btnNext.style.display = 'none';
this.container.classList.remove('ecl-tabs__container--right');
this.btnPrev.style.display = 'none';
this.container.classList.remove('ecl-tabs__container--left');
this.list.style.width = 'auto';
// Hide items that won't fit in the list
let stopWidth = this.moreButton.getBoundingClientRect().width + 25;
const hiddenItems = [];
const listWidth = this.list.getBoundingClientRect().width;
this.moreButtonActive = false;
this.listItems.forEach((item, i) => {
item.classList.remove('ecl-tabs__item--hidden');
if (
listWidth >= stopWidth + item.getBoundingClientRect().width &&
!hiddenItems.includes(i - 1)
) {
stopWidth += item.getBoundingClientRect().width;
} else {
item.classList.add('ecl-tabs__item--hidden');
if (item.childNodes[0].classList.contains('ecl-tabs__link--active')) {
this.moreButtonActive = true;
}
hiddenItems.push(i);
}
});
// Add active class to the more button if it contains an active element
if (this.moreButtonActive) {
this.moreButton.classList.add('ecl-tabs__toggle--active');
} else {
this.moreButton.classList.remove('ecl-tabs__toggle--active');
}
// Toggle the visibility of More button and items in dropdown
if (!hiddenItems.length) {
this.moreItem.classList.add('ecl-tabs__item--hidden');
this.moreButton.classList.add('ecl-tabs__toggle--hidden');
} else {
this.moreItem.classList.remove('ecl-tabs__item--hidden');
this.moreButton.classList.remove('ecl-tabs__toggle--hidden');
this.moreLabel.textContent = this.moreLabelValue.replace(
'%d',
hiddenItems.length,
);
this.dropdownItems.forEach((item, i) => {
if (!hiddenItems.includes(i)) {
item.classList.add('ecl-tabs__item--hidden');
} else {
item.classList.remove('ecl-tabs__item--hidden');
}
});
}
this.tabsKeyEvents();
}, 100);
}
/**
* Bind key events on tabs for accessibility.
*/
tabsKeyEvents() {
this.tabsKey = [];
this.listItems.forEach((item, index, array) => {
let tab = null;
if (!item.classList.contains('ecl-tabs__item--hidden')) {
tab = queryOne('.ecl-tabs__link', item);
} else {
const dropdownItem = this.dropdownItems[index];
tab = queryOne('.ecl-tabs__link', dropdownItem);
}
tab.addEventListener('keydown', this.handleKeyboardOnTabs);
this.tabsKey.push(tab);
if (index === 0) {
this.firstTab = tab;
}
if (index === array.length - 1) {
this.lastTab = tab;
}
});
}
/**
* Close the dropdown.
* @param {Event} e
*/
closeMoreDropdown(e) {
let el = e.target;
while (el) {
if (el === this.moreButton) {
return;
}
el = el.parentNode;
}
this.moreButton.setAttribute('aria-expanded', false);
this.dropdown.classList.remove('ecl-tabs__dropdown--show');
}
/**
* @param {Event} e
*/
handleKeyboardOnTabs(e) {
const tgt = e.currentTarget;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
this.arrowFocusToTab(tgt, 'prev');
break;
case 'ArrowRight':
case 'ArrowDown':
this.arrowFocusToTab(tgt, 'next');
break;
case 'Home':
this.moveFocus(this.firstTab);
break;
case 'End':
this.moveFocus(this.lastTab);
break;
default:
}
}
/**
* @param {HTMLElement} currentTab tab element
*/
moveFocus(currentTab) {
if (currentTab.closest('.ecl-tabs__dropdown')) {
this.moreButton.setAttribute('aria-expanded', true);
this.dropdown.classList.add('ecl-tabs__dropdown--show');
} else {
this.moreButton.setAttribute('aria-expanded', false);
this.dropdown.classList.remove('ecl-tabs__dropdown--show');
}
currentTab.focus();
}
/**
* @param {HTMLElement} currentTab tab element
* @param {string} direction key arrow direction
*/
arrowFocusToTab(currentTab, direction) {
let index = this.tabsKey.indexOf(currentTab);
index = direction === 'next' ? index + 1 : index - 1;
const startTab = direction === 'next' ? this.firstTab : this.lastTab;
const endTab = direction === 'next' ? this.lastTab : this.firstTab;
if (this.isMobile) {
if (currentTab !== endTab) {
this.moveFocus(this.tabsKey[index]);
this.shiftTabs(direction);
}
return;
}
if (currentTab === endTab) {
this.moveFocus(startTab);
} else {
this.moveFocus(this.tabsKey[index]);
}
}
}
export default Tabs;