table.js

import { queryAll, queryOne } from '@ecl/dom-utils';
import getSystem from '@ecl/builder/utils/getSystem';
import iconSvgAllArrow from '@ecl/resources-icons/dist/svg/all/solid-arrow.svg';

const system = getSystem();
const iconSvgAllArrowSize = system === 'eu' ? 'm' : 'xs';

/**
 * @param {HTMLElement} element DOM element for component instantiation and scope
 * @param {Object} options
 * @param {String} options.sortSelector Selector for toggling element
 * @param {String} options.sortLabelSelector Selector for sorting button label
 * @param {Boolean} options.attachClickListener
 */
export class Table {
  /**
   * @static
   * Shorthand for instance creation and initialisation.
   *
   * @param {HTMLElement} root DOM element for component instantiation and scope
   *
   * @return {Table} An instance of table.
   */
  static autoInit(root, { TABLE: defaultOptions = {} } = {}) {
    const table = new Table(root, defaultOptions);
    table.init();
    root.ECLTable = table;
    return table;
  }

  constructor(
    element,
    {
      sortSelector = '[data-ecl-table-sort-toggle]',
      sortLabelSelector = 'data-ecl-table-sort-label',
    } = {},
  ) {
    // Check element
    if (!element || element.nodeType !== Node.ELEMENT_NODE) {
      throw new TypeError(
        'DOM element should be given to initialize this widget.',
      );
    }

    this.element = element;

    // Options
    this.sortSelector = sortSelector;
    this.sortLabelSelector = sortLabelSelector;

    // Private variables
    this.sortHeadings = null;

    // Bind `this` for use in callbacks
    this.handleClickOnSort = this.handleClickOnSort.bind(this);
  }

  /**
   * @returns {HTMLElement}
   */
  static createSortIcon(customClass) {
    const tempElement = document.createElement('span');
    tempElement.innerHTML = iconSvgAllArrow; // avoiding the use of not-so-stable createElementNs
    const svg = tempElement.children[0];
    svg.removeAttribute('height');
    svg.removeAttribute('width');
    svg.setAttribute('focusable', false);
    svg.setAttribute('aria-hidden', true);
    // The following element is <path> which does not support classList API as others.
    svg.setAttribute(
      'class',
      `ecl-table__icon ecl-icon ecl-icon--${iconSvgAllArrowSize} ${customClass}`,
    );
    return svg;
  }

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

    this.sortHeadings = queryAll(this.sortSelector, this.element);

    // Add sort arrows and bind click event on toggles.
    if (this.sortHeadings) {
      this.sortHeadings.forEach((tr) => {
        const sort = document.createElement('button');
        sort.classList.add('ecl-table__arrow');
        if (this.element.hasAttribute(this.sortLabelSelector)) {
          sort.setAttribute(
            'aria-label',
            this.element.getAttribute(this.sortLabelSelector),
          );
        }
        sort.appendChild(Table.createSortIcon('ecl-table__icon-up'));
        sort.appendChild(Table.createSortIcon('ecl-table__icon-down'));
        tr.appendChild(sort);
        tr.addEventListener('click', (e) => this.handleClickOnSort(tr)(e));
      });
    }

    // Set default row order via dataset.
    const tbody = queryOne('tbody', this.element);
    [...queryAll('tr', tbody)].forEach((tr, index) => {
      tr.setAttribute('data-ecl-table-order', index);
    });

    // Set ecl initialized attribute
    this.element.setAttribute('data-ecl-auto-initialized', 'true');
    ECL.components.set(this.element, this);
  }

  /**
   * Destroy component.
   */
  destroy() {
    if (this.sortHeadings) {
      this.sortHeadings.forEach((tr) => {
        tr.removeEventListener('click', (e) => this.handleClickOnSort(tr)(e));
      });
    }
    if (this.element) {
      this.element.removeAttribute('data-ecl-auto-initialized');
      ECL.components.delete(this.element);
    }
  }

  /**
   * @param {HTMLElement} toggle Target element to toggle.
   */
  handleClickOnSort = (toggle) => (event) => {
    event.preventDefault();
    const table = toggle.closest('table');
    const tbody = queryOne('tbody', table);
    let order = toggle.getAttribute('aria-sort');

    // Get current column index, taking into account the colspan.
    let colIndex = 0;
    let prev = toggle.previousElementSibling;
    while (prev) {
      colIndex += prev.getAttribute('colspan')
        ? Number(prev.getAttribute('colspan'))
        : 1;
      prev = prev.previousElementSibling;
    }

    // Cell comparer function.
    const comparer = (idx, asc) => (a, b) =>
      ((v1, v2) =>
        v1 !== '' && v2 !== '' && !Number.isNaN(+v1) && !Number.isNaN(+v2)
          ? v1 - v2
          : v1.toString().localeCompare(v2))(
        (asc ? a : b).children[idx].textContent,
        (asc ? b : a).children[idx].textContent,
      );

    if (order === 'descending') {
      // If current order is 'descending' reset column filter sort rows by default order.
      [...queryAll('tr', tbody)].forEach((tr, index) => {
        const defaultTr = queryOne(`[data-ecl-table-order='${index}']`, tbody);
        tbody.appendChild(defaultTr);
      });
      order = null;
    } else {
      // Otherwise we sort the rows and set new order.
      [...queryAll('tr', tbody)]
        .sort(comparer(colIndex, order !== 'ascending'))
        .forEach((tr) => tbody.appendChild(tr));
      order = order === 'ascending' ? 'descending' : 'ascending';
    }

    // Change heading aria-sort attr.
    this.sortHeadings.forEach((th) => {
      if (order && th === toggle) {
        th.setAttribute('aria-sort', order);
      } else {
        th.removeAttribute('aria-sort');
      }
    });
  };
}

export default Table;