|
|
|
@@ -199,6 +199,21 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
@property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
|
|
|
|
@property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
|
|
|
|
accessor showSelectionCheckbox: boolean = false;
|
|
|
|
accessor showSelectionCheckbox: boolean = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Enables row virtualization. Only rows visible in the nearest scroll
|
|
|
|
|
|
|
|
* ancestor (or the viewport) plus a small overscan are rendered. Top and
|
|
|
|
|
|
|
|
* bottom spacer rows preserve the scrollbar geometry.
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* Assumes uniform row height (measured once from the first rendered row).
|
|
|
|
|
|
|
|
* Recommended for tables with > a few hundred rows.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
@property({ type: Boolean, reflect: true, attribute: 'virtualized' })
|
|
|
|
|
|
|
|
accessor virtualized: boolean = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Number of extra rows rendered above and below the visible window. */
|
|
|
|
|
|
|
|
@property({ type: Number, attribute: 'virtual-overscan' })
|
|
|
|
|
|
|
|
accessor virtualOverscan: number = 8;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* When set, the table renders inside a fixed-height scroll container
|
|
|
|
* When set, the table renders inside a fixed-height scroll container
|
|
|
|
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
|
|
|
|
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
|
|
|
|
@@ -245,6 +260,46 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
@state()
|
|
|
|
@state()
|
|
|
|
private accessor __editingCell: { rowId: string; colKey: string } | undefined = undefined;
|
|
|
|
private accessor __editingCell: { rowId: string; colKey: string } | undefined = undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* True while the page-sticky floating header overlay is visible. Lifted
|
|
|
|
|
|
|
|
* to @state so the floating-header clone subtree is rendered only when
|
|
|
|
|
|
|
|
* needed (saves a full thead worth of cells per render when inactive).
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
|
|
|
private accessor __floatingActive: boolean = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Render memoization ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
// These caches let render() short-circuit when the relevant inputs
|
|
|
|
|
|
|
|
// (by reference) haven't changed. They are NOT @state — mutating them
|
|
|
|
|
|
|
|
// must never trigger a re-render.
|
|
|
|
|
|
|
|
private __memoEffectiveCols?: {
|
|
|
|
|
|
|
|
columns: any;
|
|
|
|
|
|
|
|
augment: boolean;
|
|
|
|
|
|
|
|
displayFunction: any;
|
|
|
|
|
|
|
|
data: any;
|
|
|
|
|
|
|
|
out: Column<T>[];
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
private __memoViewData?: {
|
|
|
|
|
|
|
|
data: any;
|
|
|
|
|
|
|
|
sortBy: any;
|
|
|
|
|
|
|
|
filterText: string;
|
|
|
|
|
|
|
|
columnFilters: any;
|
|
|
|
|
|
|
|
searchMode: string;
|
|
|
|
|
|
|
|
effectiveColumns: Column<T>[];
|
|
|
|
|
|
|
|
out: T[];
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
/** Tracks the (data, columns) pair that `determineColumnWidths()` last sized for. */
|
|
|
|
|
|
|
|
private __columnsSizedFor?: { data: any; columns: any };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Virtualization state ────────────────────────────────────────────
|
|
|
|
|
|
|
|
/** Estimated row height (px). Measured once from the first rendered row. */
|
|
|
|
|
|
|
|
private __rowHeight: number = 36;
|
|
|
|
|
|
|
|
/** True once we've measured `__rowHeight` from a real DOM row. */
|
|
|
|
|
|
|
|
private __rowHeightMeasured: boolean = false;
|
|
|
|
|
|
|
|
/** Currently rendered range [start, end). Triggers re-render when changed. */
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
|
|
|
private accessor __virtualRange: { start: number; end: number } = { start: 0, end: 0 };
|
|
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
constructor() {
|
|
|
|
super();
|
|
|
|
super();
|
|
|
|
// Make the host focusable so it can receive Ctrl/Cmd+C for copy.
|
|
|
|
// Make the host focusable so it can receive Ctrl/Cmd+C for copy.
|
|
|
|
@@ -368,28 +423,106 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
|
|
|
|
|
|
|
|
public static styles = tableStyles;
|
|
|
|
public static styles = tableStyles;
|
|
|
|
|
|
|
|
|
|
|
|
public render(): TemplateResult {
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Returns the effective column schema, memoized by reference of the inputs
|
|
|
|
|
|
|
|
* that affect it. Avoids re-running `computeEffectiveColumnsFn` /
|
|
|
|
|
|
|
|
* `computeColumnsFromDisplayFunctionFn` on every Lit update.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
private __getEffectiveColumns(): Column<T>[] {
|
|
|
|
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
|
|
|
|
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
|
|
|
|
const effectiveColumns: Column<T>[] = usingColumns
|
|
|
|
const cache = this.__memoEffectiveCols;
|
|
|
|
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
|
|
|
|
if (
|
|
|
|
|
|
|
|
cache &&
|
|
|
|
|
|
|
|
cache.columns === this.columns &&
|
|
|
|
|
|
|
|
cache.augment === this.augmentFromDisplayFunction &&
|
|
|
|
|
|
|
|
cache.displayFunction === this.displayFunction &&
|
|
|
|
|
|
|
|
cache.data === this.data
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
return cache.out;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const out = usingColumns
|
|
|
|
|
|
|
|
? computeEffectiveColumnsFn(
|
|
|
|
|
|
|
|
this.columns,
|
|
|
|
|
|
|
|
this.augmentFromDisplayFunction,
|
|
|
|
|
|
|
|
this.displayFunction,
|
|
|
|
|
|
|
|
this.data
|
|
|
|
|
|
|
|
)
|
|
|
|
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
|
|
|
|
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
|
|
|
|
|
|
|
|
this.__memoEffectiveCols = {
|
|
|
|
|
|
|
|
columns: this.columns,
|
|
|
|
|
|
|
|
augment: this.augmentFromDisplayFunction,
|
|
|
|
|
|
|
|
displayFunction: this.displayFunction,
|
|
|
|
|
|
|
|
data: this.data,
|
|
|
|
|
|
|
|
out,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
return out;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const lucenePred = compileLucenePredicate<T>(
|
|
|
|
/**
|
|
|
|
this.filterText,
|
|
|
|
* Returns the sorted/filtered view of the data, memoized by reference of
|
|
|
|
this.searchMode === 'data' ? 'data' : 'table',
|
|
|
|
* everything that affects it. Avoids re-running the lucene compiler and
|
|
|
|
effectiveColumns
|
|
|
|
* the sort/filter pipeline on every render.
|
|
|
|
);
|
|
|
|
*/
|
|
|
|
|
|
|
|
private __getViewData(effectiveColumns: Column<T>[]): T[] {
|
|
|
|
const viewData = getViewDataFn(
|
|
|
|
const searchMode = this.searchMode === 'data' ? 'data' : 'table';
|
|
|
|
|
|
|
|
const cache = this.__memoViewData;
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
|
|
cache &&
|
|
|
|
|
|
|
|
cache.data === this.data &&
|
|
|
|
|
|
|
|
cache.sortBy === this.sortBy &&
|
|
|
|
|
|
|
|
cache.filterText === this.filterText &&
|
|
|
|
|
|
|
|
cache.columnFilters === this.columnFilters &&
|
|
|
|
|
|
|
|
cache.searchMode === searchMode &&
|
|
|
|
|
|
|
|
cache.effectiveColumns === effectiveColumns
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
return cache.out;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const lucenePred = compileLucenePredicate<T>(this.filterText, searchMode, effectiveColumns);
|
|
|
|
|
|
|
|
const out = getViewDataFn(
|
|
|
|
this.data,
|
|
|
|
this.data,
|
|
|
|
effectiveColumns,
|
|
|
|
effectiveColumns,
|
|
|
|
this.sortBy,
|
|
|
|
this.sortBy,
|
|
|
|
this.filterText,
|
|
|
|
this.filterText,
|
|
|
|
this.columnFilters,
|
|
|
|
this.columnFilters,
|
|
|
|
this.searchMode === 'data' ? 'data' : 'table',
|
|
|
|
searchMode,
|
|
|
|
lucenePred || undefined
|
|
|
|
lucenePred || undefined
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
this.__memoViewData = {
|
|
|
|
|
|
|
|
data: this.data,
|
|
|
|
|
|
|
|
sortBy: this.sortBy,
|
|
|
|
|
|
|
|
filterText: this.filterText,
|
|
|
|
|
|
|
|
columnFilters: this.columnFilters,
|
|
|
|
|
|
|
|
searchMode,
|
|
|
|
|
|
|
|
effectiveColumns,
|
|
|
|
|
|
|
|
out,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
return out;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public render(): TemplateResult {
|
|
|
|
|
|
|
|
const effectiveColumns = this.__getEffectiveColumns();
|
|
|
|
|
|
|
|
const viewData = this.__getViewData(effectiveColumns);
|
|
|
|
(this as any)._lastViewData = viewData;
|
|
|
|
(this as any)._lastViewData = viewData;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Virtualization slice — only the rows in `__virtualRange` actually
|
|
|
|
|
|
|
|
// render. Top/bottom spacer rows preserve scroll geometry.
|
|
|
|
|
|
|
|
const useVirtual = this.virtualized && viewData.length > 0;
|
|
|
|
|
|
|
|
let renderRows: T[] = viewData;
|
|
|
|
|
|
|
|
let renderStart = 0;
|
|
|
|
|
|
|
|
let topSpacerHeight = 0;
|
|
|
|
|
|
|
|
let bottomSpacerHeight = 0;
|
|
|
|
|
|
|
|
if (useVirtual) {
|
|
|
|
|
|
|
|
const range = this.__virtualRange;
|
|
|
|
|
|
|
|
const start = Math.max(0, range.start);
|
|
|
|
|
|
|
|
const end = Math.min(viewData.length, range.end || 0);
|
|
|
|
|
|
|
|
// On the very first render the range is {0,0} — render a small first
|
|
|
|
|
|
|
|
// window so we can measure row height and compute the real range.
|
|
|
|
|
|
|
|
const initialEnd = end > 0 ? end : Math.min(viewData.length, this.virtualOverscan * 2 + 16);
|
|
|
|
|
|
|
|
renderStart = start;
|
|
|
|
|
|
|
|
renderRows = viewData.slice(start, initialEnd);
|
|
|
|
|
|
|
|
topSpacerHeight = start * this.__rowHeight;
|
|
|
|
|
|
|
|
bottomSpacerHeight = Math.max(0, viewData.length - initialEnd) * this.__rowHeight;
|
|
|
|
|
|
|
|
}
|
|
|
|
return html`
|
|
|
|
return html`
|
|
|
|
<dees-tile>
|
|
|
|
<dees-tile>
|
|
|
|
<div slot="header" class="header">
|
|
|
|
<div slot="header" class="header">
|
|
|
|
@@ -460,98 +593,25 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
<thead>
|
|
|
|
<thead>
|
|
|
|
${this.renderHeaderRows(effectiveColumns)}
|
|
|
|
${this.renderHeaderRows(effectiveColumns)}
|
|
|
|
</thead>
|
|
|
|
</thead>
|
|
|
|
<tbody>
|
|
|
|
<tbody
|
|
|
|
${viewData.map((itemArg, rowIndex) => {
|
|
|
|
@click=${this.__onTbodyClick}
|
|
|
|
const getTr = (elementArg: HTMLElement): HTMLElement => {
|
|
|
|
@dblclick=${this.__onTbodyDblclick}
|
|
|
|
if (elementArg.tagName === 'TR') {
|
|
|
|
@mousedown=${this.__onTbodyMousedown}
|
|
|
|
return elementArg;
|
|
|
|
@contextmenu=${this.__onTbodyContextmenu}
|
|
|
|
} else {
|
|
|
|
@dragenter=${this.__onTbodyDragenter}
|
|
|
|
return getTr(elementArg.parentElement!);
|
|
|
|
@dragleave=${this.__onTbodyDragleave}
|
|
|
|
}
|
|
|
|
@dragover=${this.__onTbodyDragover}
|
|
|
|
};
|
|
|
|
@drop=${this.__onTbodyDrop}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
${useVirtual && topSpacerHeight > 0
|
|
|
|
|
|
|
|
? html`<tr aria-hidden="true" style="height:${topSpacerHeight}px"><td></td></tr>`
|
|
|
|
|
|
|
|
: html``}
|
|
|
|
|
|
|
|
${renderRows.map((itemArg, sliceIdx) => {
|
|
|
|
|
|
|
|
const rowIndex = renderStart + sliceIdx;
|
|
|
|
|
|
|
|
const rowId = this.getRowId(itemArg);
|
|
|
|
return html`
|
|
|
|
return html`
|
|
|
|
<tr
|
|
|
|
<tr
|
|
|
|
@click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
|
|
|
|
data-row-idx=${rowIndex}
|
|
|
|
@mousedown=${(e: MouseEvent) => {
|
|
|
|
|
|
|
|
// Prevent the browser's native shift-click text
|
|
|
|
|
|
|
|
// selection so range-select doesn't highlight text.
|
|
|
|
|
|
|
|
if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
@dragenter=${async (eventArg: DragEvent) => {
|
|
|
|
|
|
|
|
eventArg.preventDefault();
|
|
|
|
|
|
|
|
eventArg.stopPropagation();
|
|
|
|
|
|
|
|
const realTarget = getTr(eventArg.target as HTMLElement);
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
|
|
realTarget.classList.add('hasAttachment');
|
|
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
@dragleave=${async (eventArg: DragEvent) => {
|
|
|
|
|
|
|
|
eventArg.preventDefault();
|
|
|
|
|
|
|
|
eventArg.stopPropagation();
|
|
|
|
|
|
|
|
const realTarget = getTr(eventArg.target as HTMLElement);
|
|
|
|
|
|
|
|
realTarget.classList.remove('hasAttachment');
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
@dragover=${async (eventArg: DragEvent) => {
|
|
|
|
|
|
|
|
eventArg.preventDefault();
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
@drop=${async (eventArg: DragEvent) => {
|
|
|
|
|
|
|
|
eventArg.preventDefault();
|
|
|
|
|
|
|
|
const newFiles: File[] = [];
|
|
|
|
|
|
|
|
for (const file of Array.from(eventArg.dataTransfer!.files)) {
|
|
|
|
|
|
|
|
this.files.push(file);
|
|
|
|
|
|
|
|
newFiles.push(file);
|
|
|
|
|
|
|
|
this.requestUpdate();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const result: File[] = this.fileWeakMap.get(itemArg as object);
|
|
|
|
|
|
|
|
if (!result) {
|
|
|
|
|
|
|
|
this.fileWeakMap.set(itemArg as object, newFiles);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
result.push(...newFiles);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
@contextmenu=${async (eventArg: MouseEvent) => {
|
|
|
|
|
|
|
|
// If the right-clicked row isn't part of the
|
|
|
|
|
|
|
|
// current selection, treat it like a plain click
|
|
|
|
|
|
|
|
// first so the context menu acts on a sensible
|
|
|
|
|
|
|
|
// selection (matches file-manager behavior).
|
|
|
|
|
|
|
|
if (!this.isRowSelected(itemArg)) {
|
|
|
|
|
|
|
|
this.selectedDataRow = itemArg;
|
|
|
|
|
|
|
|
this.selectedIds.clear();
|
|
|
|
|
|
|
|
this.selectedIds.add(this.getRowId(itemArg));
|
|
|
|
|
|
|
|
this.__selectionAnchorId = this.getRowId(itemArg);
|
|
|
|
|
|
|
|
this.emitSelectionChange();
|
|
|
|
|
|
|
|
this.requestUpdate();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const userItems: plugins.tsclass.website.IMenuItem[] =
|
|
|
|
|
|
|
|
this.getActionsForType('contextmenu').map((action) => ({
|
|
|
|
|
|
|
|
name: action.name,
|
|
|
|
|
|
|
|
iconName: action.iconName as any,
|
|
|
|
|
|
|
|
action: async () => {
|
|
|
|
|
|
|
|
await action.actionFunc({
|
|
|
|
|
|
|
|
item: itemArg,
|
|
|
|
|
|
|
|
table: this,
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
const defaultItems: plugins.tsclass.website.IMenuItem[] = [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
name:
|
|
|
|
|
|
|
|
this.selectedIds.size > 1
|
|
|
|
|
|
|
|
? `Copy ${this.selectedIds.size} rows as JSON`
|
|
|
|
|
|
|
|
: 'Copy row as JSON',
|
|
|
|
|
|
|
|
iconName: 'lucide:Copy' as any,
|
|
|
|
|
|
|
|
action: async () => {
|
|
|
|
|
|
|
|
this.copySelectionAsJson(itemArg);
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
|
|
|
|
|
|
|
...userItems,
|
|
|
|
|
|
|
|
...defaultItems,
|
|
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
|
|
|
|
class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
|
|
|
|
>
|
|
|
|
>
|
|
|
|
${this.showSelectionCheckbox
|
|
|
|
${this.showSelectionCheckbox
|
|
|
|
@@ -574,7 +634,6 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
: value;
|
|
|
|
: value;
|
|
|
|
const editKey = String(col.key);
|
|
|
|
const editKey = String(col.key);
|
|
|
|
const isEditable = !!(col.editable || col.editor);
|
|
|
|
const isEditable = !!(col.editable || col.editor);
|
|
|
|
const rowId = this.getRowId(itemArg);
|
|
|
|
|
|
|
|
const isFocused =
|
|
|
|
const isFocused =
|
|
|
|
this.__focusedCell?.rowId === rowId &&
|
|
|
|
this.__focusedCell?.rowId === rowId &&
|
|
|
|
this.__focusedCell?.colKey === editKey;
|
|
|
|
this.__focusedCell?.colKey === editKey;
|
|
|
|
@@ -591,26 +650,7 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
return html`
|
|
|
|
return html`
|
|
|
|
<td
|
|
|
|
<td
|
|
|
|
class=${cellClasses}
|
|
|
|
class=${cellClasses}
|
|
|
|
@click=${(e: MouseEvent) => {
|
|
|
|
data-col-key=${editKey}
|
|
|
|
if (isEditing) {
|
|
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isEditable) {
|
|
|
|
|
|
|
|
this.__focusedCell = { rowId, colKey: editKey };
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
@dblclick=${(e: Event) => {
|
|
|
|
|
|
|
|
const dblAction = this.dataActions.find((actionArg) =>
|
|
|
|
|
|
|
|
actionArg.type?.includes('doubleClick')
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
if (isEditable) {
|
|
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
|
|
this.startEditing(itemArg, col);
|
|
|
|
|
|
|
|
} else if (dblAction) {
|
|
|
|
|
|
|
|
dblAction.actionFunc({ item: itemArg, table: this });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<div class="innerCellContainer">
|
|
|
|
<div class="innerCellContainer">
|
|
|
|
${isEditing ? this.renderCellEditor(itemArg, col) : content}
|
|
|
|
${isEditing ? this.renderCellEditor(itemArg, col) : content}
|
|
|
|
@@ -646,15 +686,20 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
})()}
|
|
|
|
})()}
|
|
|
|
</tr>`;
|
|
|
|
</tr>`;
|
|
|
|
})}
|
|
|
|
})}
|
|
|
|
|
|
|
|
${useVirtual && bottomSpacerHeight > 0
|
|
|
|
|
|
|
|
? html`<tr aria-hidden="true" style="height:${bottomSpacerHeight}px"><td></td></tr>`
|
|
|
|
|
|
|
|
: html``}
|
|
|
|
</tbody>
|
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
</table>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="floatingHeader" aria-hidden="true">
|
|
|
|
<div class="floatingHeader" aria-hidden="true">
|
|
|
|
<table>
|
|
|
|
${this.__floatingActive
|
|
|
|
<thead>
|
|
|
|
? html`<table>
|
|
|
|
${this.renderHeaderRows(effectiveColumns)}
|
|
|
|
<thead>
|
|
|
|
</thead>
|
|
|
|
${this.renderHeaderRows(effectiveColumns)}
|
|
|
|
</table>
|
|
|
|
</thead>
|
|
|
|
|
|
|
|
</table>`
|
|
|
|
|
|
|
|
: html``}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
`
|
|
|
|
`
|
|
|
|
: html` <div class="noDataSet">No data set!</div> `}
|
|
|
|
: html` <div class="noDataSet">No data set!</div> `}
|
|
|
|
@@ -771,7 +816,8 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
// ─── Floating header (page-sticky) lifecycle ─────────────────────────
|
|
|
|
// ─── Floating header (page-sticky) lifecycle ─────────────────────────
|
|
|
|
private __floatingResizeObserver?: ResizeObserver;
|
|
|
|
private __floatingResizeObserver?: ResizeObserver;
|
|
|
|
private __floatingScrollHandler?: () => void;
|
|
|
|
private __floatingScrollHandler?: () => void;
|
|
|
|
private __floatingActive = false;
|
|
|
|
// __floatingActive is declared as a @state field above so its toggle
|
|
|
|
|
|
|
|
// triggers re-rendering of the floating-header clone subtree.
|
|
|
|
private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
|
|
|
|
private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
|
|
|
|
|
|
|
|
|
|
|
|
private get __floatingHeaderEl(): HTMLDivElement | null {
|
|
|
|
private get __floatingHeaderEl(): HTMLDivElement | null {
|
|
|
|
@@ -854,32 +900,45 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
|
|
|
|
|
|
|
|
private setupFloatingHeader() {
|
|
|
|
private setupFloatingHeader() {
|
|
|
|
this.teardownFloatingHeader();
|
|
|
|
this.teardownFloatingHeader();
|
|
|
|
if (this.fixedHeight) return;
|
|
|
|
// Skip entirely only when neither feature needs scroll watchers.
|
|
|
|
|
|
|
|
if (this.fixedHeight && !this.virtualized) return;
|
|
|
|
const realTable = this.__realTableEl;
|
|
|
|
const realTable = this.__realTableEl;
|
|
|
|
if (!realTable) return;
|
|
|
|
if (!realTable) return;
|
|
|
|
|
|
|
|
|
|
|
|
this.__scrollAncestors = this.__collectScrollAncestors();
|
|
|
|
this.__scrollAncestors = this.__collectScrollAncestors();
|
|
|
|
// .tableScroll is a descendant (inside our shadow root), not an ancestor,
|
|
|
|
// .tableScroll is a descendant (inside our shadow root), not an ancestor,
|
|
|
|
// so the upward walk above misses it. Add it explicitly so horizontal
|
|
|
|
// so the upward walk above misses it. Add it explicitly. In Mode A
|
|
|
|
// scrolling inside the table re-syncs the floating header.
|
|
|
|
// (`fixedHeight`) it is the only vertical scroll source — mark it as
|
|
|
|
|
|
|
|
// scrollsY in that case so virtualization picks it up.
|
|
|
|
const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
|
|
|
|
const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
|
|
|
|
if (tableScrollEl) {
|
|
|
|
if (tableScrollEl) {
|
|
|
|
this.__scrollAncestors.unshift({ target: tableScrollEl, scrollsY: false, scrollsX: true });
|
|
|
|
this.__scrollAncestors.unshift({
|
|
|
|
|
|
|
|
target: tableScrollEl,
|
|
|
|
|
|
|
|
scrollsY: this.fixedHeight,
|
|
|
|
|
|
|
|
scrollsX: true,
|
|
|
|
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Track resize of the real table so we can mirror its width and column widths.
|
|
|
|
// Track resize of the real table so we can mirror its width and column widths.
|
|
|
|
this.__floatingResizeObserver = new ResizeObserver(() => {
|
|
|
|
this.__floatingResizeObserver = new ResizeObserver(() => {
|
|
|
|
this.__syncFloatingHeader();
|
|
|
|
if (!this.fixedHeight) this.__syncFloatingHeader();
|
|
|
|
|
|
|
|
if (this.virtualized) this.__computeVirtualRange();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
this.__floatingResizeObserver.observe(realTable);
|
|
|
|
this.__floatingResizeObserver.observe(realTable);
|
|
|
|
|
|
|
|
|
|
|
|
this.__floatingScrollHandler = () => this.__syncFloatingHeader();
|
|
|
|
this.__floatingScrollHandler = () => {
|
|
|
|
|
|
|
|
if (!this.fixedHeight) this.__syncFloatingHeader();
|
|
|
|
|
|
|
|
// Recompute virtual range on every scroll — cheap (one rect read +
|
|
|
|
|
|
|
|
// some math) and necessary so rows materialize before they're seen.
|
|
|
|
|
|
|
|
if (this.virtualized) this.__computeVirtualRange();
|
|
|
|
|
|
|
|
};
|
|
|
|
for (const a of this.__scrollAncestors) {
|
|
|
|
for (const a of this.__scrollAncestors) {
|
|
|
|
a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
|
|
|
|
a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
|
|
|
|
window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
|
|
|
|
|
|
|
|
|
|
|
|
this.__syncFloatingHeader();
|
|
|
|
if (!this.fixedHeight) this.__syncFloatingHeader();
|
|
|
|
|
|
|
|
if (this.virtualized) this.__computeVirtualRange();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private teardownFloatingHeader() {
|
|
|
|
private teardownFloatingHeader() {
|
|
|
|
@@ -898,35 +957,99 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
if (fh) fh.classList.remove('active');
|
|
|
|
if (fh) fh.classList.remove('active');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Virtualization ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Computes the visible row range based on the table's position in its
|
|
|
|
|
|
|
|
* nearest vertical scroll ancestor (or the viewport). Updates
|
|
|
|
|
|
|
|
* `__virtualRange` if it changed; that triggers a Lit re-render.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
private __computeVirtualRange() {
|
|
|
|
|
|
|
|
if (!this.virtualized) return;
|
|
|
|
|
|
|
|
const view: T[] = (this as any)._lastViewData ?? [];
|
|
|
|
|
|
|
|
const total = view.length;
|
|
|
|
|
|
|
|
if (total === 0) {
|
|
|
|
|
|
|
|
if (this.__virtualRange.start !== 0 || this.__virtualRange.end !== 0) {
|
|
|
|
|
|
|
|
this.__virtualRange = { start: 0, end: 0 };
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const realTable = this.__realTableEl;
|
|
|
|
|
|
|
|
if (!realTable) return;
|
|
|
|
|
|
|
|
const tableRect = realTable.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Find the innermost vertical scroll ancestor (rect + content height).
|
|
|
|
|
|
|
|
let viewportTop = 0;
|
|
|
|
|
|
|
|
let viewportBottom = window.innerHeight;
|
|
|
|
|
|
|
|
for (const a of this.__scrollAncestors) {
|
|
|
|
|
|
|
|
if (a.target === window || !a.scrollsY) continue;
|
|
|
|
|
|
|
|
const r = (a.target as Element).getBoundingClientRect();
|
|
|
|
|
|
|
|
const cs = getComputedStyle(a.target as Element);
|
|
|
|
|
|
|
|
const bt = parseFloat(cs.borderTopWidth) || 0;
|
|
|
|
|
|
|
|
const bb = parseFloat(cs.borderBottomWidth) || 0;
|
|
|
|
|
|
|
|
viewportTop = Math.max(viewportTop, r.top + bt);
|
|
|
|
|
|
|
|
viewportBottom = Math.min(viewportBottom, r.bottom - bb);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const rowH = Math.max(1, this.__rowHeight);
|
|
|
|
|
|
|
|
// Distance from the table top to the visible window top, in px of body
|
|
|
|
|
|
|
|
// content (so any header offset above the rows is excluded).
|
|
|
|
|
|
|
|
const headerHeight = realTable.tHead?.getBoundingClientRect().height ?? 0;
|
|
|
|
|
|
|
|
const bodyTop = tableRect.top + headerHeight;
|
|
|
|
|
|
|
|
const offsetIntoBody = Math.max(0, viewportTop - bodyTop);
|
|
|
|
|
|
|
|
const visiblePx = Math.max(0, viewportBottom - Math.max(viewportTop, bodyTop));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const startRaw = Math.floor(offsetIntoBody / rowH);
|
|
|
|
|
|
|
|
const visibleCount = Math.ceil(visiblePx / rowH) + 1;
|
|
|
|
|
|
|
|
const start = Math.max(0, startRaw - this.virtualOverscan);
|
|
|
|
|
|
|
|
const end = Math.min(total, startRaw + visibleCount + this.virtualOverscan);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (start !== this.__virtualRange.start || end !== this.__virtualRange.end) {
|
|
|
|
|
|
|
|
this.__virtualRange = { start, end };
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Measures the height of the first rendered body row and stores it for
|
|
|
|
|
|
|
|
* subsequent virtualization math. Idempotent — only measures once per
|
|
|
|
|
|
|
|
* `data`/`columns` pair (cleared in `updated()` when those change).
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
private __measureRowHeight() {
|
|
|
|
|
|
|
|
if (!this.virtualized || this.__rowHeightMeasured) return;
|
|
|
|
|
|
|
|
const tbody = this.shadowRoot?.querySelector('tbody') as HTMLTableSectionElement | null;
|
|
|
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
|
|
|
const firstRow = Array.from(tbody.rows).find((r) => r.hasAttribute('data-row-idx'));
|
|
|
|
|
|
|
|
if (!firstRow) return;
|
|
|
|
|
|
|
|
const h = firstRow.getBoundingClientRect().height;
|
|
|
|
|
|
|
|
if (h > 0) {
|
|
|
|
|
|
|
|
this.__rowHeight = h;
|
|
|
|
|
|
|
|
this.__rowHeightMeasured = true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* Single function that drives both activation and geometry of the floating
|
|
|
|
* Single function that drives both activation and geometry of the floating
|
|
|
|
* header. Called on scroll, resize, table-resize, and after each render.
|
|
|
|
* header. Called on scroll, resize, table-resize, and after relevant
|
|
|
|
|
|
|
|
* renders.
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* Activation is decided from the *real* header geometry, so this function
|
|
|
|
|
|
|
|
* works even when the clone subtree hasn't been rendered yet (it's only
|
|
|
|
|
|
|
|
* rendered when `__floatingActive` is true). The first activation flips
|
|
|
|
|
|
|
|
* `__floatingActive`; the next render materializes the clone; the next
|
|
|
|
|
|
|
|
* call here mirrors widths and positions.
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
private __syncFloatingHeader() {
|
|
|
|
private __syncFloatingHeader() {
|
|
|
|
const fh = this.__floatingHeaderEl;
|
|
|
|
const fh = this.__floatingHeaderEl;
|
|
|
|
const realTable = this.__realTableEl;
|
|
|
|
const realTable = this.__realTableEl;
|
|
|
|
const floatTable = this.__floatingTableEl;
|
|
|
|
if (!fh || !realTable) return;
|
|
|
|
if (!fh || !realTable || !floatTable) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const tableRect = realTable.getBoundingClientRect();
|
|
|
|
const tableRect = realTable.getBoundingClientRect();
|
|
|
|
const stick = this.__getStickContext();
|
|
|
|
const stick = this.__getStickContext();
|
|
|
|
|
|
|
|
|
|
|
|
// Mirror table layout + per-cell widths so columns line up.
|
|
|
|
|
|
|
|
floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
|
|
|
|
|
|
|
|
const realHeadRows = realTable.tHead?.rows;
|
|
|
|
const realHeadRows = realTable.tHead?.rows;
|
|
|
|
const floatHeadRows = floatTable.tHead?.rows;
|
|
|
|
|
|
|
|
let headerHeight = 0;
|
|
|
|
let headerHeight = 0;
|
|
|
|
if (realHeadRows && floatHeadRows) {
|
|
|
|
if (realHeadRows) {
|
|
|
|
for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
|
|
|
|
for (let r = 0; r < realHeadRows.length; r++) {
|
|
|
|
headerHeight += realHeadRows[r].getBoundingClientRect().height;
|
|
|
|
headerHeight += realHeadRows[r].getBoundingClientRect().height;
|
|
|
|
const realCells = realHeadRows[r].cells;
|
|
|
|
|
|
|
|
const floatCells = floatHeadRows[r].cells;
|
|
|
|
|
|
|
|
for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
|
|
|
|
|
|
|
|
const w = realCells[c].getBoundingClientRect().width;
|
|
|
|
|
|
|
|
(floatCells[c] as HTMLElement).style.width = `${w}px`;
|
|
|
|
|
|
|
|
(floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
|
|
|
|
|
|
|
|
(floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -938,9 +1061,34 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
if (shouldBeActive !== this.__floatingActive) {
|
|
|
|
if (shouldBeActive !== this.__floatingActive) {
|
|
|
|
this.__floatingActive = shouldBeActive;
|
|
|
|
this.__floatingActive = shouldBeActive;
|
|
|
|
fh.classList.toggle('active', shouldBeActive);
|
|
|
|
fh.classList.toggle('active', shouldBeActive);
|
|
|
|
|
|
|
|
if (shouldBeActive) {
|
|
|
|
|
|
|
|
// Clone subtree doesn't exist yet — wait for the next render to
|
|
|
|
|
|
|
|
// materialize it, then complete geometry sync.
|
|
|
|
|
|
|
|
this.updateComplete.then(() => this.__syncFloatingHeader());
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (!shouldBeActive) return;
|
|
|
|
if (!shouldBeActive) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Mirror table layout + per-cell widths so columns line up. The clone
|
|
|
|
|
|
|
|
// exists at this point because __floatingActive === true.
|
|
|
|
|
|
|
|
const floatTable = this.__floatingTableEl;
|
|
|
|
|
|
|
|
if (!floatTable) return;
|
|
|
|
|
|
|
|
floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
|
|
|
|
|
|
|
|
const floatHeadRows = floatTable.tHead?.rows;
|
|
|
|
|
|
|
|
if (realHeadRows && floatHeadRows) {
|
|
|
|
|
|
|
|
for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
|
|
|
|
|
|
|
|
const realCells = realHeadRows[r].cells;
|
|
|
|
|
|
|
|
const floatCells = floatHeadRows[r].cells;
|
|
|
|
|
|
|
|
for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
|
|
|
|
|
|
|
|
const w = realCells[c].getBoundingClientRect().width;
|
|
|
|
|
|
|
|
(floatCells[c] as HTMLElement).style.width = `${w}px`;
|
|
|
|
|
|
|
|
(floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
|
|
|
|
|
|
|
|
(floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Position the floating header. Clip horizontally to the scroll context
|
|
|
|
// Position the floating header. Clip horizontally to the scroll context
|
|
|
|
// so a horizontally-scrolled inner container's header doesn't bleed
|
|
|
|
// so a horizontally-scrolled inner container's header doesn't bleed
|
|
|
|
// outside the container's border.
|
|
|
|
// outside the container's border.
|
|
|
|
@@ -970,24 +1118,55 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
|
|
|
|
|
|
|
|
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
|
|
|
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
|
|
|
super.updated(changedProperties);
|
|
|
|
super.updated(changedProperties);
|
|
|
|
this.determineColumnWidths();
|
|
|
|
|
|
|
|
// (Re)wire the floating header whenever the relevant props change or
|
|
|
|
// Only re-measure column widths when the data or schema actually changed
|
|
|
|
// the table markup may have appeared/disappeared.
|
|
|
|
// (or on first paint). `determineColumnWidths` is the single biggest
|
|
|
|
|
|
|
|
// first-paint cost — it forces multiple layout flushes per row.
|
|
|
|
|
|
|
|
const dataOrColsChanged =
|
|
|
|
|
|
|
|
!this.__columnsSizedFor ||
|
|
|
|
|
|
|
|
this.__columnsSizedFor.data !== this.data ||
|
|
|
|
|
|
|
|
this.__columnsSizedFor.columns !== this.columns;
|
|
|
|
|
|
|
|
if (dataOrColsChanged) {
|
|
|
|
|
|
|
|
this.__columnsSizedFor = { data: this.data, columns: this.columns };
|
|
|
|
|
|
|
|
this.determineColumnWidths();
|
|
|
|
|
|
|
|
// Force re-measure of row height; structure may have changed.
|
|
|
|
|
|
|
|
this.__rowHeightMeasured = false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Virtualization: measure row height after the first paint with rows,
|
|
|
|
|
|
|
|
// then compute the visible range. Both ops only run when `virtualized`
|
|
|
|
|
|
|
|
// is true, so the cost is zero for normal tables.
|
|
|
|
|
|
|
|
if (this.virtualized) {
|
|
|
|
|
|
|
|
this.__measureRowHeight();
|
|
|
|
|
|
|
|
this.__computeVirtualRange();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// (Re)wire the scroll watchers (used by both the floating header in
|
|
|
|
|
|
|
|
// Mode B and by virtualization). Skip entirely only when neither
|
|
|
|
|
|
|
|
// feature needs them.
|
|
|
|
if (
|
|
|
|
if (
|
|
|
|
changedProperties.has('fixedHeight') ||
|
|
|
|
changedProperties.has('fixedHeight') ||
|
|
|
|
|
|
|
|
changedProperties.has('virtualized') ||
|
|
|
|
changedProperties.has('data') ||
|
|
|
|
changedProperties.has('data') ||
|
|
|
|
changedProperties.has('columns') ||
|
|
|
|
changedProperties.has('columns') ||
|
|
|
|
!this.__floatingScrollHandler
|
|
|
|
!this.__floatingScrollHandler
|
|
|
|
) {
|
|
|
|
) {
|
|
|
|
if (!this.fixedHeight && this.data.length > 0) {
|
|
|
|
const needsScrollWatchers = (!this.fixedHeight || this.virtualized) && this.data.length > 0;
|
|
|
|
|
|
|
|
if (needsScrollWatchers) {
|
|
|
|
this.setupFloatingHeader();
|
|
|
|
this.setupFloatingHeader();
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
this.teardownFloatingHeader();
|
|
|
|
this.teardownFloatingHeader();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Keep the floating header in sync after any re-render
|
|
|
|
// Only sync the floating header geometry when it's actually showing or
|
|
|
|
// (column widths may have changed).
|
|
|
|
// the table layout-affecting state changed. Avoids per-render layout
|
|
|
|
if (!this.fixedHeight && this.data.length > 0) {
|
|
|
|
// reads (getBoundingClientRect on every header cell) for typical updates
|
|
|
|
|
|
|
|
// like sort changes or selection toggles.
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
|
|
!this.fixedHeight &&
|
|
|
|
|
|
|
|
this.data.length > 0 &&
|
|
|
|
|
|
|
|
(this.__floatingActive || dataOrColsChanged)
|
|
|
|
|
|
|
|
) {
|
|
|
|
this.__syncFloatingHeader();
|
|
|
|
this.__syncFloatingHeader();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (this.searchable) {
|
|
|
|
if (this.searchable) {
|
|
|
|
@@ -1502,6 +1681,187 @@ export class DeesTable<T> extends DeesElement {
|
|
|
|
this.requestUpdate();
|
|
|
|
this.requestUpdate();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Delegated tbody event handlers ─────────────────────────────────
|
|
|
|
|
|
|
|
// Hoisted from per-<tr> closures to a single set of handlers on <tbody>.
|
|
|
|
|
|
|
|
// Cuts ~7 closure allocations per row per render. Each handler resolves
|
|
|
|
|
|
|
|
// the source row via `data-row-idx` (and `data-col-key` for cell-level
|
|
|
|
|
|
|
|
// events) using the latest `_lastViewData`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __resolveRow(eventArg: Event): { item: T; rowIdx: number } | null {
|
|
|
|
|
|
|
|
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
|
|
|
|
|
|
|
let tr: HTMLTableRowElement | null = null;
|
|
|
|
|
|
|
|
for (const t of path) {
|
|
|
|
|
|
|
|
const el = t as HTMLElement;
|
|
|
|
|
|
|
|
if (el?.tagName === 'TR' && el.hasAttribute('data-row-idx')) {
|
|
|
|
|
|
|
|
tr = el as HTMLTableRowElement;
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!tr) return null;
|
|
|
|
|
|
|
|
const rowIdx = Number(tr.getAttribute('data-row-idx'));
|
|
|
|
|
|
|
|
const view: T[] = (this as any)._lastViewData ?? [];
|
|
|
|
|
|
|
|
const item = view[rowIdx];
|
|
|
|
|
|
|
|
if (!item) return null;
|
|
|
|
|
|
|
|
return { item, rowIdx };
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __resolveCell(eventArg: Event): { item: T; rowIdx: number; col: Column<T> } | null {
|
|
|
|
|
|
|
|
const row = this.__resolveRow(eventArg);
|
|
|
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
|
|
|
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
|
|
|
|
|
|
|
let td: HTMLTableCellElement | null = null;
|
|
|
|
|
|
|
|
for (const t of path) {
|
|
|
|
|
|
|
|
const el = t as HTMLElement;
|
|
|
|
|
|
|
|
if (el?.tagName === 'TD' && el.hasAttribute('data-col-key')) {
|
|
|
|
|
|
|
|
td = el as HTMLTableCellElement;
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!td) return null;
|
|
|
|
|
|
|
|
const colKey = td.getAttribute('data-col-key')!;
|
|
|
|
|
|
|
|
const cols = this.__getEffectiveColumns();
|
|
|
|
|
|
|
|
const col = cols.find((c) => String(c.key) === colKey);
|
|
|
|
|
|
|
|
if (!col) return null;
|
|
|
|
|
|
|
|
return { item: row.item, rowIdx: row.rowIdx, col };
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __isInActionsCol(eventArg: Event): boolean {
|
|
|
|
|
|
|
|
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
|
|
|
|
|
|
|
for (const t of path) {
|
|
|
|
|
|
|
|
const el = t as HTMLElement;
|
|
|
|
|
|
|
|
if (el?.classList?.contains('actionsCol')) return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __isInEditor(eventArg: Event): boolean {
|
|
|
|
|
|
|
|
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
|
|
|
|
|
|
|
for (const t of path) {
|
|
|
|
|
|
|
|
const el = t as HTMLElement;
|
|
|
|
|
|
|
|
const tag = el?.tagName;
|
|
|
|
|
|
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || el?.isContentEditable) return true;
|
|
|
|
|
|
|
|
if (tag && tag.startsWith('DEES-INPUT-')) return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __onTbodyClick = (eventArg: MouseEvent) => {
|
|
|
|
|
|
|
|
if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return;
|
|
|
|
|
|
|
|
const cell = this.__resolveCell(eventArg);
|
|
|
|
|
|
|
|
if (!cell) return;
|
|
|
|
|
|
|
|
const view: T[] = (this as any)._lastViewData ?? [];
|
|
|
|
|
|
|
|
// Cell focus (when editable)
|
|
|
|
|
|
|
|
if (cell.col.editable || cell.col.editor) {
|
|
|
|
|
|
|
|
this.__focusedCell = {
|
|
|
|
|
|
|
|
rowId: this.getRowId(cell.item),
|
|
|
|
|
|
|
|
colKey: String(cell.col.key),
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// Row selection (file-manager style)
|
|
|
|
|
|
|
|
this.handleRowClick(eventArg, cell.item, cell.rowIdx, view);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __onTbodyDblclick = (eventArg: MouseEvent) => {
|
|
|
|
|
|
|
|
if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return;
|
|
|
|
|
|
|
|
const cell = this.__resolveCell(eventArg);
|
|
|
|
|
|
|
|
if (!cell) return;
|
|
|
|
|
|
|
|
const isEditable = !!(cell.col.editable || cell.col.editor);
|
|
|
|
|
|
|
|
if (isEditable) {
|
|
|
|
|
|
|
|
eventArg.stopPropagation();
|
|
|
|
|
|
|
|
this.startEditing(cell.item, cell.col);
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const dblAction = this.dataActions.find((a) => a.type?.includes('doubleClick'));
|
|
|
|
|
|
|
|
if (dblAction) dblAction.actionFunc({ item: cell.item, table: this });
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __onTbodyMousedown = (eventArg: MouseEvent) => {
|
|
|
|
|
|
|
|
// Suppress browser's native shift-click text selection so range-select
|
|
|
|
|
|
|
|
// doesn't highlight text mid-table.
|
|
|
|
|
|
|
|
if (eventArg.shiftKey && this.selectionMode !== 'single') eventArg.preventDefault();
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __onTbodyContextmenu = (eventArg: MouseEvent) => {
|
|
|
|
|
|
|
|
if (this.__isInActionsCol(eventArg)) return;
|
|
|
|
|
|
|
|
const row = this.__resolveRow(eventArg);
|
|
|
|
|
|
|
|
if (!row) return;
|
|
|
|
|
|
|
|
const item = row.item;
|
|
|
|
|
|
|
|
// Match file-manager behavior: right-clicking a non-selected row makes
|
|
|
|
|
|
|
|
// it the selection first.
|
|
|
|
|
|
|
|
if (!this.isRowSelected(item)) {
|
|
|
|
|
|
|
|
this.selectedDataRow = item;
|
|
|
|
|
|
|
|
this.selectedIds.clear();
|
|
|
|
|
|
|
|
this.selectedIds.add(this.getRowId(item));
|
|
|
|
|
|
|
|
this.__selectionAnchorId = this.getRowId(item);
|
|
|
|
|
|
|
|
this.emitSelectionChange();
|
|
|
|
|
|
|
|
this.requestUpdate();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const userItems: plugins.tsclass.website.IMenuItem[] = this.getActionsForType('contextmenu').map(
|
|
|
|
|
|
|
|
(action) => ({
|
|
|
|
|
|
|
|
name: action.name,
|
|
|
|
|
|
|
|
iconName: action.iconName as any,
|
|
|
|
|
|
|
|
action: async () => {
|
|
|
|
|
|
|
|
await action.actionFunc({ item, table: this });
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
const defaultItems: plugins.tsclass.website.IMenuItem[] = [
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
name:
|
|
|
|
|
|
|
|
this.selectedIds.size > 1
|
|
|
|
|
|
|
|
? `Copy ${this.selectedIds.size} rows as JSON`
|
|
|
|
|
|
|
|
: 'Copy row as JSON',
|
|
|
|
|
|
|
|
iconName: 'lucide:Copy' as any,
|
|
|
|
|
|
|
|
action: async () => {
|
|
|
|
|
|
|
|
this.copySelectionAsJson(item);
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
DeesContextmenu.openContextMenuWithOptions(eventArg, [...userItems, ...defaultItems]);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __onTbodyDragenter = (eventArg: DragEvent) => {
|
|
|
|
|
|
|
|
eventArg.preventDefault();
|
|
|
|
|
|
|
|
eventArg.stopPropagation();
|
|
|
|
|
|
|
|
const row = this.__resolveRow(eventArg);
|
|
|
|
|
|
|
|
if (!row) return;
|
|
|
|
|
|
|
|
const tr = (eventArg.composedPath?.() || []).find(
|
|
|
|
|
|
|
|
(t) => (t as HTMLElement)?.tagName === 'TR'
|
|
|
|
|
|
|
|
) as HTMLElement | undefined;
|
|
|
|
|
|
|
|
if (tr) setTimeout(() => tr.classList.add('hasAttachment'), 0);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __onTbodyDragleave = (eventArg: DragEvent) => {
|
|
|
|
|
|
|
|
eventArg.preventDefault();
|
|
|
|
|
|
|
|
eventArg.stopPropagation();
|
|
|
|
|
|
|
|
const tr = (eventArg.composedPath?.() || []).find(
|
|
|
|
|
|
|
|
(t) => (t as HTMLElement)?.tagName === 'TR'
|
|
|
|
|
|
|
|
) as HTMLElement | undefined;
|
|
|
|
|
|
|
|
if (tr) tr.classList.remove('hasAttachment');
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __onTbodyDragover = (eventArg: DragEvent) => {
|
|
|
|
|
|
|
|
eventArg.preventDefault();
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private __onTbodyDrop = async (eventArg: DragEvent) => {
|
|
|
|
|
|
|
|
eventArg.preventDefault();
|
|
|
|
|
|
|
|
const row = this.__resolveRow(eventArg);
|
|
|
|
|
|
|
|
if (!row) return;
|
|
|
|
|
|
|
|
const item = row.item;
|
|
|
|
|
|
|
|
const newFiles: File[] = [];
|
|
|
|
|
|
|
|
for (const file of Array.from(eventArg.dataTransfer!.files)) {
|
|
|
|
|
|
|
|
this.files.push(file);
|
|
|
|
|
|
|
|
newFiles.push(file);
|
|
|
|
|
|
|
|
this.requestUpdate();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const existing: File[] | undefined = this.fileWeakMap.get(item as object);
|
|
|
|
|
|
|
|
if (!existing) this.fileWeakMap.set(item as object, newFiles);
|
|
|
|
|
|
|
|
else existing.push(...newFiles);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* Handles row clicks with file-manager style selection semantics:
|
|
|
|
* Handles row clicks with file-manager style selection semantics:
|
|
|
|
* - plain click: select only this row, set anchor
|
|
|
|
* - plain click: select only this row, set anchor
|
|
|
|
|