Reference Source

coral-spectrum/coral-component-table/src/scripts/Table.js

/**
 * Copyright 2019 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {BaseComponent} from '../../../coral-base-component';
import {DragAction} from '../../../coral-dragaction';
import TableColumn from './TableColumn';
import TableCell from './TableCell';
import TableRow from './TableRow';
import TableHead from './TableHead';
import TableBody from './TableBody';
import TableFoot from './TableFoot';
import '../../../coral-component-button';
import {Checkbox} from '../../../coral-component-checkbox';
import base from '../templates/base';
import {SelectableCollection} from '../../../coral-collection';
import {
  isTableHeaderCell,
  isTableCell,
  isTableRow,
  isTableBody,
  getCellByIndex,
  getColumns,
  getCells,
  getContentCells,
  getHeaderCells,
  getRows,
  getSiblingsOf,
  getIndexOf,
  divider
} from './TableUtil';
import {transform, validate, commons, i18n, Keys} from '../../../coral-utils';

const CLASSNAME = '_coral-Table-wrapper';

/**
 Enumeration for {@link Table} variants

 @typedef {Object} TableVariantEnum

 @property {String} DEFAULT
 A default table.
 @property {String} QUIET
 A quiet table with transparent borders and background.
 @property {String} LIST
 Not supported. Falls back to DEFAULT.
 */
const variant = {
  DEFAULT: 'default',
  QUIET: 'quiet',
  LIST: 'list'
};

const ALL_VARIANT_CLASSES = [];
for (const variantValue in variant) {
  ALL_VARIANT_CLASSES.push(`${CLASSNAME}--${variant[variantValue]}`);
}

const IS_DISABLED = 'is-disabled';
const IS_SORTED = 'is-sorted';
const IS_UNSELECTABLE = 'is-unselectable';
const IS_FIRST_ITEM_DRAGGED = 'is-draggedFirstItem';
const IS_LAST_ITEM_DRAGGED = 'is-draggedLastItem';
const IS_DRAGGING_CLASS = 'is-dragging';
const IS_BEFORE_CLASS = 'is-before';
const IS_AFTER_CLASS = 'is-after';
const IS_LAYOUTING = 'is-layouting';
const IS_READY = 'is-ready';
const KEY_SPACE = Keys.keyToCode('space');

/**
 @class Coral.Table
 @classdesc A Table component is a container component to display and manipulate data in two dimensions.
 To define table actions on specific elements, handles can be used.
 A handle is given a special attribute :
 - <code>[coral-table-select]</code>. Select/unselect all table items.
 - <code>[coral-table-rowselect]</code>. Select/unselect the table item.
 - <code>[coral-table-roworder]</code>. Drag to order the table item.
 - <code>[coral-table-rowlock]</code>. Lock/unlock the table item.
 @htmltag coral-table
 @htmlbasetag table
 @extends {HTMLTableElement}
 @extends {BaseComponent}
 */
class Table extends BaseComponent(HTMLTableElement) {
  /** @ignore */
  constructor() {
    super();

    // Templates
    this._elements = {
      head: this.querySelector('thead[is="coral-table-head"]') || new TableHead(),
      body: this.querySelector('tbody[is="coral-table-body"]') || new TableBody(),
      foot: this.querySelector('tfoot[is="coral-table-foot"]') || new TableFoot(),
      columns: this.querySelector('colgroup') || document.createElement('colgroup')
    };
    base.call(this._elements, {commons});

    // Events
    this._delegateEvents({
      // Table specific
      'global:coral-commons:_webfontactive': '_resetLayout',
      'change [coral-table-select]': '_onSelectAll',
      'capture:scroll [handle="container"]': '_onScroll',

      // Head specific
      'click thead[is="coral-table-head"] th[is="coral-table-headercell"]': '_onHeaderCellSort',
      'coral-dragaction:dragstart thead[is="coral-table-head"] th[is="coral-table-headercell"]': '_onHeaderCellDragStart',
      'coral-dragaction:drag thead[is="coral-table-head"] tr[is="coral-table-row"] > th[is="coral-table-headercell"]': '_onHeaderCellDrag',
      'coral-dragaction:dragend thead[is="coral-table-head"] tr[is="coral-table-row"] > th[is="coral-table-headercell"]': '_onHeaderCellDragEnd',
      // a11y
      'key:enter th[is="coral-table-headercell"]': '_onHeaderCellSort',
      'key:enter th[is="coral-table-headercell"] coral-table-headercell-content': '_onHeaderCellSort',
      'key:space th[is="coral-table-headercell"]': '_onHeaderCellSort',
      'key:space th[is="coral-table-headercell"] coral-table-headercell-content': '_onHeaderCellSort',

      // Body specific
      'click tbody[is="coral-table-body"] [coral-table-rowlock]': '_onRowLock',
      'click tbody[is="coral-table-body"] [coral-table-rowselect]': '_onRowSelect',
      'click tbody[is="coral-table-body"] tr[is="coral-table-row"][selectable] [coral-table-cellselect]': '_onCellSelect',
      'capture:mousedown tbody[is="coral-table-body"] [coral-table-roworder]:not([disabled])': '_onRowOrder',
      'capture:touchstart tbody[is="coral-table-body"] [coral-table-roworder]:not([disabled])': '_onRowOrder',
      'coral-dragaction:dragstart tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowDragStart',
      'coral-dragaction:drag tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowDrag',
      'coral-dragaction:dragover tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowDragOver',
      'coral-dragaction:dragend tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowDragEnd',
      // a11y
      'mousedown tbody[is="coral-table-body"] [coral-table-rowselect]': '_onRowDown',
      'key:enter tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowSelect',
      'key:space tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowSelect',
      'key:pageup tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onFocusPreviousItem',
      'key:pagedown tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onFocusNextItem',
      'key:left tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onFocusPreviousItem',
      'key:right tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onFocusNextItem',
      'key:up tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onFocusPreviousItem',
      'key:down tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onFocusNextItem',
      'key:home tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onFocusFirstItem',
      'key:end tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onFocusLastItem',
      'key:shift+pageup tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onSelectPreviousItem',
      'key:shift+pagedown tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onSelectNextItem',
      'key:shift+left tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onSelectPreviousItem',
      'key:shift+right tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onSelectNextItem',
      'key:shift+up tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onSelectPreviousItem',
      'key:shift+down tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onSelectNextItem',

      // Private
      'coral-table-row:_multiplechanged': '_onRowMultipleChanged',
      'coral-table-row:_beforeselectedchanged': '_onBeforeRowSelectionChanged',
      'coral-table-row:_selectedchanged': '_onRowSelectionChanged',
      'coral-table-row:_lockedchanged': '_onRowLockedChanged',
      'coral-table-row:_change': '_onRowChange',
      'coral-table-row:_contentchanged': '_onRowContentChanged',
      'coral-table-headercell:_contentchanged': '_resetLayout',
      'coral-table-head:_contentchanged': '_onHeadContentChanged',
      'coral-table-body:_contentchanged': '_onBodyContentChanged',
      'coral-table-body:_empty': '_onBodyEmpty',
      'coral-table-column:_alignmentchanged': '_onAlignmentChanged',
      'coral-table-column:_fixedwidthchanged': '_onFixedWidthChanged',
      'coral-table-column:_orderablechanged': '_onColumnOrderableChanged',
      'coral-table-column:_sortablechanged': '_onColumnSortableChanged',
      'coral-table-column:_sortabledirectionchanged': '_onColumnSortableDirectionChanged',
      'coral-table-column:_hiddenchanged': '_onColumnHiddenChanged',
      'coral-table-column:_beforecolumnsort': '_onBeforeColumnSort',
      'coral-table-column:_sort': '_onColumnSort',
      'coral-table-head:_stickychanged': '_onHeadStickyChanged'
    });

    // Required for coral-table:change event
    this._oldSelection = [];
    // References selected items in their selection order and is only used for keyboard selection
    this._lastSelectedItems = {
      items: [],
      direction: null
    };

    // Don't sort by default
    this._allowSorting = false;

    // Debounce timer
    this._timeout = null;
    // Debounce wait in milliseconds
    this._wait = 50;

    // Used by resizing detector
    this._resetLayout = this._resetLayout.bind(this);
    // Init observer
    this._toggleObserver(true);
  }

  /**
   The head of the table.

   @type {TableHead}
   @contentzone
   */
  get head() {
    return this._getContentZone(this._elements.head);
  }

  set head(value) {
    this._setContentZone('head', value, {
      handle: 'head',
      tagName: 'thead',
      insert: function (head) {
        // Using the native table API allows to position the head element at the correct position.
        this._elements.table.tHead = head;

        // To init the head observer
        head.setAttribute('_observe', 'on');
      }
    });
  }

  /**
   The body of the table. Multiple bodies are not supported.

   @type {TableBody}
   @contentzone
   */
  get body() {
    return this._getContentZone(this._elements.body);
  }

  set body(value) {
    this._setContentZone('body', value, {
      handle: 'body',
      tagName: 'tbody',
      insert: function (body) {
        this._elements.table.appendChild(body);
        this.items._container = body;

        // To init the body observer
        body.setAttribute('_observe', 'on');
      }
    });
  }

  /**
   The foot of the table.

   @type {TableFoot}
   @contentzone
   */
  get foot() {
    return this._getContentZone(this._elements.foot);
  }

  set foot(value) {
    this._setContentZone('foot', value, {
      handle: 'foot',
      tagName: 'tfoot',
      insert: function (foot) {
        // Using the native table API allows to position the foot element at the correct position.
        this._elements.table.tFoot = foot;
      }
    });
  }

  /**
   The columns of the table.

   @type {TableColumn}
   @contentzone
   */
  get columns() {
    return this._getContentZone(this._elements.columns);
  }

  set columns(value) {
    this._setContentZone('columns', value, {
      handle: 'columns',
      tagName: 'colgroup',
      insert: function (content) {
        this._elements.table.appendChild(content);
      }
    });
  }

  /**
   The table's variant. See {@link TableVariantEnum}.

   @type {String}
   @default TableVariantEnum.DEFAULT
   @htmlattribute variant
   @htmlattributereflected
   */
  get variant() {
    return this._variant || variant.DEFAULT;
  }

  set variant(value) {
    value = transform.string(value).toLowerCase();
    this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
    this._reflectAttribute('variant', this._variant);

    this.classList.remove(...ALL_VARIANT_CLASSES);
    this.classList.add(`${CLASSNAME}--${this._variant}`);
  }

  /**
   Whether the items are selectable.

   @type {Boolean}
   @default false
   @htmlattribute selectable
   @htmlattributereflected
   */
  get selectable() {
    return this._selectable || false;
  }

  set selectable(value) {
    this._selectable = transform.booleanAttr(value);
    this._reflectAttribute('selectable', this._selectable);

    const rows = getRows([this.body]);

    if (this._selectable) {
      rows.forEach((row) => {
        row.setAttribute('_selectable', '');
      });
    } else {
      // Clear selection
      rows.forEach((row) => {
        row.removeAttribute('_selectable');
      });

      this.trigger('coral-table:change', {
        selection: [],
        oldSelection: this._oldSelection
      });

      // Sync used collection
      this._oldSelection = [];
      this._lastSelectedItems.items = [];
    }

    // a11y
    this._toggleFocusable();
  }

  /**
   Whether the table is orderable. If the table is sorted, ordering handles are hidden.

   @type {Boolean}
   @default false
   @htmlattribute orderable
   @htmlattributereflected
   */
  get orderable() {
    return this._orderable || false;
  }

  set orderable(value) {
    this._orderable = transform.booleanAttr(value);
    this._reflectAttribute('orderable', this._orderable);

    getRows([this.body]).forEach((row) => {
      row[this._orderable ? 'setAttribute' : 'removeAttribute']('_orderable', '');
    });

    // a11y
    this._toggleFocusable();
  }

  /**
   Whether multiple items can be selected.

   @type {Boolean}
   @default false
   @htmlattribute multiple
   @htmlattributereflected
   */
  get multiple() {
    return this._multiple || false;
  }

  set multiple(value) {
    this._multiple = transform.booleanAttr(value);
    this._reflectAttribute('multiple', this._multiple);

    this._elements.table.setAttribute('aria-multiselectable', this._multiple);

    // Deselect all except last
    if (!this.multiple) {
      const selection = this.selectedItems;

      if (selection.length > 1) {
        selection.forEach((row, i) => {
          // Don't trigger too many events
          row.set('selected', i === selection.length - 1, true);
        });

        // Synchronise the table select handle
        const newSelection = this.selectedItems;

        if (newSelection.length) {
          this._setSelectAllHandleState('indeterminate');
        } else {
          this._setSelectAllHandleState('unchecked');
        }

        this.trigger('coral-table:change', {
          selection: newSelection,
          oldSelection: selection
        });

        // Sync used collection
        this._oldSelection = newSelection;
        this._lastSelectedItems.items = newSelection;
      }
    }
  }

  /**
   Whether the table rows can be locked/unlocked. If rows are locked, they float to the top of the table and aren't
   affected by column sorting.

   @type {Boolean}
   @default false
   @htmlattribute lockable
   @htmlattributereflected
   */
  get lockable() {
    return this._lockable || false;
  }

  set lockable(value) {
    this._lockable = transform.booleanAttr(value);
    this._reflectAttribute('lockable', this._lockable);

    getRows([this.body]).forEach((row) => {
      row[this._lockable ? 'setAttribute' : 'removeAttribute']('_lockable', '');
    });

    // a11y
    this._toggleFocusable();
  }

  /**
   Specifies <code>aria-labelledby</code> value.

   @type {?String}
   @default null
   @htmlattribute labelledby
   */
  get labelledBy() {
    return this._elements.table.getAttribute('aria-labelledby');
  }

  set labelledBy(value) {
    value = transform.string(value);

    this._elements.table[value ? 'setAttribute' : 'removeAttribute']('aria-labelledby', value);
  }

  /**
   Specifies <code>aria-label</code> value.

   @type {String}
   @default null
   @htmlattribute labelled
   */
  get labelled() {
    return this._elements.table.getAttribute('aria-label');
  }

  set labelled(value) {
    value = transform.string(value);

    this._elements.table[value ? 'setAttribute' : 'removeAttribute']('aria-label', value);
  }

  /**
   Returns an Array containing the selected items.

   @type {Array.<HTMLElement>}
   @readonly
   */
  get selectedItems() {
    return this.items._getAllSelected();
  }

  /**
   Returns the first selected item of the table. The value <code>null</code> is returned if no element is
   selected.

   @type {HTMLElement}
   @readonly
   */
  get selectedItem() {
    return this.items._getFirstSelected();
  }

  /**
   The Collection Interface that allows interacting with the items that the component contains.

   @type {SelectableCollection}
   @readonly
   */
  get items() {
    // Construct the collection on first request
    if (!this._items) {
      this._items = new SelectableCollection({
        host: this,
        container: this.body,
        itemBaseTagName: 'tr',
        itemTagName: 'coral-table-row'
      });
    }

    return this._items;
  }

  /** @private */
  _onSelectAll(event) {
    if (this.selectable) {
      let rows = this._getSelectableItems();

      if (rows.length) {
        if (this.multiple) {
          const selected = event.target.checked;

          rows.forEach((row) => {
            // Don't trigger too many events
            row.set('selected', selected, true);
          });

          rows = selected ? rows : [];

          // Synchronise the table select handle
          this._setSelectAllHandleState(selected ? 'checked' : 'unchecked');

          this.trigger('coral-table:change', {
            selection: rows,
            oldSelection: this._oldSelection
          });

          // Sync used collection
          this._oldSelection = rows;
          this._lastSelectedItems.items = rows;
        } else {
          // Only select last item
          const lastItem = rows[rows.length - 1];
          lastItem.selected = !lastItem.selected;
        }
      }
    }
  }

  _triggerChangeEvent() {
    if (!this._preventTriggeringEvents) {
      const selectedItems = this.selectedItems;
      this.trigger('coral-table:change', {
        oldSelection: this._oldSelection,
        selection: selectedItems
      });

      this._oldSelection = selectedItems;
    }
  }

  /** @private */
  _onRowOrder(event) {
    const table = this;
    const row = event.target.closest('tr[is="coral-table-row"]');

    if (row && table.orderable) {
      const head = table.head;
      const body = table.body;
      const sticky = head && head.sticky;
      const style = row.getAttribute('style');
      const index = getIndexOf(row);
      const oldBefore = row.nextElementSibling;
      const dragAction = new DragAction(row);
      const items = getRows([body]);
      const tableBoundingClientRect = table.getBoundingClientRect();
      const rowBoundingClientRect = row.getBoundingClientRect();

      if (row === items[0]) {
        table.classList.add(IS_FIRST_ITEM_DRAGGED);
      } else if (row === items[items.length - 1]) {
        table.classList.add(IS_LAST_ITEM_DRAGGED);
      }

      dragAction.axis = 'vertical';
      // Handle the scroll in table
      dragAction.scroll = false;
      // Specify selection handle directly on the row if none found
      dragAction.handle = row.querySelector('[coral-table-roworder]');

      // The row placeholder indicating where the dragged element will be dropped
      const placeholder = row.cloneNode(true);
      placeholder.classList.add('_coral-Table-row--placeholder');

      // Prepare the row position before inserting its placeholder
      row.style.top = `${rowBoundingClientRect.top - tableBoundingClientRect.top}px`;

      // Prevent change event from triggering if the cloned node is selected
      table._preventTriggeringEvents = true;
      body.insertBefore(placeholder, row.nextElementSibling);
      window.requestAnimationFrame(() => {
        table._preventTriggeringEvents = false;
      });

      // Store the data to avoid re-reading the layout on drag events
      const dragData = {
        placeholder: placeholder,
        index: index,
        oldBefore: oldBefore,
        // Backup styles to restore them later
        style: {
          row: style
        }
      };

      // Required to handle the scrolling of the sticky table on drag events
      if (sticky) {
        dragData.sticky = sticky;
        dragData.tableTop = tableBoundingClientRect.top;
        dragData.tableSize = tableBoundingClientRect.height;
        dragData.headSize = parseFloat(table._elements.container.style.marginTop);
        dragData.dragElementSize = rowBoundingClientRect.height;
      }

      row.dragAction._dragData = dragData;
    }
  }

  /** @private */
  _onHeaderCellSort(event) {
    const table = this;
    const matchedTarget = event.matchedTarget.closest('th');

    // Don't sort if the column was dragged
    if (!matchedTarget._isDragging) {
      const column = table._getColumn(matchedTarget);
      // Only sort if actually sortable and event not defaultPrevented
      if (column && column.sortable) {
        event.preventDefault();

        column._sort();

        // Restore focus on the header cell in any case
        matchedTarget.focus();
      }
    }
  }

  /** @private */
  _onHeaderCellDragStart(event) {
    const table = this;
    const matchedTarget = event.matchedTarget;
    const dragElement = event.detail.dragElement;
    const siblingHeaderCellSelector = matchedTarget === dragElement ? 'th[is="coral-table-headercell"]' : 'th[is="coral-table-headercell"] coral-table-headercell-content';
    const tableBoundingClientRect = table.getBoundingClientRect();

    // Store the data to be used on drag events
    dragElement.dragAction._dragData = {
      draggedColumnIndex: getIndexOf(matchedTarget),
      tableLeft: tableBoundingClientRect.left,
      tableSize: tableBoundingClientRect.width,
      dragElementSize: matchedTarget.getBoundingClientRect().width,
      tableScrollWidth: table._elements.container.scrollWidth
    };

    getSiblingsOf(matchedTarget, siblingHeaderCellSelector, 'prevAll').forEach((item) => {
      item.classList.add(IS_BEFORE_CLASS);
    });

    getSiblingsOf(matchedTarget, siblingHeaderCellSelector, 'nextAll').forEach((item) => {
      item.classList.add(IS_AFTER_CLASS);
    });
  }

  /** @private */
  _onHeaderCellDrag(event) {
    const table = this;
    const container = table._elements.container;
    const matchedTarget = event.matchedTarget;
    const dragElement = event.detail.dragElement;
    const dragData = dragElement.dragAction._dragData;
    const row = matchedTarget.parentElement;
    const isHeaderCellDragged = matchedTarget === dragElement;
    const containerScrollLeft = container.scrollLeft;
    const documentScrollLeft = document.body.scrollLeft;

    // Prevent sorting on header cell click if the header cell is being dragged
    matchedTarget._isDragging = true;

    // Scroll left/right if table edge is reached
    const position = dragElement.getBoundingClientRect().left - dragData.tableLeft;
    const leftScrollLimit = 0;
    const rightScrollLimit = dragData.tableSize - dragData.dragElementSize;
    const scrollOffset = 10;

    if (position < leftScrollLimit) {
      container.scrollLeft -= scrollOffset;
    }
    // 2nd condition is required to avoid increasing the container scroll width
    else if (position > rightScrollLimit && containerScrollLeft + dragData.tableSize < dragData.tableScrollWidth) {
      container.scrollLeft += scrollOffset;
    }

    // Position sibling header cells based on the dragged element
    getHeaderCells(row).forEach((headerCell) => {
      const draggedHeaderCell = isHeaderCellDragged ? headerCell : headerCell.content;

      if (!draggedHeaderCell.classList.contains(IS_DRAGGING_CLASS)) {
        const offsetLeft = draggedHeaderCell.getBoundingClientRect().left + documentScrollLeft;
        const isAfter = event.detail.pageX < offsetLeft + draggedHeaderCell.offsetWidth / 3;

        draggedHeaderCell.classList.toggle(IS_AFTER_CLASS, isAfter);
        draggedHeaderCell.classList.toggle(IS_BEFORE_CLASS, !isAfter);

        const columnIndex = getIndexOf(headerCell);
        const dragElementIndex = getIndexOf(matchedTarget);

        // Place headercell after
        if (draggedHeaderCell.classList.contains(IS_AFTER_CLASS)) {
          if (columnIndex < dragElementIndex) {
            // Position the header cells based on their siblings position
            if (isHeaderCellDragged) {
              const nextHeaderCellWidth = draggedHeaderCell.clientWidth;
              draggedHeaderCell.style.left = `${nextHeaderCellWidth}px`;
            } else {
              const nextHeaderCell = getSiblingsOf(headerCell, 'th[is="coral-table-headercell"]', 'next');
              const nextHeaderCellLeftOffset = nextHeaderCell.getBoundingClientRect().left + documentScrollLeft;
              draggedHeaderCell.style.left = `${nextHeaderCellLeftOffset + containerScrollLeft}px`;
            }
          } else {
            draggedHeaderCell.style.left = '';
          }
        }

        // Place headerCell before
        if (draggedHeaderCell.classList.contains(IS_BEFORE_CLASS)) {
          if (columnIndex > dragElementIndex) {
            const prev = getSiblingsOf(headerCell, 'th[is="coral-table-headercell"]', 'prev');

            // Position the header cells based on their siblings position
            if (isHeaderCellDragged) {
              const beforeHeaderCellWidth = prev.clientWidth;
              draggedHeaderCell.style.left = `${-1 * (beforeHeaderCellWidth)}px`;
            } else {
              const beforeHeaderCellLeftOffset = prev.getBoundingClientRect().left + documentScrollLeft;
              draggedHeaderCell.style.left = `${beforeHeaderCellLeftOffset + containerScrollLeft}px`;
            }
          } else {
            draggedHeaderCell.style.left = '';
          }
        }
      }
    });
  }

  /** @private */
  _onHeaderCellDragEnd(event) {
    const table = this;
    const matchedTarget = event.matchedTarget;
    const dragElement = event.detail.dragElement;
    const dragData = dragElement.dragAction._dragData;
    const column = table._getColumn(matchedTarget);
    const headRows = getRows([table.head]);
    const isHeaderCellDragged = matchedTarget === dragElement;
    const row = matchedTarget.parentElement;

    // Select all cells in table body and foot given the index
    const getCellsByIndex = (cellIndex) => {
      const cellElements = [];
      const rows = getRows([table.body, table.foot]);
      rows.forEach((rowElement) => {
        const cell = getCellByIndex(rowElement, cellIndex);
        if (cell) {
          cellElements.push(cell);
        }
      });
      return cellElements;
    };

    const cells = getCellsByIndex(getIndexOf(matchedTarget));
    let before = null;
    let after = null;

    // Siblings are either header cell or header cell content based on the current sticky state
    if (isHeaderCellDragged) {
      before = row.querySelector(`th[is="coral-table-headercell"].${IS_AFTER_CLASS}`);

      after = row.querySelectorAll(`th[is="coral-table-headercell"].${IS_BEFORE_CLASS}`);
      after = after.length ? after[after.length - 1] : null;
    } else {
      before = row.querySelector(`th[is="coral-table-headercell"] > coral-table-headercell-content.${IS_AFTER_CLASS}`);
      before = before ? before.parentNode : null;

      after = row.querySelectorAll(`th[is="coral-table-headercell"] > coral-table-headercell-content.${IS_BEFORE_CLASS}`);
      after = after.length ? after[after.length - 1].parentNode : null;
    }

    // Did header cell order change ?
    const swapped = !(before && before.previousElementSibling === matchedTarget || after && after.nextElementSibling === matchedTarget);

    // Switch whole columns based on the new position of the dragged element
    if (swapped) {
      const beforeColumn = before ? table._getColumn(before) : null;

      // Trigger the event on table
      const beforeEvent = table.trigger('coral-table:beforecolumndrag', {
        column: column,
        before: beforeColumn
      });

      const oldBefore = column.nextElementSibling;

      if (!beforeEvent.defaultPrevented) {
        // Insert the headercell at the new position
        if (before) {
          const beforeIndex = getIndexOf(before);
          const beforeCells = getCellsByIndex(beforeIndex);
          cells.forEach((cell, i) => {
            cell.parentNode.insertBefore(cell, beforeCells[i]);
          });

          // Sync <coral-table-column> by reordering it too
          const beforeCol = getColumns(table.columns)[beforeIndex];
          if (beforeCol && column) {
            table.columns.insertBefore(column, beforeCol);
          }

          row.insertBefore(matchedTarget, before);
        }
        if (after) {
          const afterIndex = getIndexOf(after);
          const afterCells = getCellsByIndex(afterIndex);
          cells.forEach((cell, i) => {
            cell.parentNode.insertBefore(cell, afterCells[i].nextElementSibling);
          });

          // Sync <coral-table-column> by reordering it too
          const afterCol = getColumns(table.columns)[afterIndex];
          if (afterCol && column) {
            table.columns.insertBefore(column, afterCol.nextElementSibling);
          }

          row.insertBefore(matchedTarget, after.nextElementSibling);
        }

        // Trigger the order event if the column position changed
        if (dragData.draggedColumnIndex !== getIndexOf(matchedTarget)) {
          const newBefore = getColumns(table.columns)[getIndexOf(column) + 1];
          table.trigger('coral-table:columndrag', {
            column: column,
            oldBefore: oldBefore,
            before: newBefore || null
          });
        }
      }
    }

    // Restoring default header cells styling
    headRows.forEach((rowElement) => {
      getHeaderCells(rowElement).forEach((headerCell) => {
        headerCell = isHeaderCellDragged ? headerCell : headerCell.content;
        headerCell.classList.remove(IS_AFTER_CLASS);
        headerCell.classList.remove(IS_BEFORE_CLASS);
        headerCell.style.left = '';
      });
    });

    // Trigger a relayout
    table._resetLayout();

    window.requestAnimationFrame(() => {
      // Allows sorting again after dragging completed
      matchedTarget._isDragging = undefined;
      // Refocus the dragged element manually
      table._toggleElementTabIndex(dragElement, null, true);
    });
  }

  /** @private */
  _onCellSelect(event) {
    const cell = event.target.closest('td[is="coral-table-cell"]');

    if (cell) {
      cell.selected = !cell.selected;
    }
  }

  /** @private */
  _onRowSelect(event) {
    const table = this;
    const row = event.target.closest('tr[is="coral-table-row"]');

    if (row) {
      // Ignore selection if the row is locked
      if (table.lockable && row.locked) {
        return;
      }

      // Restore text-selection
      table.classList.remove(IS_UNSELECTABLE);

      // Prevent row selection when it's the selection handle and the target is an input
      if (table.selectable && (Keys.filterInputs(event) || !row.hasAttribute('coral-table-rowselect'))) {
        // Pressing space scrolls the sticky table to the bottom if scrollable
        if (event.keyCode === KEY_SPACE) {
          event.preventDefault();
        }

        if (event.shiftKey) {
          let lastSelectedItem = table._lastSelectedItems.items[table._lastSelectedItems.items.length - 1];
          const lastSelectedDirection = table._lastSelectedItems.direction;

          // If no selected items, by default set the first item as last selected item
          if (!table.selectedItem) {
            const rows = table._getSelectableItems();
            if (rows.length) {
              lastSelectedItem = rows[0];
              lastSelectedItem.set('selected', true, true);
            }
          }

          // Don't continue if table has no items or if the last selected item is the clicked item
          if (lastSelectedItem && getIndexOf(row) !== getIndexOf(lastSelectedItem)) {
            // Range selection direction
            const before = getIndexOf(row) < getIndexOf(lastSelectedItem);
            const rangeQuery = before ? 'prevUntil' : 'nextUntil';

            // Store direction
            table._lastSelectedItems.direction = before ? 'up' : 'down';

            if (!row.selected) {
              // Store selection range
              const selectionRange = getSiblingsOf(lastSelectedItem, 'tr[is="coral-table-row"]:not([selected])', rangeQuery);
              selectionRange[before ? 'push' : 'unshift'](lastSelectedItem);

              // Direction change
              if (!before && lastSelectedDirection === 'up' || before && lastSelectedDirection === 'down') {
                selectionRange.forEach((item) => {
                  item.set('selected', false, true);
                });
              }

              // Select item
              const selectionRangeRow = selectionRange[before ? 0 : selectionRange.length - 1];
              selectionRangeRow.set('selected', true, true);
              getSiblingsOf(selectionRangeRow, row, rangeQuery).forEach((item) => {
                item.set('selected', true, true);
              });
            } else {
              const selection = getSiblingsOf(lastSelectedItem, row, rangeQuery);

              // If some items are not selected
              if (selection.some((item) => !item.hasAttribute('selected'))) {
                // Select all items in between
                selection.forEach((item) => {
                  item.set('selected', true, true);
                });

                // Deselect selected item right before/after the selection range
                getSiblingsOf(row, 'tr[is="coral-table-row"]:not([selected])', rangeQuery).forEach((item) => {
                  item.set('selected', false, true);
                });
              } else {
                // Deselect items
                selection[before ? 'push' : 'unshift'](lastSelectedItem);
                selection.forEach((item) => {
                  item.set('selected', false, true);
                });
              }
            }
          }
        } else {
          // Remove direction if simple click without shift key pressed
          table._lastSelectedItems.direction = null;
        }

        // Select the row that was clicked and keep the row selected if shift key was pressed
        row.selected = event.shiftKey ? true : !row.selected;

        // Don't focus the row if the target isn't the row and focusable
        table._focusItem(row, event.target === event.matchedTarget || event.target.tabIndex < 0);
      }
    }
  }

  /** @private */
  _onRowLock(event) {
    const table = this;

    if (table.lockable) {
      const row = event.target.closest('tr[is="coral-table-row"]');
      if (row) {
        event.preventDefault();
        event.stopPropagation();
        row.locked = !row.locked;

        // Refocus the locked/unlocked item manually
        window.requestAnimationFrame(() => {
          table._focusItem(row, true);
        });
      }
    }
  }

  /** @private */
  _onRowDown(event) {
    const table = this;

    // Prevent text-selection
    if (table.selectedItem && event.shiftKey) {
      table.classList.add(IS_UNSELECTABLE);

      // @polyfill IE
      // Store text selection feature
      const onSelectStart = document.onselectstart;
      // Kill text selection feature
      document.onselectstart = () => false;
      // Restore text selection feature
      window.requestAnimationFrame(() => {
        document.onselectstart = onSelectStart;
      });
    }
  }

  /** @private */
  _onRowDragStart(event) {
    const table = this;
    const head = table.head;
    const body = table.body;
    const dragElement = event.detail.dragElement;
    const dragData = dragElement.dragAction._dragData;

    dragData.style.cells = [];
    getCells(dragElement).forEach((cell) => {
      // Backup styles to restore them later
      dragData.style.cells.push(cell.getAttribute('style'));
      // Cells will shrink otherwise
      cell.style.width = window.getComputedStyle(cell).width;
    });

    if (head && !head.sticky) {
      // @polyfill ie11
      // Element that scrolls the document.
      const scrollingElement = document.scrollingElement || document.documentElement;
      dragElement.style.top = `${dragElement.getBoundingClientRect().top + scrollingElement.scrollTop}px`;
    }
    dragElement.style.position = 'absolute';

    // Setting drop zones allows to listen for coral-dragaction:dragover event
    dragElement.dragAction.dropZone = body.querySelectorAll(`tr[is="coral-table-row"]:not(.${IS_DRAGGING_CLASS})`);

    // We cannot rely on :focus since the row is being moved in the dom while dnd
    dragElement.classList.add('is-focused');
  }

  /** @private */
  _onRowDrag(event) {
    const table = this;
    const body = table.body;
    const dragElement = event.detail.dragElement;
    const dragData = dragElement.dragAction._dragData;
    const firstRow = getRows([body])[0];

    // Insert the placeholder at the top
    if (dragElement.getBoundingClientRect().top <= firstRow.getBoundingClientRect().top) {
      table._preventTriggeringEvents = true;
      body.insertBefore(dragData.placeholder, firstRow);
      window.requestAnimationFrame(() => {
        table._preventTriggeringEvents = false;
      });
    }

    // Scroll up/down if table edge is reached
    if (dragData.sticky) {
      const dragElementTop = dragElement.getBoundingClientRect().top;
      const position = dragElementTop - dragData.tableTop - dragData.headSize;
      const topScrollLimit = 0;
      const bottomScrollLimit = dragData.tableSize - dragData.dragElementSize - dragData.headSize;
      const scrollOffset = 10;

      // Handle the scrollbar position based on the dragged element position.
      // nextFrame is required else Chrome wouldn't take scrollTop changes in account when dragging the first row down
      window.requestAnimationFrame(() => {
        if (position < topScrollLimit) {
          table._elements.container.scrollTop -= scrollOffset;
        } else if (position > bottomScrollLimit) {
          table._elements.container.scrollTop += scrollOffset;
        }
      });
    }
  }

  /** @private */
  _onRowDragOver(event) {
    const table = this;
    const body = table.body;
    const dragElement = event.detail.dragElement;
    const dropElement = event.detail.dropElement;
    const dragData = dragElement.dragAction._dragData;

    // Swap the placeholder
    if (dragElement.getBoundingClientRect().top >= dropElement.getBoundingClientRect().top) {
      table._preventTriggeringEvents = true;
      body.insertBefore(dragData.placeholder, dropElement.nextElementSibling);
      window.requestAnimationFrame(() => {
        table._preventTriggeringEvents = false;
      });
    }
  }

  /** @private */
  _onRowDragEnd(event) {
    const table = this;
    const body = table.body;
    const dragElement = event.detail.dragElement;

    const dragData = dragElement.dragAction._dragData;
    const before = dragData.placeholder.nextElementSibling;

    // Clean up
    table.classList.remove(IS_FIRST_ITEM_DRAGGED);
    table.classList.remove(IS_LAST_ITEM_DRAGGED);

    body.removeChild(dragData.placeholder);
    dragElement.dragAction.destroy();

    // Restore specific styling
    dragElement.setAttribute('style', dragData.style.row || '');
    getCells(dragElement).forEach((cell, i) => {
      cell.setAttribute('style', dragData.style.cells[i] || '');
    });

    // Trigger the event on table
    const beforeEvent = table.trigger('coral-table:beforeroworder', {
      row: dragElement,
      before: before
    });

    if (!beforeEvent.defaultPrevented) {
      // Did row order change ?
      const rows = getRows([body]).filter((item) => item !== dragElement);

      if (dragData.index !== rows.indexOf(dragData.placeholder)) {
        // Insert the row at the new position and prevent change event from triggering
        table._preventTriggeringEvents = true;
        body.insertBefore(dragElement, before);
        window.requestAnimationFrame(() => {
          table._preventTriggeringEvents = false;
        });

        // Trigger the order event if the row position changed
        table.trigger('coral-table:roworder', {
          row: dragElement,
          oldBefore: dragData.oldBefore,
          before: before
        });
      }
    }

    // Refocus the dragged element manually
    window.requestAnimationFrame(() => {
      dragElement.classList.remove('is-focused');
      table._focusItem(dragElement, true);
    });
  }

  /** @private */
  _onRowMultipleChanged(event) {
    event.stopImmediatePropagation();

    const table = this;
    const row = event.target;

    // Deselect all except last
    if (!row.multiple) {
      const selectedItems = row.selectedItems;
      table._preventTriggeringEvents = true;
      selectedItems.forEach((cell, i) => {
        cell.selected = i === selectedItems.length - 1;
      });

      window.requestAnimationFrame(() => {
        table._preventTriggeringEvents = false;

        table.trigger('coral-table:rowchange', {
          oldSelection: selectedItems,
          selection: row.selectedItems,
          row: row
        });
      });
    }
  }

  /** @private */
  _onBeforeRowSelectionChanged(event) {
    event.stopImmediatePropagation();

    // In single selection, if the added item is selected, the rest should be deselected
    const selectedItem = this.selectedItem;
    if (!this.multiple && selectedItem && !event.target.selected) {
      selectedItem.set('selected', false, true);
      this._removeLastSelectedItem(selectedItem);
    }
  }

  /** @private */
  _syncSelectAllHandle(selectedItems, items) {
    if (items.length && selectedItems.length === items.length) {
      this._setSelectAllHandleState('checked');
    } else if (!selectedItems.length) {
      this._setSelectAllHandleState('unchecked');
    } else {
      this._setSelectAllHandleState('indeterminate');
    }
  }

  /** @private */
  _setSelectAllHandleState(state) {
    const handle = this.querySelector('[coral-table-select]');

    if (handle) {

      // If the handle is a checkbox but lacks a label, label it with "Select All".
      if (handle.tagName === 'CORAL-CHECKBOX') {
        if (!handle.labelled) {
          handle.setAttribute('labelled', i18n.get('Select All'));
        }
        if (!handle.title) {
          handle.setAttribute('title', i18n.get('Select All'));
        }
      }

      if (state === 'checked') {
        handle.removeAttribute('indeterminate');
        handle.setAttribute('checked', '');
      } else if (state === 'unchecked') {
        handle.removeAttribute('indeterminate');
        handle.removeAttribute('checked');
      } else if (state === 'indeterminate') {
        handle.setAttribute('indeterminate', '');
      }
    }
  }

  /** @private */
  _onRowSelectionChanged(event) {
    event.stopImmediatePropagation();

    this._triggerChangeEvent();

    const table = this;
    const body = table.body;
    const row = event.target;

    // Synchronise the table select handle
    if (body && body.contains(row)) {
      const selection = table.selectedItems;
      const rows = table._getSelectableItems();

      // Sync select all handle
      table._syncSelectAllHandle(selection, rows);

      // Store or remove the row reference
      table[row.selected ? '_addLastSelectedItem' : '_removeLastSelectedItem'](row);

      // Store selected items range
      const lastSelectedItem = table._lastSelectedItems.items[table._lastSelectedItems.items.length - 1];
      const next = table._lastSelectedItems.direction === 'down';
      if (row.selected && lastSelectedItem && lastSelectedItem.selected && getSiblingsOf(lastSelectedItem, 'tr[is="coral-table-row"][selected]', next ? 'next' : 'prev')) {
        getSiblingsOf(lastSelectedItem, 'tr[is="coral-table-row"]:not([selected])', next ? 'nextUntil' : 'prevUntil').forEach((item) => {
          table._addLastSelectedItem(item);
        });
      }
    }
  }

  _onRowLockedChanged(event) {
    event.stopImmediatePropagation();

    const table = this;
    const body = this.body;
    const row = event.target;

    if (body && body.contains(row)) {
      if (row.locked) {
        // Store the row index as reference to place it back if unlocked and its selection state
        row._rowIndex = getIndexOf(row);

        // Insert row at first position of its tbody
        table._preventTriggeringEvents = true;
        body.insertBefore(row, getRows([body])[0]);
        window.requestAnimationFrame(() => {
          table._preventTriggeringEvents = false;
        });

        // Trigger event on table
        table.trigger('coral-table:rowlock', {row});
      } else {
        // Put the row back to its initial position
        if (row._rowIndex >= 0) {
          const beforeRow = getRows([body])[row._rowIndex];
          if (beforeRow) {
            // Insert row at its initial position
            table._preventTriggeringEvents = true;
            body.insertBefore(row, beforeRow.nextElementSibling);
            window.requestAnimationFrame(() => {
              table._preventTriggeringEvents = false;
            });
          }
        }

        // Trigger event on table
        table.trigger('coral-table:rowunlock', {row});
      }
    }
  }

  _onHeadContentChanged(event) {
    event.stopImmediatePropagation();

    const table = this;
    const head = table.head;
    const addedNodes = event.detail.addedNodes;

    for (let i = 0 ; i < addedNodes.length ; i++) {
      const node = addedNodes[i];

      // Sync header cell whether sticky or not
      if (isTableHeaderCell(node)) {
        table._toggleStickyHeaderCell(node, head.sticky);
      }
    }
  }

  /** @private */
  _onBodyContentChanged(event) {
    if (event.stopImmediatePropagation) {
      event.stopImmediatePropagation();
    }

    const table = this;
    const addedNodes = event.detail.addedNodes;
    const removedNodes = event.detail.removedNodes;
    let addedNode = null;
    const selectItem = (item) => {
      item.selected = item === addedNode;
    };
    let changed = false;

    // Sync added nodes
    for (let i = 0 ; i < addedNodes.length ; i++) {
      addedNode = addedNodes[i];

      // Sync row state with table properties
      if (isTableRow(addedNode)) {
        changed = true;

        addedNode._toggleSelectable(table.selectable);
        addedNode._toggleOrderable(table.orderable);
        addedNode._toggleLockable(table.lockable);

        // @compat
        this._toggleSelectionCheckbox(addedNode);

        const selectedItems = table.selectedItems;
        if (addedNode.selected) {
          // In single selection, if the added item is selected, the rest should be deselected
          if (!table.multiple && selectedItems.length > 1) {
            selectedItems.forEach(selectItem);
          }

          table._triggerChangeEvent();
        }

        // Cells are selectable too
        if (addedNode.selectable) {
          addedNode.trigger('coral-table-row:_contentchanged', {
            addedNodes: getContentCells(addedNode),
            removedNodes: []
          });
        }

        // Trigger collection event
        if (!table._preventTriggeringEvents) {
          table.trigger('coral-collection:add', {
            item: addedNode
          });
        }

        // a11y
        table._toggleFocusable();
      }
    }

    // Sync removed nodes
    for (let k = 0 ; k < removedNodes.length ; k++) {
      const removedNode = removedNodes[k];

      if (isTableRow(removedNode)) {
        changed = true;

        // If the focusable item is removed, the first item becomes the new focusable item
        if (removedNode.getAttribute('tabindex') === '0') {
          const firstItem = getRows([table.body])[0];
          if (firstItem) {
            table._focusItem(firstItem);
          }
        }

        if (removedNode.selected) {
          table._triggerChangeEvent();
        }

        // Sync _lastSelectedItems array
        const removedItemIndex = table._lastSelectedItems.items.indexOf(removedNode);
        if (removedItemIndex !== -1) {
          table._lastSelectedItems.items = table._lastSelectedItems.items.splice(removedItemIndex, 1);
        }

        // Trigger collection event
        if (!table._preventTriggeringEvents) {
          table.trigger('coral-collection:remove', {
            item: removedNode
          });
        }
      }
    }

    if (changed) {
      const items = this._getSelectableItems();
      // Sync select all handle if any.
      table._syncSelectAllHandle(table.selectedItems, items);
      // Disable table features if no items.
      table._toggleInteractivity(items.length === 0);
    }
  }

  /** @private */
  _onBodyEmpty(event) {
    event.stopImmediatePropagation();
    this._toggleInteractivity(true);
  }

  /** @private */
  _onRowChange(event) {
    event.stopImmediatePropagation();

    if (!this._preventTriggeringEvents) {
      this.trigger('coral-table:rowchange', {
        oldSelection: event.detail.oldSelection,
        selection: event.detail.selection,
        row: event.target
      });
    }
  }

  /** @private */
  _onRowContentChanged(event) {
    event.stopImmediatePropagation();

    const table = this;
    const row = event.target;
    const addedNodes = event.detail.addedNodes;
    let addedNode = null;
    const removedNodes = event.detail.removedNodes;
    const selectItem = (item) => {
      item.selected = item === addedNode;
    };

    // Sync added nodes
    for (let i = 0 ; i < addedNodes.length ; i++) {
      addedNode = addedNodes[i];

      // Sync row state with table properties
      if (isTableCell(addedNode)) {
        addedNode._toggleSelectable(row.selectable);

        const selectedItems = row.selectedItems;
        if (addedNode.selected) {
          // In single selection, if the added item is selected, the rest should be deselected
          if (!row.multiple && selectedItems.length > 1) {
            selectedItems.forEach(selectItem);
          }

          row._triggerChangeEvent();
        }

        // Trigger collection event
        if (!table._preventTriggeringEvents) {
          row.trigger('coral-collection:add', {
            item: addedNode
          });
        }
      }
      // Add appropriate scope depending on whether headercell is in THEAD or TBODY
      else if (isTableHeaderCell(addedNode)) {
        table._setHeaderCellScope(addedNode, row.parentNode);
      }
    }

    // Sync removed nodes
    for (let k = 0 ; k < removedNodes.length ; k++) {
      const removedNode = removedNodes[k];

      if (isTableCell(removedNode)) {
        if (removedNode.selected) {
          row._triggerChangeEvent();
        }

        // Trigger collection event
        if (!table._preventTriggeringEvents) {
          row.trigger('coral-collection:remove', {
            item: removedNode
          });
        }
      }
    }
  }

  /** @private */
  _toggleInteractivity(disable) {
    const table = this;
    const selectAll = table.querySelector('[coral-table-select]');

    if (selectAll) {
      selectAll.disabled = disable;
    }

    table.classList.toggle(IS_DISABLED, disable);
  }

  _onAlignmentChanged(event) {
    event.stopImmediatePropagation();

    this._resetAlignmentColumns();
  }

  /** @private */
  _onFixedWidthChanged(event) {
    event.stopImmediatePropagation();

    const table = this;
    const head = table.head;
    const column = event.target;

    if (head) {
      const headRows = getRows([head]);
      const columnIndex = getIndexOf(event.target);

      headRows.forEach((row) => {
        const headerCell = getCellByIndex(row, columnIndex);
        if (headerCell && headerCell.tagName === 'TH') {
          headerCell[column.fixedWidth ? 'setAttribute' : 'removeAttribute']('fixedwidth', '');
        }
      });
    }

    table._resetLayout();
  }

  /** @private */
  _onColumnOrderableChanged(event) {
    event.stopImmediatePropagation();

    const table = this;
    const head = this.head;
    const column = event.target;
    const headerCell = table._getColumnHeaderCell(column);

    if (headerCell) {
      // Move the drag handle
      table._toggleDragActionHandle(headerCell, head && head.sticky);

      table._resetLayout();
    }
  }

  /** @private */
  _onColumnSortableChanged(event) {
    event.stopImmediatePropagation();

    const table = this;
    const head = this.head;
    const column = event.target;
    const headerCell = table._getColumnHeaderCell(column);

    if (headerCell) {
      // For icons (chevron up/down) styling
      headerCell[column.sortable ? 'setAttribute' : 'removeAttribute']('sortable', '');

      // Toggle tab index. Sortable headercells are focusable.
      table._toggleHeaderCellTabIndex(headerCell, head && head.sticky);

      table._resetLayout();
    }
  }

  _onColumnSortableDirectionChanged(event) {
    event.stopImmediatePropagation();

    const table = this;
    const column = event.target;
    const sortableDirection = TableColumn.sortableDirection;

    // Hide coral-table-roworder handles if table is sorted
    table.classList.toggle(IS_SORTED, table._isSorted());

    const headerCell = table._getColumnHeaderCell(column);
    if (headerCell) {
      // For icons (chevron up/down) styling
      headerCell.setAttribute('sortabledirection', column.sortableDirection);
      (table.head.sticky ? headerCell.content : headerCell).setAttribute('aria-sort', column.sortableDirection === sortableDirection.DEFAULT ? 'none' : column.sortableDirection);

      if (column.sortableDirection === sortableDirection.DEFAULT) {
        this._elements.liveRegion.innerText = '';
      } else {
        const textContent = headerCell.content.textContent.trim();
        if (textContent.length) {
          // Set live region to true so that sort description string will be announced.
          this._elements.liveRegion.setAttribute('aria-live', 'polite');
          this._elements.liveRegion.removeAttribute('aria-hidden');
          this._elements.liveRegion.innerText = i18n.get(`sorted by column {0} in ${column.sortableDirection} order`, textContent);

          // @a11y wait 2.5 seconds to give screen reader enough time to announce the live region before silencing the it.
          window.setTimeout(() => {
            this._elements.liveRegion.setAttribute('aria-live', 'off');
            this._elements.liveRegion.setAttribute('aria-hidden', 'true');
          }, 2500);
        }
      }
    }
  }

  _onColumnHiddenChanged(event) {
    event.stopImmediatePropagation();

    this._resetHiddenColumns(true);
  }

  _onBeforeColumnSort(event) {
    event.stopImmediatePropagation();

    const table = this;
    const column = event.target;
    const newSortableDirection = event.detail.newSortableDirection;

    const beforeEvent = table.trigger('coral-table:beforecolumnsort', {
      column: column,
      direction: newSortableDirection
    });

    if (!beforeEvent.defaultPrevented) {
      column.sortableDirection = newSortableDirection;
    }
  }

  _onColumnSort(event) {
    event.stopImmediatePropagation();

    // Don't sort yet
    if (!this._allowSorting) {
      return;
    }

    const table = this;
    const body = table.body;
    const column = event.target;
    const columnIndex = getIndexOf(column);
    const colHeaderCell = table._getColumnHeaderCell(column);
    const onInitialization = event.detail.onInitialization;
    const sortableDirection = event.detail.sortableDirection;
    const sortableType = event.detail.sortableType;

    const rows = getRows([body]);
    const cells = [];

    // Prevent change event from triggering when sorting
    if (table) {
      table._preventTriggeringEvents = true;
    }

    // Store a reference of the default row index for default sortable direction
    rows.forEach((row, i) => {
      if (typeof row._defaultRowIndex === 'undefined') {
        row._defaultRowIndex = i;
      }

      const cell = getCellByIndex(row, columnIndex);
      if (cell) {
        cells.push(cell);
      }
    });

    if (column.sortableDirection === sortableDirection.ASCENDING) {
      // Remove sortable direction on sibling columns
      getSiblingsOf(column, 'col[is="coral-table-column"]').forEach((col) => {
        col._preventSort = true;
        col.setAttribute('sortabledirection', sortableDirection.DEFAULT);
        col._preventSort = false;
      });

      if (colHeaderCell) {
        // For icons (chevron up/down) styling
        getSiblingsOf(colHeaderCell, 'th[is="coral-table-headercell"]').forEach((headerCell) => {
          headerCell.setAttribute('sortabledirection', sortableDirection.DEFAULT);
          (table.head.sticky ? headerCell.content : headerCell).setAttribute('aria-sort', 'none');
        });
      }

      // Use cell value to sort and fallback if not specified
      cells.sort((a, b) => {
        if (column.sortableType === sortableType.ALPHANUMERIC) {
          const aText = a.value ? a.value : a.textContent;
          const bText = b.value ? b.value : b.textContent;
          return aText.localeCompare(bText);
        } else if (column.sortableType === sortableType.NUMBER) {
          // Remove all spaces and replace commas with dots for decimal values
          const aNumber = parseFloat(a.value ? a.value : a.textContent.replace(/\s+/g, '').replace(/,/g, '.'));
          const bNumber = parseFloat(b.value ? b.value : b.textContent.replace(/\s+/g, '').replace(/,/g, '.'));
          return aNumber > bNumber ? 1 : -1;
        } else if (column.sortableType === sortableType.DATE) {
          const aDate = a.value ? new Date(parseInt(a.value, 10)) : new Date(a.textContent);
          const bDate = b.value ? new Date(parseInt(b.value, 10)) : new Date(b.textContent);
          return aDate > bDate ? 1 : -1;
        }
      });

      // Only sort if not custom sorting
      if (column.sortableType !== sortableType.CUSTOM) {
        if (body) {
          // Insert the row at the new position if actually sorted
          cells.forEach((cell) => {
            const row = cell.parentElement;
            // Prevent locked row to be sorted
            if (!row.locked) {
              body.appendChild(row);
            }
          });
        }

        // Trigger on table
        table.trigger('coral-table:columnsort', {column});
      }

      // Table is in a sorted state. Disable orderable actions
      rows.forEach((row) => {
        if (row.dragAction) {
          row.dragAction.destroy();
        }
      });
    } else if (column.sortableDirection === sortableDirection.DESCENDING) {
      getSiblingsOf(column, 'col[is="coral-table-column"]').forEach((col) => {
        col._preventSort = true;
        col.setAttribute('sortabledirection', sortableDirection.DEFAULT);
        col._preventSort = false;
      });

      if (colHeaderCell) {
        getSiblingsOf(colHeaderCell, 'th[is="coral-table-headercell"]').forEach((headerCell) => {
          headerCell.setAttribute('sortabledirection', sortableDirection.DEFAULT);
          (table.head.sticky ? headerCell.content : headerCell).setAttribute('aria-sort', 'none');
        });
      }

      cells.sort((a, b) => {
        if (column.sortableType === sortableType.ALPHANUMERIC) {
          const aText = a.value ? a.value : a.textContent;
          const bText = b.value ? b.value : b.textContent;
          return bText.localeCompare(aText);
        } else if (column.sortableType === sortableType.NUMBER) {
          // Remove all spaces and replace commas with dots for decimal values
          const aNumber = parseFloat(a.value ? a.value : a.textContent.replace(/\s+/g, '').replace(/,/g, '.'));
          const bNumber = parseFloat(b.value ? b.value : b.textContent.replace(/\s+/g, '').replace(/,/g, '.'));
          return aNumber < bNumber ? 1 : -1;
        } else if (column.sortableType === sortableType.DATE) {
          const aDate = a.value ? new Date(parseInt(a.value, 10)) : new Date(a.textContent);
          const bDate = b.value ? new Date(parseInt(b.value, 10)) : new Date(b.textContent);
          return aDate < bDate ? 1 : -1;
        }
      });

      // Only sort if not custom sorting
      if (column.sortableType !== sortableType.CUSTOM) {
        if (body) {
          // Insert the row at the new position if actually sorted
          cells.forEach((cell) => {
            const row = cell.parentElement;
            // Prevent locked row to be sorted
            if (!row.locked) {
              body.appendChild(row);
            }
          });
        }

        // Trigger on table
        table.trigger('coral-table:columnsort', {column});
      }

      // Table is in a sorted state. Disable orderable actions
      rows.forEach((row) => {
        if (row.dragAction) {
          row.dragAction.destroy();
        }
      });
    } else if (column.sortableDirection === sortableDirection.DEFAULT && !onInitialization) {
      // Only sort if not custom sorting
      if (column.sortableType !== sortableType.CUSTOM) {
        // Put rows back to their initial position
        rows.sort((a, b) => a._defaultRowIndex > b._defaultRowIndex ? 1 : -1);

        rows.forEach((row) => {
          // Prevent locked row to be sorted
          if (body && !row.locked) {
            body.appendChild(row);
          }
        });

        // Trigger on table
        table.trigger('coral-table:columnsort', {column});
      }
    }

    // Allow triggering change events again after sorting
    window.requestAnimationFrame(() => {
      // a11y initialize column sort aria-describedby
      if (onInitialization && column.sortableDirection !== sortableDirection.DEFAULT) {
        const textContent = colHeaderCell.content.textContent.trim();
        if (textContent.length) {
          this._elements.liveRegion.innerText = i18n.get(`sorted by column {0} in ${column.sortableDirection} order`, textContent);
        }
      }

      table._preventTriggeringEvents = false;
    });
  }

  _onHeadStickyChanged(event) {
    event.stopImmediatePropagation();

    // a11y
    this._toggleFocusable();

    const table = this;
    const head = event.target;

    // Wait next frame before reading and changing header cell layout
    window.requestAnimationFrame(() => {
      // Defines the head height
      const tableHeight = head.sticky ? `${head.getBoundingClientRect().height}px` : null;
      table._resetContainerLayout(tableHeight, table._elements.container.style.height);

      getRows([head]).forEach((row) => {
        getHeaderCells(row).forEach((headerCell) => {
          table._toggleStickyHeaderCell(headerCell, head.sticky);
        });
      });

      // Make sure sticky styling is applied
      table.classList.toggle(`${CLASSNAME}--sticky`, head.sticky);

      // Layout sticky head
      table._preventResetLayout = false;
      table._resetLayout();
    });
  }

  /** @private */
  _getColumnHeaderCell(column) {
    const table = this;
    const head = table.head;
    let headerCell = null;

    if (head) {
      const headRows = getRows([head]);
      const columnIndex = getIndexOf(column);
      if (headRows.length) {
        headerCell = getCellByIndex(headRows[headRows.length - 1], columnIndex);
        headerCell = headerCell && headerCell.tagName === 'TH' ? headerCell : null;
      }
    }

    return headerCell;
  }

  /** @private */
  _getColumn(headerCell) {
    // Get the corresponding column
    return getColumns(this.columns)[getIndexOf(headerCell)] || null;
  }

  /** @private */
  _toggleStickyHeaderCell(headerCell, sticky) {
    // Set the size
    this._layoutStickyCell(headerCell, sticky);

    // Define DragAction on the sticky cell instead of the headercell
    this._toggleDragActionHandle(headerCell, sticky);

    // Toggle tab index. Sortable headercells are focusable.
    this._toggleHeaderCellTabIndex(headerCell, sticky);
  }

  _layoutStickyCell(headerCell, sticky) {
    if (sticky) {
      const computedStyle = window.getComputedStyle(headerCell);

      // Don't allow the column to shrink less than its minimum allowed
      if (!headerCell.style.minWidth) {
        let hasVisibleChildNodes = false;
        // In most cases, there's text content
        if (headerCell.textContent.trim().length) {
          hasVisibleChildNodes = true;
        }
        // Verify if there are any visible nodes without text content which could take layout space
        else {
          const headerCellChildren = headerCell.content.children;
          for (let i = 0 ; i < headerCellChildren.length && !hasVisibleChildNodes ; i++) {
            if (headerCellChildren[0].offsetParent) {
              hasVisibleChildNodes = true;
            }
          }
        }

        if (hasVisibleChildNodes) {
          const width = headerCell.content.getBoundingClientRect().width;
          // Don't set the width if the header cell is hidden
          if (width > 0) {
            headerCell.style.minWidth = `${width}px`;
          }
        }
      }

      const cellWidth = parseFloat(computedStyle.width);
      const cellPadding = parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight);
      const borderRightWidth = parseFloat(computedStyle.borderRightWidth);

      // Reflect headercell size on sticky cell
      headerCell.content.style.width = `${cellWidth + cellPadding + borderRightWidth}px`;
    } else {
      // Restore headercell style
      headerCell.style.minWidth = '';
      headerCell.content.style.width = '';
      headerCell.content.style.height = '';
      headerCell.content.style.top = '';
      headerCell.content.style.marginLeft = '';
      headerCell.content.style.paddingTop = '';
    }
  }

  /** @private */
  _toggleDragActionHandle(headerCell, sticky) {
    const column = this._getColumn(headerCell);

    if (headerCell.dragAction) {
      headerCell.dragAction.destroy();
    }
    if (headerCell.content.dragAction) {
      headerCell.content.dragAction.destroy();
    }

    if (column && column.orderable) {
      const dragAction = new DragAction(sticky ? headerCell.content : headerCell);
      dragAction.axis = 'horizontal';
      // Handle the scroll in table
      dragAction.scroll = false;
      headerCell.setAttribute('orderable', '');
    } else {
      headerCell.removeAttribute('orderable');
    }
  }

  /** @private */
  _toggleFocusable() {
    const firstItem = getRows([this.body])[0];
    if (!firstItem) {
      return;
    }

    const focusableItem = this._getFocusableItem();
    if (this.selectable || this.lockable || this.orderable || (this.head && this.head.sticky)) {
      // First item is focusable by default but don't remove the tabindex of the existing focusable item
      if (!focusableItem) {
        this._toggleElementTabIndex(firstItem);
      }
    } else if (focusableItem) {
      // Basic table is not focusable
      focusableItem.removeAttribute('tabindex');
    }
  }

  /** @private */
  _toggleElementTabIndex(element, oldFocusable, forceFocus) {
    if (oldFocusable) {
      oldFocusable.removeAttribute('tabindex');
    }

    element.setAttribute('tabindex', '0');
    if (forceFocus) {
      element.focus();
    }
  }

  /** @private */
  _toggleHeaderCellTabIndex(headerCell, sticky) {
    const column = this._getColumn(headerCell);
    const sortable = column && (column.sortable || column.orderable);
    headerCell[sortable && !sticky ? 'setAttribute' : 'removeAttribute']('tabindex', '0');
    headerCell.content[sortable && sticky ? 'setAttribute' : 'removeAttribute']('tabindex', '0');
  }

  /** @private */
  _isSorted() {
    let column = null;
    const isSorted = getColumns(this.columns).some((col) => {
      column = col;
      return col.sortableDirection !== TableColumn.sortableDirection.DEFAULT;
    });

    return isSorted ? column : false;
  }

  /** @private */
  _focusEdgeItem(event, first) {
    const items = getRows([this.body]);
    if (items.length) {
      event.preventDefault();

      let item = this._getFocusableItem();
      if (item) {
        item.removeAttribute('tabindex');
      }

      item = items[first ? 0 : items.length - 1];
      item.setAttribute('tabindex', '0');
      item.focus();
    }
  }

  /** @private */
  _focusSiblingItem(event, next) {
    const item = this._getFocusableItem();
    if (item) {
      event.preventDefault();

      const siblingItem = getSiblingsOf(item, 'tr[is="coral-table-row"]', next ? 'next' : 'prev');
      if (siblingItem) {
        item.removeAttribute('tabindex');
        siblingItem.setAttribute('tabindex', '0');
        siblingItem.focus();
      }
    }
  }

  /** @private */
  _selectSiblingItem(next) {
    if (this.selectable && this.multiple) {
      const selectedItems = this.selectedItems;
      let lastSelectedItem = this._lastSelectedItems.items[this._lastSelectedItems.items.length - 1];

      if (selectedItems.length) {
        // Prevent selection if we reached the edge
        if (next && lastSelectedItem.matches(':last-of-type') || !next && lastSelectedItem.matches(':first-of-type')) {
          return;
        }

        // Target sibling item
        const sibling = getSiblingsOf(lastSelectedItem, 'tr[is="coral-table-row"]', next ? 'next' : 'prev');
        if (!sibling.hasAttribute('selected')) {
          lastSelectedItem = sibling;
        }

        // Store last selection
        this._lastSelectedItems.direction = next ? 'down' : 'up';

        // Toggle selection
        lastSelectedItem.selected = !lastSelectedItem.selected;
      } else if (getRows([this.body]).length) {
        const focusableItem = this._getFocusableItem();

        // Store last selection
        this._lastSelectedItems.direction = next ? 'down' : 'up';

        // Select focusable item by default if no items selected
        focusableItem.selected = true;
      }
    }

    // Focus last selected item
    window.requestAnimationFrame(() => {
      const itemToFocus = this._lastSelectedItems.items[this._lastSelectedItems.items.length - 1];
      if (itemToFocus) {
        this._focusItem(itemToFocus, true);
      }
    });
  }

  /** @private */
  _getFocusableItem() {
    return this.body && this.body.querySelector('tr[is="coral-table-row"][tabindex="0"]');
  }

  /** @private */
  _getFocusableHeaderCell() {
    return this.head && this.head.querySelector('th[is="coral-table-headercell"][tabindex="0"], coral-table-headercell-label[tabindex="0"]');
  }

  /** @private */
  _addLastSelectedItem(item) {
    if (this._lastSelectedItems.items.indexOf(item) === -1) {
      this._lastSelectedItems.items.push(item);
    } else {
      // Push it at the end
      this._removeLastSelectedItem(item);
      this._addLastSelectedItem(item);
    }
  }

  /** @private */
  _removeLastSelectedItem(item) {
    this._lastSelectedItems.items.splice(this._lastSelectedItems.items.indexOf(item), 1);
  }

  /** @private */
  _focusItem(item, forceFocus) {
    this._toggleElementTabIndex(item, this._getFocusableItem(), forceFocus);
  }

  /** @private */
  _onFocusFirstItem(event) {
    this._focusEdgeItem(event, true);
  }

  /** @private */
  _onFocusLastItem(event) {
    this._focusEdgeItem(event, false);
  }

  /** @private */
  _onFocusNextItem(event) {
    this._focusSiblingItem(event, true);
  }

  /** @private */
  _onFocusPreviousItem(event) {
    this._focusSiblingItem(event, false);
  }

  /** @private */
  _onSelectNextItem() {
    this._selectSiblingItem(true);
  }

  /** @private */
  _onSelectPreviousItem() {
    this._selectSiblingItem(false);
  }

  /**
   * Call the layout method of table component
   *
   * @param {Boolean} forced
   * If true call the layout method immediately, else wait for timeout
   */
  resetLayout(forced) {
    forced = transform.boolean(forced);
    if (forced === true) {
      this._doResetLayout();
      this._preventResetLayout = false;
    } else {
      this._resetLayout();
    }
  }

  /** @private */
  _doResetLayout() {
    this.classList.add(IS_LAYOUTING);
    this._resizeStickyHead();
    this._resizeContainer();
    this.classList.remove(IS_LAYOUTING);
  }

  /** @private */
  _resetLayout() {
    if (this._preventResetLayout) {
      return;
    }

    // Debounce
    if (this._timeout !== null) {
      window.clearTimeout(this._timeout);
    }

    this._timeout = window.setTimeout(() => {
      this._timeout = null;
      this._doResetLayout();
      // Mark table as ready
      this.classList.add(IS_READY);
    }, this._wait);
  }

  /** @private */
  _resizeStickyHead() {
    const table = this;
    const head = table.head;
    if (head && head.sticky) {
      getRows([head]).forEach((row) => {
        getHeaderCells(row).forEach((headerCell) => {
          table._layoutStickyCell(headerCell, true);
        });
      });
    }
  }

  /** @private */
  _resizeContainer() {
    const table = this;
    const head = table.head;

    if (head && head.sticky) {
      let calculatedHeadSize = 0;
      let previousRowHeight = 0;

      // Reset head layout
      getRows([head]).forEach((row, i) => {
        const headerCells = getHeaderCells(row);

        if (headerCells.length) {
          const computedStyle = window.getComputedStyle(headerCells[0].content);
          let rowHeight = 0;

          const stickyHeaderCellMinHeight = parseFloat(computedStyle.minHeight);
          // Divider 'row' or 'cell'  adds a border top
          const borderTop = parseFloat(computedStyle.borderTopWidth);

          headerCells.forEach((headerCell) => {
            // Reset to default
            headerCell.content.style.height = '';
            // The highest header cell defines the row height
            rowHeight = Math.max(rowHeight, headerCell.content.getBoundingClientRect().height);
          });

          // Add the row height to the table head height
          calculatedHeadSize += rowHeight;

          headerCells.forEach((headerCell) => {
            // Expand the header cell height to the row height
            if (rowHeight - borderTop !== stickyHeaderCellMinHeight) {
              headerCell.content.style.height = `${rowHeight}px`;
            }

            // Vertically align text in sticky cell by getting the label height
            if (headerCell.content.textContent.trim().length && !headerCell.content.querySelector('coral-checkbox[coral-table-select]')) {
              const stickyHeaderCellHeight = headerCell.content.getBoundingClientRect().height;
              const span = document.createElement('span');

              // Prevents a recursive table relayout that is triggered from changing the header cell content
              table._preventResetLayout = true;

              while (headerCell.content.firstChild) {
                span.appendChild(headerCell.content.firstChild);
              }
              headerCell.content.appendChild(span);

              const labelHeight = span.getBoundingClientRect().height;
              const paddingTop = (stickyHeaderCellHeight - labelHeight) / 2;

              while (span.firstChild) {
                headerCell.content.appendChild(span.firstChild);
              }
              headerCell.content.removeChild(span);

              headerCell.content.style.paddingTop = `${paddingTop}px`;

              window.requestAnimationFrame(() => {
                table._preventResetLayout = false;
              });
            }

            // Position the sticky cell
            previousRowHeight = previousRowHeight || rowHeight;
            headerCell.content.style.top = `${i > 0 ? previousRowHeight * i + borderTop * (i - 1) : 0}px`;
          });
        }
      });

      const containerComputedStyle = window.getComputedStyle(this._elements.container);
      const borderTopWidth = parseFloat(containerComputedStyle.borderTopWidth);
      const borderBottomWidth = parseFloat(containerComputedStyle.borderBottomWidth);

      const containerBorderSize = borderTopWidth + borderBottomWidth;
      const containerMarginTop = `${calculatedHeadSize}px`;
      const containerHeight = `calc(100% - ${calculatedHeadSize + containerBorderSize}px)`;
      this._resetContainerLayout(containerMarginTop, containerHeight);
    } else {
      this._resetContainerLayout();
    }
  }

  /** @private */
  _resetContainerLayout(marginTop, height) {
    this._elements.container.style.marginTop = marginTop || '';
    this._elements.container.style.height = height || '';
  }

  /** @private */
  _resetHiddenColumns(resetLayout) {
    this.id = this.id || commons.getUID();

    // Delete styles
    this._elements.hiddenStyle.innerHTML = '';

    // Render styles for each column
    getColumns(this.columns).forEach((column) => {
      if (column.hidden) {
        const columnIndex = getIndexOf(column) + 1;

        this._elements.hiddenStyle.innerHTML += `
           #${this.id} ._coral-Table-cell:nth-child(${columnIndex}),
           #${this.id} ._coral-Table-headerCell:nth-child(${columnIndex}) {
             display: none;
           }
        `;
      }
    });

    if (resetLayout) {
      this._resetLayout();
    }
  }

  _resetAlignmentColumns() {
    this.id = this.id || commons.getUID();

    // Delete styles
    this._elements.alignmentStyle.innerHTML = '';

    getColumns(this.columns).forEach((column) => {
      const columnAlignment = column.alignment;
      const columnIndex = getIndexOf(column) + 1;

      this._elements.alignmentStyle.innerHTML += `
         #${this.id} ._coral-Table-cell:nth-child(${columnIndex}),
         #${this.id} ._coral-Table-headerCell:nth-child(${columnIndex}) {
           text-align: ${columnAlignment};
         }
      `;
    });
  }

  /** @private */
  _onScroll() {
    const table = this;
    const head = table.head;

    // Ignore if only vertical scroll
    const scrollLeft = table._elements.container.scrollLeft;
    if (table._lastScrollLeft === scrollLeft) {
      return;
    }
    table._lastScrollLeft = scrollLeft;

    if (head && head.sticky) {
      // Trigger a reflow that will reposition the sticky cells for FF only.
      head.style.margin = '1px';

      window.requestAnimationFrame(() => {
        head.style.margin = '';

        // In other browsers e.g Chrome or IE, we need to adjust the position of the sticky cells manually
        if (!table._preventLayoutStickyCellOnScroll) {
          const firstHeaderCell = head.querySelector('tr[is="coral-table-row"] th[is="coral-table-headercell"]');

          if (firstHeaderCell) {
            // Verify if the sticky cells need to be adjusted. If the first one didn't move, we can assume that they
            // all need to be adjusted. By default, the left offset is 1px because of the table border.
            if (table._layoutStickyCellOnScroll || firstHeaderCell.content.offsetLeft === 1) {
              table._layoutStickyCellOnScroll = true;

              getRows([head]).forEach((row) => {
                getHeaderCells(row).forEach((headerCell) => {
                  const paddingLeft = parseFloat(window.getComputedStyle(headerCell).paddingLeft);
                  headerCell.content.style.marginLeft = `-${scrollLeft + paddingLeft}px`;
                });
              });
            } else {
              // We don't need to layout the sticky cells manually
              table._preventLayoutStickyCellOnScroll = true;
            }
          }
        }
      });
    }
  }

  // @compat
  _toggleSelectionCheckbox(row) {
    const cells = getContentCells(row);
    const renderCheckbox = (cell, process) => {
      // Support cloneNode
      cell._checkbox = cell._checkbox || cell.querySelector('coral-checkbox');

      // Render checkbox if none
      if (!cell._checkbox) {
        cell._checkbox = new Checkbox();
      }

      process(cell._checkbox);

      // Add checkbox
      cell.insertBefore(cell._checkbox, cell.firstChild);
    };

    cells.forEach((cell, i) => {
      const isRowSelect = i === 0 && cell.hasAttribute('coral-table-rowselect');
      const isCellSelect = cell.hasAttribute('coral-table-cellselect') || cell.querySelector('coral-checkbox[coral-table-cellselect]');

      if (isRowSelect || isCellSelect) {
        let handle;
        if (isRowSelect) {
          handle = 'coral-table-rowselect';
        }
        if (isCellSelect) {
          handle = 'coral-table-cellselect';
        }

        renderCheckbox(cell, (checkbox) => {
          // Define selection handle
          if (isRowSelect) {
            cell.classList.add('_coral-Table-cell--check');
            cell.removeAttribute(handle);
            checkbox.setAttribute(handle, '');
          } else {
            cell.setAttribute(handle, '');
            checkbox.removeAttribute(handle);
          }

          // Sync selection
          const isSelected = (isRowSelect ? row : cell).hasAttribute('selected');
          checkbox[isSelected ? 'setAttribute' : 'removeAttribute']('checked', '');
        });
      }
    });
  }

  /** @private */
  _setHeaderCellScope(headerCell, tableSection) {
    // Add appropriate scope depending on whether header cell is in THEAD or TBODY
    const scope = tableSection.nodeName === 'THEAD' || tableSection.nodeName === 'TFOOT' ? 'col' : 'row';
    if (scope === 'col') {
      if (this.head && this.head.sticky) {
        headerCell.setAttribute('role', 'presentation');
        headerCell._elements.content.setAttribute('role', 'columnheader');
      } else {
        headerCell.setAttribute('role', 'columnheader');
      }
    } else {
      headerCell.setAttribute('role', 'rowheader');
    }
    headerCell.setAttribute('scope', scope);
  }

  /**  @private */
  _handleMutations(mutations) {
    mutations.forEach((mutation) => {
      // Sync removed nodes
      for (let k = 0 ; k < mutation.removedNodes.length ; k++) {
        const removedNode = mutation.removedNodes[k];

        if (isTableBody(removedNode)) {
          // Always make sure there's a body content zone
          if (!this.body) {
            this.body = new TableBody();
          }

          this._onBodyContentChanged({
            target: removedNode,
            detail: {
              addedNodes: [],
              removedNodes: getRows([removedNode])
            }
          });
        }
      }
    });

    this._resetLayout();
  }

  _getSelectableItems() {
    return this.items._getSelectableItems().filter(item => !item.querySelector('[coral-table-rowselect][disabled]'));
  }

  _toggleObserver(enable) {
    this._observer = this._observer || new MutationObserver(this._handleMutations.bind(this));

    if (enable) {
      this._observer.observe(this, {
        childList: true,
        subtree: true
      });
    } else {
      this._observer.disconnect();
    }
  }

  get _contentZones() {
    return {
      tbody: 'body',
      thead: 'head',
      tfoot: 'foot',
      colgroup: 'columns'
    };
  }

  /**
   Returns {@link Table} variants.

   @return {TableVariantEnum}
   */
  static get variant() {
    return variant;
  }

  /**
   Returns divider options for {@link TableHead}, {@link TableBody} and {@link TableFoot}.

   @return {TableSectionDividerEnum}
   */
  static get divider() {
    return divider;
  }

  static get _attributePropertyMap() {
    return commons.extend(super._attributePropertyMap, {
      labelledby: 'labelledBy'
    });
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat(['variant', 'selectable', 'orderable', 'labelled', 'labelledby', 'multiple', 'lockable']);
  }

  /** @ignore */
  render() {
    super.render();

    this.classList.add(CLASSNAME);

    // Wrapper should have role="presentation" because it wraps another table
    this.setAttribute('role', 'presentation');

    // Default reflected attribute
    if (!this._variant) {
      this.variant = variant.DEFAULT;
    }

    const head = this._elements.head;
    const body = this._elements.body;
    const foot = this._elements.foot;
    const columns = this._elements.columns;

    // Disable observer while rendering template
    this._toggleObserver(false);
    this._elements.head.setAttribute('_observe', 'off');
    this._elements.body.setAttribute('_observe', 'off');

    // Render template
    const frag = document.createDocumentFragment();
    frag.appendChild(this._elements.container);
    frag.appendChild(this._elements.liveRegion);

    // cloneNode support
    const wrapper = this.querySelector('._coral-Table-wrapper-container');
    if (wrapper) {
      wrapper.remove();
    }

    let liveRegion = this.querySelector('._coral-Table-liveRegion');
    if (liveRegion) {
      liveRegion.remove();
    }

    // Append frag
    this.appendChild(frag);

    // Call content zone inserts
    this.head = head;
    this.body = body;
    this.foot = foot;
    this.columns = columns;

    // Set header cell scope
    getRows([this._elements.table]).forEach((row) => {
      getHeaderCells(row).forEach((headerCell) => {
        this._setHeaderCellScope(headerCell, row.parentNode);
      });
    });

    // With a thead and tfoot,
    if (this.head && this.foot) {
      const headRows = getRows([this.head]);
      const footRows = getRows([this.foot]);
      // if the number of rows in the thead and tfoot match
      if (headRows.length === footRows.length) {
        let redundantFooter = true;
        // and the textContent of each thead header cell matches the textContent of each tfoot header cell in the same column
        headRows.forEach((row, rowIndex) => getHeaderCells(row).forEach((headerCell, cellIndex) => {
          const footerCell = getHeaderCells(footRows[rowIndex])[cellIndex];
          if (!footerCell || headerCell.textContent.trim() !== footerCell.textContent.trim()) {
            redundantFooter = false;
          }
        }));
        // the tfoot is redundant and should be hidden to prevent double or triple voicing of table headers.
        if (redundantFooter) {
          this.foot.setAttribute('aria-hidden', 'true');
        }
      }
    }

    // Detect table size changes
    commons.addResizeListener(this, this._resetLayout);

    // Disable table features if no items.
    const items = this._getSelectableItems();
    this._toggleInteractivity(items.length === 0);

    // Sync selection state
    if (this.selectable) {
      const selectedItems = this.selectedItems;

      // Sync select all handle if any
      this._syncSelectAllHandle(selectedItems, items);

      // Sync used collections
      if (selectedItems.length) {
        this._oldSelection = selectedItems;
        this._lastSelectedItems.items = selectedItems;
      }
    }

    // Sync sorted
    this._allowSorting = true;
    const column = this._isSorted();
    if (column) {
      column._doSort(true);
    }

    // @compat
    if (this.body) {
      const rows = getRows([this.body]);
      // Use the first column as selection column
      rows.forEach(row => this._toggleSelectionCheckbox(row));
    }

    // Enable observer again
    this._toggleObserver(true);

    // Mark table as ready
    if (!this.head || this.head && !this.head.hasAttribute('sticky')) {
      this.classList.add(IS_READY);
    }
  }

  /**
   Triggered before a {@link Table} column gets sorted by user interaction. Can be used to cancel column sorting and define
   custom sorting.

   @typedef {CustomEvent} coral-table:beforecolumnsort

   @property {TableColumn} detail.column
   The column to be sorted.
   @property {String} detail.direction
   The requested sorting direction for the column.
   */

  /**
   Triggered when a {@link Table} column is sorted.

   @typedef {CustomEvent} coral-table:columnsort

   @param {TableColumn} detail.column
   The sorted column.
   */

  /**
   Triggered before a {@link Table} column is dragged. Can be used to cancel column dragging.

   @typedef {CustomEvent} coral-table:beforecolumndrag

   @property {TableColumn} detail.column
   The dragged column.
   @property {TableColumn} detail.before
   The column will be inserted before this sibling column.
   If <code>null</code>, the column is inserted at the end.
   */

  /**
   Triggered when a {@link Table} column is dragged.

   @typedef {CustomEvent} coral-table:columndrag

   @property {TableColumn} detail.column
   The dragged column.
   @property {TableColumn} detail.oldBefore
   The column next sibling before the swap.
   If <code>null</code>, the column was the last item.
   @property {TableColumn} detail.before
   The column is inserted before this sibling column.
   If <code>null</code>, the column is inserted at the end.
   */

  /**
   Triggered before a {@link Table} row is ordered. Can be used to cancel row ordering.

   @typedef {CustomEvent} coral-table:beforeroworder

   @property {TableRow} detail.row
   The row to be ordered.
   @property {TableRow} detail.before
   The row will be inserted before this sibling row.
   If <code>null</code>, the row is inserted at the end.
   */

  /**
   Triggered when a {@link Table} row is ordered.

   @typedef {CustomEvent} coral-table:roworder

   @property {TableRow} detail.row
   The ordered row.
   @property {TableRow} detail.oldBefore
   The row next sibling before the swap.
   If <code>null</code>, the row was the last item.
   @param {TableRow} detail.before
   The row is inserted before this sibling row.
   If <code>null</code>, the row is inserted at the end.
   */

  /**
   Triggered when a {@linked Table} row is locked.

   @typedef {CustomEvent} coral-table:rowlock

   @property {TableRow} detail.row
   The locked row.
   */

  /**
   Triggered when {@link Table} a row is locked.

   @typedef {CustomEvent} coral-table:rowunlock

   @property {TableRow} detail.row
   The unlocked row.
   */

  /**
   Triggered when a {@link Table} row selection changed.

   @typedef {CustomEvent} coral-table:rowchange

   @property {Array.<TableCell>} detail.oldSelection
   The old item selection. When {@link TableRow#multiple}, it includes an Array.
   @property {Array.<TableCell>} detail.selection
   The item selection. When {@link Coral.Table.Row#multiple}, it includes an Array.
   @property {TableRow} detail.row
   The targeted row.
   */

  /**
   Triggered when the {@link Table} selection changed.

   @typedef {CustomEvent} coral-table:change

   @property {Array.<TableRow>} detail.oldSelection
   The old item selection. When {@link Table#multiple}, it includes an Array.
   @property {Array.<TableRow>} detail.selection
   The item selection. When {@link Table#multiple}, it includes an Array.
   */
}

export default Table;