From ac9cc8cfedd7a464bc3e1b6d30d04cb2573a4603 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 7 Apr 2026 15:56:55 +0000 Subject: [PATCH] feat(dees-table): add virtualized row rendering for large tables and optimize table rendering performance --- changelog.md | 7 + ts_web/00_commitinfo_data.ts | 2 +- .../dees-table/dees-table.demo.ts | 3 +- .../00group-dataview/dees-table/dees-table.ts | 678 ++++++++++++++---- 4 files changed, 529 insertions(+), 161 deletions(-) diff --git a/changelog.md b/changelog.md index 37b7619..d60bfae 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-07 - 3.66.0 - feat(dees-table) +add virtualized row rendering for large tables and optimize table rendering performance + +- add a virtualized mode with configurable overscan to render only visible rows while preserving scroll height +- improve table render performance with memoized column and view-data computation plus deferred floating header rendering +- update the dees-table demo to showcase virtualized scrolling in the fixed-height example + ## 2026-04-07 - 3.65.0 - feat(dees-table) add schema-based in-cell editing with keyboard navigation and cell edit events diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 39821ed..7edb860 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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.' } diff --git a/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts b/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts index f0fadb0..38da7a9 100644 --- a/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts +++ b/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts @@ -700,7 +700,8 @@ export const demoFunc = () => html` 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 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[]; + }; + private __memoViewData?: { + data: any; + sortBy: any; + filterText: string; + columnFilters: any; + searchMode: string; + effectiveColumns: Column[]; + 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 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[] { const usingColumns = Array.isArray(this.columns) && this.columns.length > 0; - const effectiveColumns: Column[] = 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( - 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[] { + 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(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`
@@ -460,98 +593,25 @@ export class DeesTable extends DeesElement { ${this.renderHeaderRows(effectiveColumns)} - - ${viewData.map((itemArg, rowIndex) => { - const getTr = (elementArg: HTMLElement): HTMLElement => { - if (elementArg.tagName === 'TR') { - return elementArg; - } else { - return getTr(elementArg.parentElement!); - } - }; + + ${useVirtual && topSpacerHeight > 0 + ? html`` + : html``} + ${renderRows.map((itemArg, sliceIdx) => { + const rowIndex = renderStart + sliceIdx; + const rowId = this.getRowId(itemArg); return html` 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 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 extends DeesElement { return html` { - 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} >
${isEditing ? this.renderCellEditor(itemArg, col) : content} @@ -646,15 +686,20 @@ export class DeesTable extends DeesElement { })()} `; })} + ${useVirtual && bottomSpacerHeight > 0 + ? html`` + : html``}
` : html`
No data set!
`} @@ -771,7 +816,8 @@ export class DeesTable 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 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 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 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 extends DeesElement { public async updated(changedProperties: Map): Promise { 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 extends DeesElement { this.requestUpdate(); } + // ─── Delegated tbody event handlers ───────────────────────────────── + // Hoisted from per- closures to a single set of handlers on . + // 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 } | 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