inpage-navigation.js

import Stickyfill from 'stickyfilljs';
import Gumshoe from 'gumshoejs/dist/gumshoe.polyfills';
import { queryOne, queryAll } from '@ecl/dom-utils';
import EventManager from '@ecl/event-manager';
import { createFocusTrap } from 'focus-trap';

/**
 * @param {HTMLElement} element DOM element for component instantiation and scope
 * @param {Object} options
 * @param {String} options.stickySelector Selector for sticky inpage navigation element
 * @param {String} options.containerSelector Selector for inpage navigation container element
 * @param {String} options.inPageList Selector for inpage navigation list element
 * @param {String} options.spySelector Selector for inpage navigation spied element
 * @param {String} options.toggleSelector Selector for inpage navigation trigger element
 * @param {String} options.linksSelector Selector for inpage navigation link element
 * @param {String} options.spyActiveContainer Selector for inpage navigation container to spy on element
 * @param {String} options.spyClass Selector to spy on
 * @param {String} options.spyTrigger
 * @param {Number} options.spyOffset
 * @param {Boolean} options.attachClickListener Whether or not to bind click events
 * @param {Boolean} options.attachKeyListener Whether or not to bind click events
 * @param {Boolean} options.attachResizeListener Whether or not to bind resize events
 * @param {Boolean} options.attachScrollListener Whether or not to bind scroll events
 */
export class InpageNavigation {
  /**
   * @static
   * Shorthand for instance creation and initialisation.
   *
   * @param {HTMLElement} root DOM element for component instantiation and scope
   *
   * @return {InpageNavigation} An instance of InpageNavigation.
   */
  static autoInit(root, { INPAGE_NAVIGATION: defaultOptions = {} } = {}) {
    const inpageNavigation = new InpageNavigation(root, defaultOptions);
    inpageNavigation.init();
    root.ECLInpageNavigation = inpageNavigation;
    return inpageNavigation;
  }

  /**
   * An array of supported events for this component.
   *
   * @type {Array<string>}
   * @event onToggle
   *   Triggered when the list is toggled in mobile
   * @event onClick
   *   Triggered when an item is clicked
   * @memberof InpageNavigation
   */
  supportedEvents = ['onToggle', 'onClick'];

  constructor(
    element,
    {
      stickySelector = '[data-ecl-inpage-navigation]',
      containerSelector = '[data-ecl-inpage-navigation-container]',
      inPageList = '[data-ecl-inpage-navigation-list]',
      spySelector = '[data-ecl-inpage-navigation-link]',
      toggleSelector = '[data-ecl-inpage-navigation-trigger]',
      linksSelector = '[data-ecl-inpage-navigation-link]',
      spyActiveContainer = 'ecl-inpage-navigation--visible',
      spyOffset = 20,
      spyClass = 'ecl-inpage-navigation__item--active',
      spyTrigger = '[data-ecl-inpage-navigation-trigger-current]',
      attachClickListener = true,
      attachResizeListener = true,
      attachScrollListener = true,
      attachKeyListener = true,
      contentClass = 'inpage-navigation__heading--active',
    } = {},
  ) {
    // 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();

    this.attachClickListener = attachClickListener;
    this.attachKeyListener = attachKeyListener;
    this.attachResizeListener = attachResizeListener;
    this.attachScrollListener = attachScrollListener;
    this.stickySelector = stickySelector;
    this.containerSelector = containerSelector;
    this.toggleSelector = toggleSelector;
    this.linksSelector = linksSelector;
    this.inPageList = inPageList;
    this.spyActiveContainer = spyActiveContainer;
    this.spySelector = spySelector;
    this.spyOffset = spyOffset;
    this.spyClass = spyClass;
    this.spyTrigger = spyTrigger;
    this.contentClass = contentClass;
    this.gumshoe = null;
    this.observer = null;
    this.stickyObserver = null;
    this.isExpanded = false;
    this.toggleElement = null;
    this.navLinks = null;
    this.resizeTimer = null;

    // Bind `this` for use in callbacks
    this.handleClickOnToggler = this.handleClickOnToggler.bind(this);
    this.handleClickOnLink = this.handleClickOnLink.bind(this);
    this.handleKeyboard = this.handleKeyboard.bind(this);
    this.initScrollSpy = this.initScrollSpy.bind(this);
    this.initObserver = this.initObserver.bind(this);
    this.activateScrollSpy = this.activateScrollSpy.bind(this);
    this.deactivateScrollSpy = this.deactivateScrollSpy.bind(this);
    this.destroySticky = this.destroySticky.bind(this);
    this.destroyScrollSpy = this.destroyScrollSpy.bind(this);
    this.destroyObserver = this.destroyObserver.bind(this);
    this.openList = this.openList.bind(this);
    this.closeList = this.closeList.bind(this);
    this.setListHeight = this.setListHeight.bind(this);
    this.handleResize = this.handleResize.bind(this);
  }

  // ACTIONS
  /**
   * Initiate sticky behaviors.
   */
  initSticky() {
    this.stickyInstance = new Stickyfill.Sticky(this.element);
  }

  /**
   * Destroy sticky behaviors.
   */
  destroySticky() {
    if (this.stickyInstance) {
      this.stickyInstance.remove();
    }
  }

  /**
   * Initiate scroll spy behaviors.
   */
  initScrollSpy() {
    this.gumshoe = new Gumshoe(this.spySelector, {
      navClass: this.spyClass,
      contentClass: this.contentClass,
      offset: this.spyOffset,
      reflow: true,
    });

    document.addEventListener('gumshoeActivate', this.activateScrollSpy, false);
    document.addEventListener(
      'gumshoeDeactivate',
      this.deactivateScrollSpy,
      false,
    );

    if ('IntersectionObserver' in window) {
      const navigationContainer = queryOne(this.containerSelector);

      if (navigationContainer) {
        let previousY = 0;
        let previousRatio = 0;
        let initialized = false;

        this.stickyObserver = new IntersectionObserver(
          (entries) => {
            if (entries && entries[0]) {
              const entry = entries[0];
              const currentY = entry.boundingClientRect.y;
              const currentRatio = entry.intersectionRatio;
              const { isIntersecting } = entry;

              if (!initialized) {
                initialized = true;
                previousY = currentY;
                previousRatio = currentRatio;
                return;
              }

              if (currentY < previousY) {
                if (!(currentRatio > previousRatio && isIntersecting)) {
                  // Scrolling down leave
                  this.element.classList.remove(this.spyActiveContainer);
                }
              } else if (currentY > previousY && isIntersecting) {
                if (currentRatio > previousRatio) {
                  // Scrolling up enter
                  this.element.classList.add(this.spyActiveContainer);
                }
              }

              previousY = currentY;
              previousRatio = currentRatio;
            }
          },
          { root: null },
        );

        // observing a target element
        this.stickyObserver.observe(navigationContainer);
      }
    }
  }

  /**
   * Activate scroll spy behaviors.
   *
   * @param {Event} event
   */
  activateScrollSpy(event) {
    const navigationTitle = queryOne(this.spyTrigger);

    this.element.classList.add(this.spyActiveContainer);
    navigationTitle.textContent = event.detail.content.textContent;
  }

  /**
   * Deactivate scroll spy behaviors.
   */
  deactivateScrollSpy() {
    const navigationTitle = queryOne(this.spyTrigger);

    this.element.classList.remove(this.spyActiveContainer);
    navigationTitle.innerHTML = '';
  }

  /**
   * Destroy scroll spy behaviors.
   */
  destroyScrollSpy() {
    if (this.stickyObserver) {
      this.stickyObserver.disconnect();
    }

    document.removeEventListener(
      'gumshoeActivate',
      this.activateScrollSpy,
      false,
    );
    document.removeEventListener(
      'gumshoeDeactivate',
      this.deactivateScrollSpy,
      false,
    );
    this.gumshoe.destroy();
  }

  /**
   * Initiate observer.
   */
  initObserver() {
    if ('MutationObserver' in window) {
      const self = this;
      this.observer = new MutationObserver((mutationsList) => {
        const body = queryOne('.ecl-col-l-9');
        const currentInpage = queryOne('[data-ecl-inpage-navigation-list]');

        mutationsList.forEach((mutation) => {
          // Exclude the changes we perform.
          if (
            mutation &&
            mutation.target &&
            mutation.target.classList &&
            !mutation.target.classList.contains(
              'ecl-inpage-navigation__trigger-current',
            )
          ) {
            // Added nodes.
            if (mutation.addedNodes.length > 0) {
              [].slice.call(mutation.addedNodes).forEach((addedNode) => {
                if (addedNode.tagName === 'H2' && addedNode.id) {
                  const H2s = queryAll('h2[id]', body);
                  const addedNodeIndex = H2s.findIndex(
                    (H2) => H2.id === addedNode.id,
                  );
                  const element =
                    currentInpage.childNodes[addedNodeIndex - 1].cloneNode(
                      true,
                    );
                  element.childNodes[0].textContent = addedNode.textContent;
                  element.childNodes[0].href = `#${addedNode.id}`;
                  currentInpage.childNodes[addedNodeIndex - 1].after(element);
                }
              });
            }
            // Removed nodes.
            if (mutation.removedNodes.length > 0) {
              [].slice.call(mutation.removedNodes).forEach((removedNode) => {
                if (removedNode.tagName === 'H2' && removedNode.id) {
                  currentInpage.childNodes.forEach((item) => {
                    if (
                      item.childNodes[0].href.indexOf(removedNode.id) !== -1
                    ) {
                      // Remove the element from the inpage.
                      item.remove();
                    }
                  });
                }
              });
            }

            self.update();
          }
        });
      });

      this.observer.observe(document, {
        subtree: true,
        childList: true,
      });
    }
  }

  /**
   * Destroy observer.
   */
  destroyObserver() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }

  /**
   * Initialise component.
   */
  init() {
    if (!ECL) {
      throw new TypeError('Called init but ECL is not present');
    }
    ECL.components = ECL.components || new Map();

    this.toggleElement = queryOne(this.toggleSelector, this.element);
    this.navLinks = queryAll(this.linksSelector, this.element);
    this.currentList = queryOne(this.inPageList, this.element);
    this.direction = getComputedStyle(this.element).direction;

    if (this.direction === 'rtl') {
      this.element.classList.add('ecl-inpage-navigation--rtl');
    }

    this.setListHeight();
    this.initSticky(this.element);
    this.initScrollSpy();
    this.initObserver();

    // Create focus trap
    this.focusTrap = createFocusTrap(this.element, {
      onActivate: () => this.openList(),
      onDeactivate: () => this.closeList(),
    });

    if (this.attachClickListener && this.toggleElement) {
      this.toggleElement.addEventListener('click', this.handleClickOnToggler);
    }
    if (this.attachResizeListener) {
      window.addEventListener('resize', this.handleResize);
    }
    if (this.attachScrollListener) {
      window.addEventListener('scroll', this.handleResize);
    }
    if (this.attachClickListener && this.navLinks) {
      this.navLinks.forEach((link) =>
        link.addEventListener('click', this.handleClickOnLink),
      );
      this.element.addEventListener('keydown', this.handleShiftTab);
      this.toggleElement.addEventListener('click', this.handleClickOnToggler);
    }

    document.addEventListener('keydown', this.handleKeyboard);

    // 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 InpageNavigation
   * @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 InpageNavigation
   */
  trigger(eventName, eventData) {
    this.eventManager.trigger(eventName, eventData);
  }

  /**
   * Update scroll spy instance.
   */
  update() {
    this.gumshoe.setup();
  }

  /**
   * Open mobile list link.
   */
  openList() {
    this.currentList.classList.add('ecl-inpage-navigation__list--visible');
    this.toggleElement.setAttribute('aria-expanded', 'true');
  }

  /**
   * Close mobile list link.
   */
  closeList() {
    this.currentList.classList.remove('ecl-inpage-navigation__list--visible');
    this.toggleElement.setAttribute('aria-expanded', 'false');
  }

  /**
   * Calculate the available space for the dropwdown and set a max-height on the list
   */
  setListHeight() {
    const viewportHeight = window.innerHeight;
    const viewportWidth = window.innerWidth;
    const listTitle = queryOne('.ecl-inpage-navigation__title', this.element);

    let topPosition = 0;
    // Mobile
    setTimeout(() => {
      if (viewportWidth < 996) {
        const toggleWrapper = this.toggleElement.parentElement;
        if (toggleWrapper) {
          // EC has currently a negative margin set on the wrapper.
          topPosition =
            toggleWrapper.getBoundingClientRect().bottom +
            parseFloat(window.getComputedStyle(toggleWrapper).marginBottom);
        }
      } else if (listTitle) {
        // If we have a title in desktop
        topPosition = listTitle.getBoundingClientRect().bottom;
      } else {
        // Get the list position if there is no title
        topPosition = this.element.getBoundingClientRect().top;
      }
      const availableSpace = viewportHeight - topPosition;
      if (availableSpace > 0) {
        this.currentList.style.maxHeight = `${availableSpace}px`;
      }
    }, 100);
  }

  /**
   * Invoke event listeners on toggle click.
   *
   * @param {Event} e
   */
  handleClickOnToggler(e) {
    e.preventDefault();

    if (this.toggleElement) {
      // Get current status
      this.isExpanded =
        this.toggleElement.getAttribute('aria-expanded') === 'true';

      // Toggle the expandable/collapsible
      this.toggleElement.setAttribute(
        'aria-expanded',
        this.isExpanded ? 'false' : 'true',
      );
      if (this.isExpanded) {
        // Untrap focus
        this.focusTrap.deactivate();
      } else {
        this.setListHeight();
        // Trap focus
        this.focusTrap.activate();

        // Focus first item
        if (this.navLinks && this.navLinks.length > 0) {
          this.navLinks[0].focus();
        }
      }

      this.trigger('onToggle', { isExpanded: this.isExpanded });
    }
  }

  /**
   * Sets the necessary attributes to collapse inpage navigation list.
   *
   * @param {Event} e
   */
  handleClickOnLink(e) {
    const { href } = e.target;
    let heading = null;

    if (href) {
      const id = href.split('#')[1];

      if (id) {
        heading = queryOne(`#${id}`, document);
      }
    }

    // Untrap focus
    this.focusTrap.deactivate();

    const eventData = { target: heading || href, e };
    this.trigger('onClick', eventData);
  }

  /**
   * Trigger events on resize
   * Uses a debounce, for performance
   */
  handleResize() {
    clearTimeout(this.resizeTimer);
    this.resizeTimer = setTimeout(() => {
      this.setListHeight();
    }, 100);
  }

  /**
   * Handle keyboard
   *
   * @param {Event} e
   */
  handleKeyboard(e) {
    const element = e.target;

    if (e.key === 'ArrowUp') {
      e.preventDefault();
      if (element === this.navLinks[0]) {
        this.handleClickOnToggler(e);
      } else {
        const prevItem = element.parentElement.previousSibling;
        if (
          prevItem &&
          prevItem.classList.contains('ecl-inpage-navigation__item')
        ) {
          const prevLink = queryOne(this.linksSelector, prevItem);
          if (prevLink) {
            prevLink.focus();
          }
        }
      }
    }

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      if (element === this.toggleElement) {
        this.handleClickOnToggler(e);
      } else {
        const nextItem = element.parentElement.nextSibling;
        if (
          nextItem &&
          nextItem.classList.contains('ecl-inpage-navigation__item')
        ) {
          const nextLink = queryOne(this.linksSelector, nextItem);
          if (nextLink) {
            nextLink.focus();
          }
        }
      }
    }
  }

  /**
   * Destroy component instance.
   */
  destroy() {
    if (this.attachClickListener && this.toggleElement) {
      this.toggleElement.removeEventListener(
        'click',
        this.handleClickOnToggler,
      );
    }
    if (this.attachClickListener && this.navLinks) {
      this.navLinks.forEach((link) =>
        link.removeEventListener('click', this.handleClickOnLink),
      );
    }
    if (this.attachKeyListener) {
      document.removeEventListener('keydown', this.handleKeyboard);
    }
    if (this.attachResizeListener) {
      window.removeEventListener('resize', this.handleResize);
    }
    this.destroyScrollSpy();
    this.destroySticky();
    this.destroyObserver();

    if (this.element) {
      this.element.removeAttribute('data-ecl-auto-initialized');
      ECL.components.delete(this.element);
    }
  }
}

export default InpageNavigation;