diff --git a/column-mover.js b/column-mover.js new file mode 100644 index 0000000..8acdefe --- /dev/null +++ b/column-mover.js @@ -0,0 +1,120 @@ +import { TpTable } from "./tp-table"; + +export default class ColumnMover { + constructor(wrap, handleSelector) { + this._handleSelector = handleSelector; + this._mouseDown = this._mouseDown.bind(this); + this._dragging = this._dragging.bind(this); + this._draggingEnd = this._draggingEnd.bind(this); + this._trackingStarted = false; + + // Amount of pixels the handle must be moved before its registered as a tracking gesture. + this.threshold = 5; + + wrap.addEventListener('mousedown', this._mouseDown, true); + } + + _mouseDown(e) { + const handle = e.composedPath().find(node => node.matches && node.matches(this._handleSelector)); + if (!handle) return; + + e.stopPropagation(); + e.preventDefault(); + this._cHandle = handle; + this._startX = e.pageX; + this._watchDrag(); + } + + _watchDrag() { + window.addEventListener('mousemove', this._dragging); + window.addEventListener('mouseup', this._draggingEnd); + } + + _stopWatchDrag() { + window.removeEventListener('mousemove', this._dragging); + window.removeEventListener('mouseup', this._draggingEnd); + } + + _dragging(e) { + if (this._trackingStarted === false && Math.abs(e.pageX - this._startX) >= this.threshold) { + this._trackingStarted = true; + this._showClone(); + this._clearSelection(); + } + + if (this._trackingStarted === true) { + this._positionClone(e.pageX, e.pageY); + this._cHandle.dispatchEvent(new CustomEvent('track-move', { detail: { target: this._cHandle, state: 'track', x: e.pageX, y: e.pageX }, bubbles: true, composed: true })); + } + } + + _draggingEnd(e) { + this._trackingStarted = false; + this._cHandle.dispatchEvent(new CustomEvent('track-move', { detail: { target: this._cHandle, state: 'end', x: e.pageX, y: e.pageX }, bubbles: true, composed: true })); + this._stopWatchDrag(); + this._cHandle = null; + this._startX = 0; + this._clearSelection(); + this._clone.remove(); + this._clone = null; + if (this._indicator) { + this._indicator.remove(); + this._indicator = null; + } + } + + _clearSelection() { + const sel = window.getSelection ? window.getSelection() : document.selection; + if (sel) { + if (sel.removeAllRanges) { + sel.removeAllRanges(); + } else if (sel.empty) { + sel.empty(); + } + } + } + + /** + * Show indicator to visualize where a column would be dropped after releasing the mouse. + * @param {number} x X-coordinate + * @param {number} y Y-coordinate + */ + showIndicator(x, y) { + if (!this._indicator) { + const icon = document.createElement('tp-icon'); + icon.icon = TpTable.downIcon; + icon.style.position = 'fixed'; + icon.style.left = '0px'; + icon.style.right = '0px'; + icon.style.zIndex = '100000'; + document.body.appendChild(icon); + this._indicator = icon; + } + + const rect = this._indicator.getBoundingClientRect(); + this._indicator.style.transform = `translate(${x - ((rect.right - rect.left) / 2)}px, ${y - rect.height}px)`; + } + + _showClone() { + const div = document.createElement('div'); + div.style.background = 'rgba(255, 255, 255, 0.6)'; + div.style.border = 'solid 1px rgba(255, 255, 255, 0.8)'; + div.style.padding = '5px 10px'; + div.style.borderRadius = '2px'; + div.style.position = 'fixed'; + div.style.zIndex = '99999'; + div.style.left = '0px'; + div.style.top = '0px'; + div.style.display = 'flex'; + div.style.alignItems = 'center'; + div.style.justifyContent = 'center'; + div.innerHTML = this._cHandle.column.label; + + this._clone = div; + document.body.appendChild(div); + } + + _positionClone(x, y) { + this._clone.style.transform = `translate(${x + 10}px, ${y}px)`; + } +} diff --git a/column-resizer.js b/column-resizer.js index 05eb9e6..7190c53 100644 --- a/column-resizer.js +++ b/column-resizer.js @@ -1,4 +1,4 @@ -export default class ColumResizer { +export default class ColumnResizer { constructor(wrap, handleSelector) { this._handleSelector = handleSelector; this._mouseDown = this._mouseDown.bind(this); diff --git a/tp-table.js b/tp-table.js index 5eb1c78..6704ff9 100644 --- a/tp-table.js +++ b/tp-table.js @@ -12,7 +12,8 @@ import './tp-table-item.js'; import { DomQuery } from '@tp/helpers/dom-query.js'; import { closest } from '@tp/helpers/closest.js'; import { LitElement, html, css, svg } from 'lit'; -import ColumResizer from './column-resizer.js'; +import ColumnResizer from './column-resizer.js'; +import ColumnMover from './column-mover.js'; export class TpTable extends DomQuery(LitElement) { static get styles() { @@ -195,7 +196,7 @@ export class TpTable extends DomQuery(LitElement) { renderTableHeader(columns) { return html` -
+
${this.selectable ? html`
this._checkedChanged(e)}>
` : null} @@ -214,7 +215,7 @@ export class TpTable extends DomQuery(LitElement) { const canSort = sortable !== false; return html` - canSort ? this._sort(column, sortDirection) : null} .type=${name} .width=${width}> + canSort ? this._sort(column, sortDirection) : null} .name=${name} .width=${width}> ${canSort ? html` ` : null} @@ -256,9 +257,10 @@ export class TpTable extends DomQuery(LitElement) { sorting: { type: Object }, selectable: { type: Boolean }, columns: { type: Array }, + items: { type: Array }, + columnMoveHandle: { type: String }, _selItems: { type: Map }, _visibleColumns: { type: Array }, - items: { type: Array }, _advFilters: { type: Array }, _advFilterActive: { type: Boolean }, _advFilterFields: { type: Object }, @@ -318,7 +320,11 @@ export class TpTable extends DomQuery(LitElement) { firstUpdated() { this.shadowRoot.querySelector('tp-scroll-threshold').target = this.shadowRoot.querySelector('lit-virtualizer'); - new ColumResizer(this.$.tableHeader, '.width-handle'); + new ColumnResizer(this.$.tableHeader, '.width-handle'); + + if (this.columnMoveHandle) { + this.colMover = new ColumnMover(this.$.tableHeader, this.columnMoveHandle); + } } _onScroll(e) { @@ -330,6 +336,7 @@ export class TpTable extends DomQuery(LitElement) { if (changes.has('items')) { this._updateSelEntries(); } + return true; } @@ -370,9 +377,9 @@ export class TpTable extends DomQuery(LitElement) { this.$.tableHeader.classList.remove('col-dragging'); const colLink = closest(handle, 'a'); - const { type, width } = colLink; + const { name, width } = colLink; const newWidth = (parseInt(width, 10) + data.dx) + 'px'; - const colDef = this.columns.find(col => col.name === type); + const colDef = this.columns.find(col => col.name === name); colDef.width = newWidth; this.columns = [...this.columns]; @@ -385,6 +392,91 @@ export class TpTable extends DomQuery(LitElement) { } } + _colMoveTracked(e) { + const { target, state, x } = e.detail; + const headers = this.shadowRoot.querySelectorAll('[part="column-label"]'); + + let targetIdx = -1; + let moveTo = -1; + + const getHeader = name => { + for (const header of headers) { + if (header.name === name) { + return header; + } + } + }; + + for (let idx = 0; idx < this.columns.length; idx++) { + const header = getHeader(this.columns[idx].name); + + if (!header) continue; + + if (header === target.parentNode) { + targetIdx = idx; + } + + const coords = header.getBoundingClientRect(); + const result = this._isOverlapping(coords, x); + + if (Math.abs(x - this.offsetWidth + this.offsetLeft) < 50) { + this.$.virtualList.scrollBy({ + left: 200, + behavior: 'smooth' + }); + } + + if (x > this.offsetLeft && Math.abs(x - this.offsetLeft) < 50) { + this.$.virtualList.scrollBy({ + left: -200, + behavior: 'smooth' + }); + } + + + if (result.overlap !== 0) { + if (state === 'track') { + this.colMover.showIndicator(result.x, coords.top); + } + + moveTo = idx; + + // User wants to move the column to the left side of the hovered column. + // This means we need to decrease the index by one. + if (result.overlap === -1) { + moveTo = idx - 1; + } + } + } + + if (state === 'end') { + if (moveTo === targetIdx) return; + + if (targetIdx > moveTo) { + moveTo++; + } + this.columns.splice(moveTo, 0, this.columns.splice(targetIdx, 1)[0]) + this.columns = [ ...this.columns ]; + this.dispatchEvent(new CustomEvent('column-moved', { detail: { from: targetIdx, to: moveTo, columns: this.columns }, bubbles: true, composed: true })); + } + } + + /** + * Determines if the mouse is hovering over the left or right half of a column header element. + * This check only happens on the x-axis. + * + * @param {{left: number, right: number}} coords Coordinates of the column header element. + * @param {number} x X-coordinate of the mouse pointer. + * @returns {{overlap: number, x: number}} overlap is -1 if overlap on the left half, 1 if overlap on the right half, 0 if no overlap. + * X is the border coordinate of the element, so either the right or left bound. Depending on the overlap side. + */ + _isOverlapping(coords, x) { + const half = coords.left + ((coords.right - coords.left) / 2); + if (x > coords.left && x <= half) return { overlap: -1, x: coords.left }; + if (x > half && x <= coords.right) return { overlap: 1, x: coords.right }; + return { overlap: 0, x: 0 }; + } + clearTriggers() { if (this.$.threshold) { this.$.threshold.clearTriggers(); @@ -405,6 +497,15 @@ export class TpTable extends DomQuery(LitElement) { } } + setSelection(items) { + this._selItems = new Map(); + items.forEach(item => { + this._selItems.set(this.getItemId(item), item); + }); + + this._selectionChanged(); + } + _selectAll() { this._selItems = new Map(); this.items.forEach(item => {