tabs.js

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;