tp-table/tp-table.js

549 lines
15 KiB
JavaScript
Raw Permalink Normal View History

2022-06-17 16:13:55 +02:00
/**
@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';
2024-01-18 16:27:07 +01:00
import ColumnResizer from './column-resizer.js';
import ColumnMover from './column-mover.js';
2022-06-17 16:13:55 +02:00
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;
}
2023-09-02 00:19:42 +02:00
a.sort-link tp-icon[part="sort-icon"] {
2022-06-17 16:13:55 +02:00
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;
}
2024-10-21 23:33:49 +02:00
#tableHeader:not(.col-dragging) .width-handle:hover > div {
2023-09-02 00:19:42 +02:00
background: var(--tp-table-handle-color-hover);
2022-06-17 16:13:55 +02:00
}
2024-10-21 23:33:49 +02:00
.width-handle.dragging {
2022-06-17 16:13:55 +02:00
position: fixed;
height: 300px;
z-index: 100;
border-style: none;
width: 3px;
2023-09-02 00:19:42 +02:00
background: var(--tp-table-handle-color-dragging, linear-gradient(180deg, rgba(59, 164, 240, 1), rgba(59, 164, 240, 0)));
2022-06-17 16:13:55 +02:00
}
.select-col {
2022-11-07 10:01:24 +01:00
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.select-col tp-checkbox::part(label) {
display: none;
2022-06-17 16:13:55 +02:00
}
lit-virtualizer {
overflow-x: hidden;
}
lit-virtualizer::-webkit-scrollbar {
width: 12px;
}
lit-virtualizer::-webkit-scrollbar-track {
2023-09-02 00:19:42 +02:00
background: var(--scrollbar-track, #c1c1c1);
2022-06-17 16:13:55 +02:00
}
lit-virtualizer::-webkit-scrollbar-thumb {
2023-09-02 00:19:42 +02:00
background-color: var(--scrollbar-thumb, #5a5a5a);
2022-06-17 16:13:55 +02:00
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`
<div class="wrap">
2023-09-19 15:40:12 +02:00
${this.renderTableHeader(columns)}
2023-01-13 11:52:10 +01:00
<div class="list" @row-selection-changed=${(e) => this._selectionChanged(e)}>
2024-10-21 23:33:49 +02:00
${!Array.isArray(items) || items.length === 0 ? html`
<div class="empty-message">
<slot name="empty-message"></slot>
</div>
` : null}
<lit-virtualizer id="virtualList" part="list" scroller .items=${items} .renderItem=${(item, idx) => this.renderItem(item, idx, columns, this._selItems.has(this.getItemId(item)))}></lit-virtualizer>
2022-06-17 16:13:55 +02:00
</div>
<tp-scroll-threshold id="threshold" lowerThreshold="40"></tp-scroll-threshold>
</div>
`;
}
2023-09-19 15:40:12 +02:00
renderTableHeader(columns) {
return html`
2024-01-18 16:27:07 +01:00
<div id="tableHeader" part="header" class="list-headline" @track=${this._colResizeTracked} @track-move=${this._colMoveTracked}>
2023-09-19 15:40:12 +02:00
${this.selectable ? html`
<div class="select-col" part="chkAll"><tp-checkbox @toggled=${e => this._checkedChanged(e)}></tp-checkbox></div>
` : null}
${columns.map((column, idx) => this.renderColumnHeader(column, idx))}
2023-09-19 15:40:12 +02:00
</div>
`;
}
renderColumnHeader(column, idx) {
2022-06-17 16:13:55 +02:00
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`
2024-01-18 16:27:07 +01:00
<a part="column-label" class="${canSort ? 'sort-link' : 'no-sort-link'}" @click=${() => canSort ? this._sort(column, sortDirection) : null} .name=${name} .width=${width}>
2022-06-17 16:13:55 +02:00
${canSort ? html`
<tp-icon part="sort-icon" .icon=${sortDirection === 'asc' ? TpTable.downIcon : TpTable.upIcon} ?hidden=${!isSortedBy}></tp-icon>
` : null}
<div class="col-label">${label}</div>
<div class="width-handle" part="width-handle"><div part="width-handle-bar"></div></div>
${this.renderColumnHeaderAddons(column)}
</a>
`;
}
renderColumnHeaderAddons(column) {
return null;
}
static get downIcon() {
return svg`<path fill="var(--tp-table-icon-color)" d="M7,10L12,15L17,10H7Z">`;
}
static get upIcon() {
return svg`<path fill="var(--tp-table-icon-color)" d="M7,15L12,10L17,15H7Z">`;
}
2023-01-13 11:52:10 +01:00
renderItem(item, idx, columns, selected) {
2022-06-17 16:13:55 +02:00
return html`
<tp-table-item
2024-10-21 23:33:49 +02:00
exportparts="cell,odd,even,row,chk"
2022-06-17 16:13:55 +02:00
item
.index=${idx}
.item=${item}
2023-01-13 11:52:10 +01:00
.selected=${selected}
2022-06-17 16:13:55 +02:00
.selectable=${this.selectable}
2023-01-13 11:52:10 +01:00
.columns=${columns}>
2022-06-17 16:13:55 +02:00
</tp-table-item>
`;
}
static get properties() {
return {
/** @type {{label: string, name: string, width: number, [required]: boolean, [visible]: boolean, [sortable]: boolean}[]} */
columns: { type: Array },
2022-06-17 16:13:55 +02:00
sorting: { type: Object },
selectable: { type: Boolean },
2024-01-18 16:27:07 +01:00
items: { type: Array },
columnMoveHandle: { type: String },
2023-01-13 11:52:10 +01:00
_selItems: { type: Map },
2022-06-17 16:13:55 +02:00
_visibleColumns: { type: Array },
_advFilters: { type: Array },
_advFilterActive: { type: Boolean },
_advFilterFields: { type: Object },
_filter: { type: String },
_totalCount: { type: Number },
};
}
constructor() {
super();
2023-01-13 11:52:10 +01:00
this._selItems = new Map();
2022-06-17 16:13:55 +02:00
}
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();
}
2023-01-13 11:52:10 +01:00
// 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);
2022-11-07 10:01:24 +01:00
}
2022-06-17 16:13:55 +02:00
firstUpdated() {
this.$.virtualList.addEventListener('scroll', e => this._onScroll(e), true);
this.shadowRoot.querySelector('tp-scroll-threshold').target = this.$.virtualList;
2024-01-18 16:27:07 +01:00
new ColumnResizer(this.$.tableHeader, '.width-handle');
if (this.columnMoveHandle) {
this.colMover = new ColumnMover(this.$.tableHeader, this.columnMoveHandle);
}
2022-06-17 16:13:55 +02:00
}
2022-11-07 10:01:24 +01:00
2022-06-17 16:13:55 +02:00
_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();
}
2024-01-18 16:27:07 +01:00
2022-06-17 16:13:55 +02:00
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() {
2022-11-07 10:01:24 +01:00
this.$.tableHeader.style.gridTemplateColumns = this._updateColumnWidths(this.selectable ? ['40px'] : []).join(' ');
2022-06-17 16:13:55 +02:00
}
_updateColumnWidths(prepend) {
2022-11-07 10:01:24 +01:00
return [...(prepend || []), ...this.columns.filter(col => col.required || col.visible).map(col => col.width)];
2022-06-17 16:13:55 +02:00
}
_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');
2024-01-18 16:27:07 +01:00
const { name, width } = colLink;
2022-06-17 16:13:55 +02:00
const newWidth = (parseInt(width, 10) + data.dx) + 'px';
2024-01-18 16:27:07 +01:00
const colDef = this.columns.find(col => col.name === name);
2022-06-17 16:13:55 +02:00
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)`;
}
}
2024-01-18 16:27:07 +01:00
_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 };
}
2022-06-17 16:13:55 +02:00
clearTriggers() {
if (this.$.threshold) {
this.$.threshold.clearTriggers();
}
}
_listOverflows() {
return this.$.virtualList.scrollHeight > this.$.virtualList.offsetHeight;
}
_checkedChanged(e) {
if (Array.isArray(this.items) === false) return;
2022-11-07 10:01:24 +01:00
if (e.detail) {
2022-06-17 16:13:55 +02:00
this._selectAll();
} else {
this._selectNone();
}
}
2024-01-18 16:27:07 +01:00
setSelection(items) {
this._selItems = new Map();
items.forEach(item => {
this._selItems.set(this.getItemId(item), item);
});
this._selectionChanged();
}
2022-06-17 16:13:55 +02:00
_selectAll() {
2023-01-13 11:52:10 +01:00
this._selItems = new Map();
this.items.forEach(item => {
this._selItems.set(this.getItemId(item), item);
2022-06-17 16:13:55 +02:00
});
this._selectionChanged();
}
_selectNone() {
2023-01-13 11:52:10 +01:00
this._selItems = new Map();
2022-06-17 16:13:55 +02:00
this._selectionChanged();
}
_selectionChanged(e) {
if (e !== undefined) {
2023-01-13 11:52:10 +01:00
if (e.detail.selected) {
this._selItems.set(this.getItemId(e.detail.item), e.detail.item);
} else {
this._selItems.delete(this.getItemId(e.detail.item));
}
2022-06-17 16:13:55 +02:00
}
2023-01-13 11:52:10 +01:00
this.dispatchEvent(new CustomEvent('item-selection-changed', { detail: Array.from(this._selItems.values()), bubbles: true, composed: true }));
2022-06-17 16:13:55 +02:00
}
_updateSelEntries() {
2022-11-07 10:01:24 +01:00
if (!this.items) {
2023-01-13 11:52:10 +01:00
this._selItems = new Map();
}
for (const selItem of this._selItems.values()) {
if (this.items.findIndex(item => this.getItemId(item) === this.getItemId(selItem)) > -1) {
}
2022-06-17 16:13:55 +02:00
}
}
}
window.customElements.define('tp-table', TpTable);