/** @license Copyright (c) 2022 trading_peter This program is available under Apache License Version 2.0 */ import '@tp/tp-scroll-threshold/tp-scroll-threshold.js'; import '@lit-labs/virtualizer'; import '@tp/tp-icon/tp-icon.js'; import '@tp/tp-checkbox/tp-checkbox.js'; 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 ColumnResizer from './column-resizer.js'; import ColumnMover from './column-mover.js'; export class TpTable extends DomQuery(LitElement) { static get styles() { return [ css` :host { display: block; padding: 20px; position: relative; } [hidden] { display: none; } .wrap { overflow-y: auto; position: absolute; left: 0; right: 0; top: 0; bottom: 0; display: flex; flex-direction: column; } .list { position: relative; flex: 1; } #tableHeader { display: grid; overflow-x: hidden; } #virtualList, .empty-message { position: absolute !important; left: 0; right: 0; top: 0; bottom: 0; } #virtualList { height: auto; } .empty-message { display: flex; flex-direction: column; align-items: center; justify-content: center; } a.sort-link, a.no-sort-link { text-decoration: none; font-weight: bold; position: relative; border-right: solid 1px #c1c1c1; padding: 10px 20px 10px 20px; display: flex; flex-direction: row; overflow: hidden; cursor: pointer; user-select: none; } a.sort-link:hover, a.no-sort-link:hover { border-right: solid 1px #c1c1c1; } a.sort-link tp-icon[part="sort-icon"] { position: absolute; left: 0; --tp-icon-width: 18px; --tp-icon-height: 18px; } div.col-label { flex: 1; text-overflow: ellipsis; white-space: nowrap; } .width-handle { position: absolute; z-index: 1; top: 0; right: -2px; cursor: col-resize; width: 6px; height: 100%; } .width-handle > div { margin: auto; width: 2px; height: 100%; background: transparent; } #tableHeader:not(.col-dragging) .width-handle:hover > div { background: var(--tp-table-handle-color-hover); } .width-handle.dragging { position: fixed; height: 300px; z-index: 100; border-style: none; width: 3px; background: var(--tp-table-handle-color-dragging, linear-gradient(180deg, rgba(59, 164, 240, 1), rgba(59, 164, 240, 0))); } .select-col { display: flex; align-items: center; justify-content: center; height: 100%; } .select-col tp-checkbox::part(label) { display: none; } lit-virtualizer { overflow-x: hidden; } lit-virtualizer::-webkit-scrollbar { width: 12px; } lit-virtualizer::-webkit-scrollbar-track { background: var(--scrollbar-track, #c1c1c1); } lit-virtualizer::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb, #5a5a5a); outline: none; border-radius: var(--scrollbar-thumb-border-radius, 4px); } [item]:hover { background: var(--table-row-hl); --list-columns-h-borders: var(--list-columns-h-borders-hover); } ` ]; } render() { const columns = this.columns || []; const items = this.items || []; return html`
${this.renderTableHeader(columns)}
this._selectionChanged(e)}> ${!Array.isArray(items) || items.length === 0 ? html`
` : null} this.renderItem(item, idx, columns, this._selItems.has(this.getItemId(item)))}>
`; } renderTableHeader(columns) { return html`
${this.selectable ? html`
this._checkedChanged(e)}>
` : null} ${columns.map((column, idx) => this.renderColumnHeader(column, idx))}
`; } renderColumnHeader(column, idx) { if (column.visible !== true && column.required !== true) return null; const { name, width, label, sortable } = column; const sorting = this.sorting || {}; const isSortedBy = sorting.column === name; const sortDirection = isSortedBy ? sorting.direction : 'desc'; const canSort = sortable !== false; return html` canSort ? this._sort(column, sortDirection) : null} .name=${name} .width=${width}> ${canSort ? html` ` : null}
${label}
${this.renderColumnHeaderAddons(column)}
`; } renderColumnHeaderAddons(column) { return null; } static get downIcon() { return svg``; } static get upIcon() { return svg``; } renderItem(item, idx, columns, selected) { return html` `; } static get properties() { return { /** @type {{label: string, name: string, width: number, [required]: boolean, [visible]: boolean, [sortable]: boolean}[]} */ columns: { type: Array }, sorting: { type: Object }, selectable: { type: Boolean }, items: { type: Array }, columnMoveHandle: { type: String }, _selItems: { type: Map }, _visibleColumns: { type: Array }, _advFilters: { type: Array }, _advFilterActive: { type: Boolean }, _advFilterFields: { type: Object }, _filter: { type: String }, _totalCount: { type: Number }, }; } constructor() { super(); this._selItems = new Map(); } updated(changes) { if (changes.has('columns')) { this._updateColumns(); } } get _emptyMessage() { return null; } get sortingPath() { return `${this.dataKey}.sorting`; } get statusFilterPath() { return `${this.dataKey}.filtering.statusFilter`; } set filter(val) { this._advFilterActive = false; this._filter = this._clone(val); this.reloadPagedList(); } set statusFilter(val) { this._statusFilter = this._clone(val); this.reloadPagedList(); } set advancedFilter(val) { this._advFilterActive = val ? true : false; this._advFilters = this._clone(val); this.reloadPagedList(); } // Override this to change how the table derives item ids. getItemId(item) { return item._id || item.id; } scrollToIndex(idx, position) { this.$.virtualList.scrollToIndex(idx, position); } firstUpdated() { this.$.virtualList.addEventListener('scroll', e => this._onScroll(e), true); this.shadowRoot.querySelector('tp-scroll-threshold').target = this.$.virtualList; new ColumnResizer(this.$.tableHeader, '.width-handle'); if (this.columnMoveHandle) { this.colMover = new ColumnMover(this.$.tableHeader, this.columnMoveHandle); } } _onScroll(e) { this.$.tableHeader.style.paddingRight = (this.$.virtualList.offsetWidth - this.$.virtualList.clientWidth) + 'px'; this.$.tableHeader.scrollLeft = this.$.virtualList.scrollLeft; } shouldUpdate(changes) { if (changes.has('items')) { this._updateSelEntries(); } return true; } _sort(column, direction) { if (this._draggedColumn) return; this.sorting = { column: column.name, direction: direction === 'asc' ? 'desc' : 'asc' }; this.dispatchEvent(new CustomEvent('sorting-changed', { detail: this.sorting, bubbles: true, composed: true })); } _updateColumns() { this.$.tableHeader.style.gridTemplateColumns = this._updateColumnWidths(this.selectable ? ['40px'] : []).join(' '); } _updateColumnWidths(prepend) { return [...(prepend || []), ...this.columns.filter(col => col.required || col.visible).map(col => col.width)]; } _colResizeTracked(e) { const handle = closest(e.target, '.width-handle'); const data = e.detail; if (data.state === 'start') { this._draggedColumn = true; const rect = handle.getBoundingClientRect(); document.body.style.userSelect = 'none'; handle.style.top = `${rect.top}px`; handle.style.left = `${rect.left}px`; handle.style.transform = `translateX(${data.dx}px)`; handle.classList.add('dragging'); this.$.tableHeader.classList.add('col-dragging'); } else if (data.state === 'end') { document.body.style.userSelect = ''; handle.classList.remove('dragging'); handle.style.transform = ''; handle.style.top = ''; handle.style.left = ''; this.$.tableHeader.classList.remove('col-dragging'); const colLink = closest(handle, 'a'); const { name, width } = colLink; const newWidth = (parseInt(width, 10) + data.dx) + 'px'; const colDef = this.columns.find(col => col.name === name); colDef.width = newWidth; this.columns = [...this.columns]; setTimeout(() => { this._draggedColumn = false; this.dispatchEvent(new CustomEvent('column-width-changed', { detail: this.columns, bubbles: true, composed: true })); }); } else if (data.state === 'track') { handle.style.transform = `translateX(${data.dx}px)`; } } _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(); } } _listOverflows() { return this.$.virtualList.scrollHeight > this.$.virtualList.offsetHeight; } _checkedChanged(e) { if (Array.isArray(this.items) === false) return; if (e.detail) { this._selectAll(); } else { this._selectNone(); } } 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 => { this._selItems.set(this.getItemId(item), item); }); this._selectionChanged(); } _selectNone() { this._selItems = new Map(); this._selectionChanged(); } _selectionChanged(e) { if (e !== undefined) { if (e.detail.selected) { this._selItems.set(this.getItemId(e.detail.item), e.detail.item); } else { this._selItems.delete(this.getItemId(e.detail.item)); } } this.dispatchEvent(new CustomEvent('item-selection-changed', { detail: Array.from(this._selItems.values()), bubbles: true, composed: true })); } _updateSelEntries() { if (!this.items) { this._selItems = new Map(); } for (const selItem of this._selItems.values()) { if (this.items.findIndex(item => this.getItemId(item) === this.getItemId(selItem)) > -1) { } } } } window.customElements.define('tp-table', TpTable);