@adobe/coral-spectrum
Version:
Coral Spectrum is a JavaScript library of Web Components following Spectrum design patterns.
1,465 lines (1,238 loc) • 100 kB
JavaScript
/**
* 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 {Decorator} from '../../../coral-decorator';
import {
isTableHeaderCell,
isTableCell,
isTableRow,
isTableBody,
getCellByIndex,
getColumns,
getCells,
getContentCells,
getHeaderCells,
getRows,
getSiblingsOf,
getIndexOf,
divider
} from './TableUtil';
import {events, 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}
*/
const Table = Decorator(class 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 dnd
'key:space tbody[is="coral-table-body"] [coral-table-roworder]:not([disabled])': '_onKeyboardDrag',
'click tbody[is="coral-table-body"] tr[is="coral-table-row"] [coral-table-roworder]:not([disabled])': '_onDragHandleClick',
'coral-dragaction:dragonkeyspace tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowDragOnKeySpace',
'coral-dragaction:dragoveronkeyarrowdown tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowDragOverOnKeyArrowDown',
'coral-dragaction:dragoveronkeyarrowup tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowDragOverOnKeyArrowUp',
'coral-dragaction:dragendonkey tbody[is="coral-table-body"] tr[is="coral-table-row"]': '_onRowDragOverOnKeyEnter',
// 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) {
if (events.isVirtualEvent(event)) {
return;
}
const table = this;
const row = event.target.closest('tr[is="coral-table-row"]');
if (row && table.orderable) {
if (row.dragAction && row.dragAction.handle) {
this._unwrapDragHandle(row.dragAction.handle);
}
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 dragAction = event.detail.dragElement.dragAction;
const dragData = dragAction._dragData;
const before = dragData.placeholder ? dragData.placeholder.nextElementSibling : null;
// Clean up
table.classList.remove(IS_FIRST_ITEM_DRAGGED);
table.classList.remove(IS_LAST_ITEM_DRAGGED);
if (dragData.placeholder && dragData.placeholder.parentNode) {
dragData.placeholder.parentNode.removeChild(dragData.placeholder);
}
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 */
_wrapDragHandle(handle, callback = () => {}) {
if(!handle.closest('span[role="application"]')) {
const span = document.createElement('span');
span.setAttribute('role', 'application');
span.setAttribute('aria-label', i18n.get('reordering'));
handle.parentNode.insertBefore(span, handle);
span.appendChild(handle);
handle.selected = true;
handle.setAttribute('aria-pressed', 'true');
window.requestAnimationFrame(() => callback());
}
}
/** @private */
_unwrapDragHandle(handle, callback = () => {}) {
const span = handle && handle.closest('span[role="application"]');
if (handle) {
handle.selected = false;
handle.removeAttribute('aria-pressed');
handle.removeAttribute('aria-describedby');
}
window.requestAnimationFrame(() => {
if (span) {
span.parentNode.insertBefore(handle, span);
span.remove();
}
callback();
});
}
/** @private */
_onKeyboardDrag(event) {
const table = this;
const row = event.target.closest('tr[is="coral-table-row"]');
if (row && table.orderable) {
event.preventDefault();
event.stopPropagation();
if (row.dragAction && row.dragAction.isKeyboardDragging) {
return;
}
const style = row.getAttribute('style');
const index = getIndexOf(row);
const oldBefore = row.nextElementSibling;
const dragAction = new DragAction(row);
dragAction.axis = 'vertical';
// Handle the scroll in table
dragAction.scroll = false;
// Specify selection handle directly on the row if none found
const handle = row.querySelector('[coral-table-roworder]');
dragAction.handle = handle;
// Wrap the drag handle button in a span with role="application",
// to force Windows screen readers into forms mode while dragging.
if (event.target === handle) {
this._wrapDragHandle(handle, () => handle.focus());
}
// The row placeholder indicating where the dragged element will be dropped
const placeholder = row.cloneNode(true);
placeholder.classList.add('_coral-Table-row--placeholder');
// 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
}
};
row.dragAction._dragData = dragData;
}
}
_onDragHandleClick(event) {
const row = event.target.closest('tr[is="coral-table-row"]');
if (!row.dragAction) {
this._onKeyboardDrag(event);
row.dragAction._isKeyboardDrag = true;
} else if (row.dragAction._isKeyboardDrag) {
row.dragAction._isKeyboardDrag = undefined;
}
}
/** @private */
_onRowDragOnKeySpace(event) {
event.preventDefault();
const dragElement = event.detail.dragElement;
const dragData = dragElement.dragAction._dragData;
if (dragElement.dragAction._isKeyboardDrag) {
return;
}
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;
});
}
/** @private */
_onRowDragOverOnKeyArrowDown(event) {
const table = this;
const body = table.body;
const dragElement = event.detail.dragElement;
const items = getRows([body]);
const index = getIndexOf(dragElement);
const dragData = dragElement.dragAction._dragData;
const handle = dragElement.dragAction.handle;
const rowHeader = dragElement.rowHeader;
event.preventDefault();
// We cannot rely on :focus since the row is being moved in the dom while dnd
dragElement.classList.add('is-focused');
if (dragElement === items[items.length - 1]) {
for (let position = 0; position < items.length - 1; position++) {
body.appendChild(items[position]);
}
body.insertBefore(items[0], items[items.length - 2].nextElementSibling);
} else {
body.insertBefore(items[index + 1], items[index]);
}
// Restore specific styling
dragElement.setAttribute('style', dragData.style.row || '');
getCells(dragElement).forEach((cell, i) => {
if (dragData.style.cells) {
cell.setAttribute('style', dragData.style.cells[i] || '');
}
});
if (handle) {
handle.focus();
this._announceLiveRegion((rowHeader ? rowHeader.textContent + ' ' : '') + i18n.get('reordered to row {0}', [getIndexOf(dragElement) + 1]), 'assertive');
}
dragElement.scrollIntoView({block: 'nearest'});
}
/** @private */
_onRowDragOverOnKeyArrowUp(event) {
const table = this;
const body = table.body;
const dragElement = event.detail.dragElement;
const items = getRows([body]);
const index = getIndexOf(dragElement);
const dragData = dragElement.dragAction._dragData;
const handle = dragElement.dragAction.handle;
const rowHeader = dragElement.rowHeader;
event.preventDefault();
// We cannot rely on :focus since the row is being moved in the dom while dnd
dragElement.classList.add('is-focused');
if (dragElement === items[0]) {
for (let position = 0; position < items.length - 2; position++) {
body.insertBefore(items[position + 1], items[0]);
}
body.insertBefore(items[items.length - 1], items[1]);
} else {
body.insertBefore(items[index - 1], items[index].nextElementSibling);
}
// Restore specific styling
dragElement.setAttribute('style', dragData.style.row || '');
getCells(dragElement).forEach((cell, i) => {
if (dragData.style.cells) {
cell.setAttribute('style', dragData.style.cells[i] || '');
}
});
if (handle) {
handle.focus();
this._announceLiveRegion((rowHeader ? rowHeader.textContent + ' ' : '') + i18n.get('reordered to row {0}', [getIndexOf(dragElement) + 1]), 'assertive');
}
dragElement.scrollIntoView({block: 'nearest'});
}
/** @private */
_onRowDragOverOnKeyEnter(event) {
const table = this;
const dragElement = event.detail.dragElement;
const dragAction = dragElement.dragAction;
const dragData = dragAction._dragData;
const handle = dragAction.handle;
if (dragAction._isKeyboardDrag) {
dragAction._isKeyboardDrag = undefined;
return;
}
// Trigger the event on table
const beforeEvent = table.trigger('coral-table:beforeroworder', {
row: dragElement,
before: dragData.oldBefore
});
if (!beforeEvent.defaultPrevented && dragData.oldBefore !== dragElement.nextElementSibling) {
// Trigger the order event if the row position changed
table.trigger('coral-table:roworder', {
row: dragElement,
oldBefore: dragData.oldBefore,
before: dragElement.nextElementSibling
});
}
dragAction.destroy();
const isFocusWithinDragElement = dragElement.contains(document.activeElement) || dragElement === document.activeElement;
const isFocusOnHandle = handle && handle === document.activeElement;
// Refocus the dragged element manually
const callback = () => {
dragElement.classList.remove('is-focused');
if (isFocusWithinDragElement) {
table._focusItem(dragElement, true);
}
if (isFocusOnHandle) {
handle.focus();
}
};
this._unwrapDragHandle(handle, callback);
}
/** @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 */
_onBeforeRowSelec