UNPKG

@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
/** * 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