feat(dees-table): add virtualized row rendering for large tables and optimize table rendering performance

This commit is contained in:
2026-04-07 15:56:55 +00:00
parent a1e808345e
commit ac9cc8cfed
4 changed files with 529 additions and 161 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-catalog',
version: '3.65.0',
version: '3.66.0',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
}

View File

@@ -700,7 +700,8 @@ export const demoFunc = () => html`
<dees-table
id="scrollSmallHeight"
.fixedHeight=${true}
heading1="People Directory (Scrollable)"
.virtualized=${true}
heading1="People Directory (Scrollable, Virtualized)"
heading2="Forced scrolling with many items"
.columns=${[
{ key: 'id', header: 'ID', sortable: true },

View File

@@ -199,6 +199,21 @@ export class DeesTable<T> extends DeesElement {
@property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
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
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
@@ -245,6 +260,46 @@ export class DeesTable<T> extends DeesElement {
@state()
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() {
super();
// 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 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 effectiveColumns: Column<T>[] = usingColumns
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
const cache = this.__memoEffectiveCols;
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);
this.__memoEffectiveCols = {
columns: this.columns,
augment: this.augmentFromDisplayFunction,
displayFunction: this.displayFunction,
data: this.data,
out,
};
return out;
}
const lucenePred = compileLucenePredicate<T>(
this.filterText,
this.searchMode === 'data' ? 'data' : 'table',
effectiveColumns
);
const viewData = getViewDataFn(
/**
* Returns the sorted/filtered view of the data, memoized by reference of
* everything that affects it. Avoids re-running the lucene compiler and
* the sort/filter pipeline on every render.
*/
private __getViewData(effectiveColumns: Column<T>[]): T[] {
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,
effectiveColumns,
this.sortBy,
this.filterText,
this.columnFilters,
this.searchMode === 'data' ? 'data' : 'table',
searchMode,
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;
// 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`
<dees-tile>
<div slot="header" class="header">
@@ -460,98 +593,25 @@ export class DeesTable<T> extends DeesElement {
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
<tbody>
${viewData.map((itemArg, rowIndex) => {
const getTr = (elementArg: HTMLElement): HTMLElement => {
if (elementArg.tagName === 'TR') {
return elementArg;
} else {
return getTr(elementArg.parentElement!);
}
};
<tbody
@click=${this.__onTbodyClick}
@dblclick=${this.__onTbodyDblclick}
@mousedown=${this.__onTbodyMousedown}
@contextmenu=${this.__onTbodyContextmenu}
@dragenter=${this.__onTbodyDragenter}
@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`
<tr
@click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
@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,
]);
}}
data-row-idx=${rowIndex}
class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
>
${this.showSelectionCheckbox
@@ -574,7 +634,6 @@ export class DeesTable<T> extends DeesElement {
: value;
const editKey = String(col.key);
const isEditable = !!(col.editable || col.editor);
const rowId = this.getRowId(itemArg);
const isFocused =
this.__focusedCell?.rowId === rowId &&
this.__focusedCell?.colKey === editKey;
@@ -591,26 +650,7 @@ export class DeesTable<T> extends DeesElement {
return html`
<td
class=${cellClasses}
@click=${(e: MouseEvent) => {
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 });
}
}}
data-col-key=${editKey}
>
<div class="innerCellContainer">
${isEditing ? this.renderCellEditor(itemArg, col) : content}
@@ -646,15 +686,20 @@ export class DeesTable<T> extends DeesElement {
})()}
</tr>`;
})}
${useVirtual && bottomSpacerHeight > 0
? html`<tr aria-hidden="true" style="height:${bottomSpacerHeight}px"><td></td></tr>`
: html``}
</tbody>
</table>
</div>
<div class="floatingHeader" aria-hidden="true">
<table>
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
</table>
${this.__floatingActive
? html`<table>
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
</table>`
: html``}
</div>
`
: html` <div class="noDataSet">No data set!</div> `}
@@ -771,7 +816,8 @@ export class DeesTable<T> extends DeesElement {
// ─── Floating header (page-sticky) lifecycle ─────────────────────────
private __floatingResizeObserver?: ResizeObserver;
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 get __floatingHeaderEl(): HTMLDivElement | null {
@@ -854,32 +900,45 @@ export class DeesTable<T> extends DeesElement {
private setupFloatingHeader() {
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;
if (!realTable) return;
this.__scrollAncestors = this.__collectScrollAncestors();
// .tableScroll is a descendant (inside our shadow root), not an ancestor,
// so the upward walk above misses it. Add it explicitly so horizontal
// scrolling inside the table re-syncs the floating header.
// so the upward walk above misses it. Add it explicitly. In Mode A
// (`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;
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.
this.__floatingResizeObserver = new ResizeObserver(() => {
this.__syncFloatingHeader();
if (!this.fixedHeight) this.__syncFloatingHeader();
if (this.virtualized) this.__computeVirtualRange();
});
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) {
a.target.addEventListener('scroll', 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() {
@@ -898,35 +957,99 @@ export class DeesTable<T> extends DeesElement {
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
* 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() {
const fh = this.__floatingHeaderEl;
const realTable = this.__realTableEl;
const floatTable = this.__floatingTableEl;
if (!fh || !realTable || !floatTable) return;
if (!fh || !realTable) return;
const tableRect = realTable.getBoundingClientRect();
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 floatHeadRows = floatTable.tHead?.rows;
let headerHeight = 0;
if (realHeadRows && floatHeadRows) {
for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
if (realHeadRows) {
for (let r = 0; r < realHeadRows.length; r++) {
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) {
this.__floatingActive = 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;
// 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
// so a horizontally-scrolled inner container's header doesn't bleed
// 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> {
super.updated(changedProperties);
this.determineColumnWidths();
// (Re)wire the floating header whenever the relevant props change or
// the table markup may have appeared/disappeared.
// Only re-measure column widths when the data or schema actually changed
// (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 (
changedProperties.has('fixedHeight') ||
changedProperties.has('virtualized') ||
changedProperties.has('data') ||
changedProperties.has('columns') ||
!this.__floatingScrollHandler
) {
if (!this.fixedHeight && this.data.length > 0) {
const needsScrollWatchers = (!this.fixedHeight || this.virtualized) && this.data.length > 0;
if (needsScrollWatchers) {
this.setupFloatingHeader();
} else {
this.teardownFloatingHeader();
}
}
// Keep the floating header in sync after any re-render
// (column widths may have changed).
if (!this.fixedHeight && this.data.length > 0) {
// Only sync the floating header geometry when it's actually showing or
// the table layout-affecting state changed. Avoids per-render layout
// 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();
}
if (this.searchable) {
@@ -1502,6 +1681,187 @@ export class DeesTable<T> extends DeesElement {
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:
* - plain click: select only this row, set anchor