@design.estate/dees-catalog 3.65.0 → 3.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -199,6 +199,21 @@ export class DeesTable<T> extends DeesElement {
199
199
  @property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
200
200
  accessor showSelectionCheckbox: boolean = false;
201
201
 
202
+ /**
203
+ * Enables row virtualization. Only rows visible in the nearest scroll
204
+ * ancestor (or the viewport) plus a small overscan are rendered. Top and
205
+ * bottom spacer rows preserve the scrollbar geometry.
206
+ *
207
+ * Assumes uniform row height (measured once from the first rendered row).
208
+ * Recommended for tables with > a few hundred rows.
209
+ */
210
+ @property({ type: Boolean, reflect: true, attribute: 'virtualized' })
211
+ accessor virtualized: boolean = false;
212
+
213
+ /** Number of extra rows rendered above and below the visible window. */
214
+ @property({ type: Number, attribute: 'virtual-overscan' })
215
+ accessor virtualOverscan: number = 8;
216
+
202
217
  /**
203
218
  * When set, the table renders inside a fixed-height scroll container
204
219
  * (`max-height: var(--table-max-height, 360px)`) and the header sticks
@@ -245,6 +260,46 @@ export class DeesTable<T> extends DeesElement {
245
260
  @state()
246
261
  private accessor __editingCell: { rowId: string; colKey: string } | undefined = undefined;
247
262
 
263
+ /**
264
+ * True while the page-sticky floating header overlay is visible. Lifted
265
+ * to @state so the floating-header clone subtree is rendered only when
266
+ * needed (saves a full thead worth of cells per render when inactive).
267
+ */
268
+ @state()
269
+ private accessor __floatingActive: boolean = false;
270
+
271
+ // ─── Render memoization ──────────────────────────────────────────────
272
+ // These caches let render() short-circuit when the relevant inputs
273
+ // (by reference) haven't changed. They are NOT @state — mutating them
274
+ // must never trigger a re-render.
275
+ private __memoEffectiveCols?: {
276
+ columns: any;
277
+ augment: boolean;
278
+ displayFunction: any;
279
+ data: any;
280
+ out: Column<T>[];
281
+ };
282
+ private __memoViewData?: {
283
+ data: any;
284
+ sortBy: any;
285
+ filterText: string;
286
+ columnFilters: any;
287
+ searchMode: string;
288
+ effectiveColumns: Column<T>[];
289
+ out: T[];
290
+ };
291
+ /** Tracks the (data, columns) pair that `determineColumnWidths()` last sized for. */
292
+ private __columnsSizedFor?: { data: any; columns: any };
293
+
294
+ // ─── Virtualization state ────────────────────────────────────────────
295
+ /** Estimated row height (px). Measured once from the first rendered row. */
296
+ private __rowHeight: number = 36;
297
+ /** True once we've measured `__rowHeight` from a real DOM row. */
298
+ private __rowHeightMeasured: boolean = false;
299
+ /** Currently rendered range [start, end). Triggers re-render when changed. */
300
+ @state()
301
+ private accessor __virtualRange: { start: number; end: number } = { start: 0, end: 0 };
302
+
248
303
  constructor() {
249
304
  super();
250
305
  // Make the host focusable so it can receive Ctrl/Cmd+C for copy.
@@ -368,28 +423,106 @@ export class DeesTable<T> extends DeesElement {
368
423
 
369
424
  public static styles = tableStyles;
370
425
 
371
- public render(): TemplateResult {
426
+ /**
427
+ * Returns the effective column schema, memoized by reference of the inputs
428
+ * that affect it. Avoids re-running `computeEffectiveColumnsFn` /
429
+ * `computeColumnsFromDisplayFunctionFn` on every Lit update.
430
+ */
431
+ private __getEffectiveColumns(): Column<T>[] {
372
432
  const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
373
- const effectiveColumns: Column<T>[] = usingColumns
374
- ? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
433
+ const cache = this.__memoEffectiveCols;
434
+ if (
435
+ cache &&
436
+ cache.columns === this.columns &&
437
+ cache.augment === this.augmentFromDisplayFunction &&
438
+ cache.displayFunction === this.displayFunction &&
439
+ cache.data === this.data
440
+ ) {
441
+ return cache.out;
442
+ }
443
+ const out = usingColumns
444
+ ? computeEffectiveColumnsFn(
445
+ this.columns,
446
+ this.augmentFromDisplayFunction,
447
+ this.displayFunction,
448
+ this.data
449
+ )
375
450
  : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
451
+ this.__memoEffectiveCols = {
452
+ columns: this.columns,
453
+ augment: this.augmentFromDisplayFunction,
454
+ displayFunction: this.displayFunction,
455
+ data: this.data,
456
+ out,
457
+ };
458
+ return out;
459
+ }
376
460
 
377
- const lucenePred = compileLucenePredicate<T>(
378
- this.filterText,
379
- this.searchMode === 'data' ? 'data' : 'table',
380
- effectiveColumns
381
- );
382
-
383
- const viewData = getViewDataFn(
461
+ /**
462
+ * Returns the sorted/filtered view of the data, memoized by reference of
463
+ * everything that affects it. Avoids re-running the lucene compiler and
464
+ * the sort/filter pipeline on every render.
465
+ */
466
+ private __getViewData(effectiveColumns: Column<T>[]): T[] {
467
+ const searchMode = this.searchMode === 'data' ? 'data' : 'table';
468
+ const cache = this.__memoViewData;
469
+ if (
470
+ cache &&
471
+ cache.data === this.data &&
472
+ cache.sortBy === this.sortBy &&
473
+ cache.filterText === this.filterText &&
474
+ cache.columnFilters === this.columnFilters &&
475
+ cache.searchMode === searchMode &&
476
+ cache.effectiveColumns === effectiveColumns
477
+ ) {
478
+ return cache.out;
479
+ }
480
+ const lucenePred = compileLucenePredicate<T>(this.filterText, searchMode, effectiveColumns);
481
+ const out = getViewDataFn(
384
482
  this.data,
385
483
  effectiveColumns,
386
484
  this.sortBy,
387
485
  this.filterText,
388
486
  this.columnFilters,
389
- this.searchMode === 'data' ? 'data' : 'table',
487
+ searchMode,
390
488
  lucenePred || undefined
391
489
  );
490
+ this.__memoViewData = {
491
+ data: this.data,
492
+ sortBy: this.sortBy,
493
+ filterText: this.filterText,
494
+ columnFilters: this.columnFilters,
495
+ searchMode,
496
+ effectiveColumns,
497
+ out,
498
+ };
499
+ return out;
500
+ }
501
+
502
+ public render(): TemplateResult {
503
+ const effectiveColumns = this.__getEffectiveColumns();
504
+ const viewData = this.__getViewData(effectiveColumns);
392
505
  (this as any)._lastViewData = viewData;
506
+
507
+ // Virtualization slice — only the rows in `__virtualRange` actually
508
+ // render. Top/bottom spacer rows preserve scroll geometry.
509
+ const useVirtual = this.virtualized && viewData.length > 0;
510
+ let renderRows: T[] = viewData;
511
+ let renderStart = 0;
512
+ let topSpacerHeight = 0;
513
+ let bottomSpacerHeight = 0;
514
+ if (useVirtual) {
515
+ const range = this.__virtualRange;
516
+ const start = Math.max(0, range.start);
517
+ const end = Math.min(viewData.length, range.end || 0);
518
+ // On the very first render the range is {0,0} — render a small first
519
+ // window so we can measure row height and compute the real range.
520
+ const initialEnd = end > 0 ? end : Math.min(viewData.length, this.virtualOverscan * 2 + 16);
521
+ renderStart = start;
522
+ renderRows = viewData.slice(start, initialEnd);
523
+ topSpacerHeight = start * this.__rowHeight;
524
+ bottomSpacerHeight = Math.max(0, viewData.length - initialEnd) * this.__rowHeight;
525
+ }
393
526
  return html`
394
527
  <dees-tile>
395
528
  <div slot="header" class="header">
@@ -460,98 +593,25 @@ export class DeesTable<T> extends DeesElement {
460
593
  <thead>
461
594
  ${this.renderHeaderRows(effectiveColumns)}
462
595
  </thead>
463
- <tbody>
464
- ${viewData.map((itemArg, rowIndex) => {
465
- const getTr = (elementArg: HTMLElement): HTMLElement => {
466
- if (elementArg.tagName === 'TR') {
467
- return elementArg;
468
- } else {
469
- return getTr(elementArg.parentElement!);
470
- }
471
- };
596
+ <tbody
597
+ @click=${this.__onTbodyClick}
598
+ @dblclick=${this.__onTbodyDblclick}
599
+ @mousedown=${this.__onTbodyMousedown}
600
+ @contextmenu=${this.__onTbodyContextmenu}
601
+ @dragenter=${this.__onTbodyDragenter}
602
+ @dragleave=${this.__onTbodyDragleave}
603
+ @dragover=${this.__onTbodyDragover}
604
+ @drop=${this.__onTbodyDrop}
605
+ >
606
+ ${useVirtual && topSpacerHeight > 0
607
+ ? html`<tr aria-hidden="true" style="height:${topSpacerHeight}px"><td></td></tr>`
608
+ : html``}
609
+ ${renderRows.map((itemArg, sliceIdx) => {
610
+ const rowIndex = renderStart + sliceIdx;
611
+ const rowId = this.getRowId(itemArg);
472
612
  return html`
473
613
  <tr
474
- @click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
475
- @mousedown=${(e: MouseEvent) => {
476
- // Prevent the browser's native shift-click text
477
- // selection so range-select doesn't highlight text.
478
- if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
479
- }}
480
- @dragenter=${async (eventArg: DragEvent) => {
481
- eventArg.preventDefault();
482
- eventArg.stopPropagation();
483
- const realTarget = getTr(eventArg.target as HTMLElement);
484
- setTimeout(() => {
485
- realTarget.classList.add('hasAttachment');
486
- }, 0);
487
- }}
488
- @dragleave=${async (eventArg: DragEvent) => {
489
- eventArg.preventDefault();
490
- eventArg.stopPropagation();
491
- const realTarget = getTr(eventArg.target as HTMLElement);
492
- realTarget.classList.remove('hasAttachment');
493
- }}
494
- @dragover=${async (eventArg: DragEvent) => {
495
- eventArg.preventDefault();
496
- }}
497
- @drop=${async (eventArg: DragEvent) => {
498
- eventArg.preventDefault();
499
- const newFiles: File[] = [];
500
- for (const file of Array.from(eventArg.dataTransfer!.files)) {
501
- this.files.push(file);
502
- newFiles.push(file);
503
- this.requestUpdate();
504
- }
505
- const result: File[] = this.fileWeakMap.get(itemArg as object);
506
- if (!result) {
507
- this.fileWeakMap.set(itemArg as object, newFiles);
508
- } else {
509
- result.push(...newFiles);
510
- }
511
- }}
512
- @contextmenu=${async (eventArg: MouseEvent) => {
513
- // If the right-clicked row isn't part of the
514
- // current selection, treat it like a plain click
515
- // first so the context menu acts on a sensible
516
- // selection (matches file-manager behavior).
517
- if (!this.isRowSelected(itemArg)) {
518
- this.selectedDataRow = itemArg;
519
- this.selectedIds.clear();
520
- this.selectedIds.add(this.getRowId(itemArg));
521
- this.__selectionAnchorId = this.getRowId(itemArg);
522
- this.emitSelectionChange();
523
- this.requestUpdate();
524
- }
525
- const userItems: plugins.tsclass.website.IMenuItem[] =
526
- this.getActionsForType('contextmenu').map((action) => ({
527
- name: action.name,
528
- iconName: action.iconName as any,
529
- action: async () => {
530
- await action.actionFunc({
531
- item: itemArg,
532
- table: this,
533
- });
534
- return null;
535
- },
536
- }));
537
- const defaultItems: plugins.tsclass.website.IMenuItem[] = [
538
- {
539
- name:
540
- this.selectedIds.size > 1
541
- ? `Copy ${this.selectedIds.size} rows as JSON`
542
- : 'Copy row as JSON',
543
- iconName: 'lucide:Copy' as any,
544
- action: async () => {
545
- this.copySelectionAsJson(itemArg);
546
- return null;
547
- },
548
- },
549
- ];
550
- DeesContextmenu.openContextMenuWithOptions(eventArg, [
551
- ...userItems,
552
- ...defaultItems,
553
- ]);
554
- }}
614
+ data-row-idx=${rowIndex}
555
615
  class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
556
616
  >
557
617
  ${this.showSelectionCheckbox
@@ -574,7 +634,6 @@ export class DeesTable<T> extends DeesElement {
574
634
  : value;
575
635
  const editKey = String(col.key);
576
636
  const isEditable = !!(col.editable || col.editor);
577
- const rowId = this.getRowId(itemArg);
578
637
  const isFocused =
579
638
  this.__focusedCell?.rowId === rowId &&
580
639
  this.__focusedCell?.colKey === editKey;
@@ -591,26 +650,7 @@ export class DeesTable<T> extends DeesElement {
591
650
  return html`
592
651
  <td
593
652
  class=${cellClasses}
594
- @click=${(e: MouseEvent) => {
595
- if (isEditing) {
596
- e.stopPropagation();
597
- return;
598
- }
599
- if (isEditable) {
600
- this.__focusedCell = { rowId, colKey: editKey };
601
- }
602
- }}
603
- @dblclick=${(e: Event) => {
604
- const dblAction = this.dataActions.find((actionArg) =>
605
- actionArg.type?.includes('doubleClick')
606
- );
607
- if (isEditable) {
608
- e.stopPropagation();
609
- this.startEditing(itemArg, col);
610
- } else if (dblAction) {
611
- dblAction.actionFunc({ item: itemArg, table: this });
612
- }
613
- }}
653
+ data-col-key=${editKey}
614
654
  >
615
655
  <div class="innerCellContainer">
616
656
  ${isEditing ? this.renderCellEditor(itemArg, col) : content}
@@ -646,15 +686,20 @@ export class DeesTable<T> extends DeesElement {
646
686
  })()}
647
687
  </tr>`;
648
688
  })}
689
+ ${useVirtual && bottomSpacerHeight > 0
690
+ ? html`<tr aria-hidden="true" style="height:${bottomSpacerHeight}px"><td></td></tr>`
691
+ : html``}
649
692
  </tbody>
650
693
  </table>
651
694
  </div>
652
695
  <div class="floatingHeader" aria-hidden="true">
653
- <table>
654
- <thead>
655
- ${this.renderHeaderRows(effectiveColumns)}
656
- </thead>
657
- </table>
696
+ ${this.__floatingActive
697
+ ? html`<table>
698
+ <thead>
699
+ ${this.renderHeaderRows(effectiveColumns)}
700
+ </thead>
701
+ </table>`
702
+ : html``}
658
703
  </div>
659
704
  `
660
705
  : html` <div class="noDataSet">No data set!</div> `}
@@ -771,7 +816,8 @@ export class DeesTable<T> extends DeesElement {
771
816
  // ─── Floating header (page-sticky) lifecycle ─────────────────────────
772
817
  private __floatingResizeObserver?: ResizeObserver;
773
818
  private __floatingScrollHandler?: () => void;
774
- private __floatingActive = false;
819
+ // __floatingActive is declared as a @state field above so its toggle
820
+ // triggers re-rendering of the floating-header clone subtree.
775
821
  private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
776
822
 
777
823
  private get __floatingHeaderEl(): HTMLDivElement | null {
@@ -854,32 +900,45 @@ export class DeesTable<T> extends DeesElement {
854
900
 
855
901
  private setupFloatingHeader() {
856
902
  this.teardownFloatingHeader();
857
- if (this.fixedHeight) return;
903
+ // Skip entirely only when neither feature needs scroll watchers.
904
+ if (this.fixedHeight && !this.virtualized) return;
858
905
  const realTable = this.__realTableEl;
859
906
  if (!realTable) return;
860
907
 
861
908
  this.__scrollAncestors = this.__collectScrollAncestors();
862
909
  // .tableScroll is a descendant (inside our shadow root), not an ancestor,
863
- // so the upward walk above misses it. Add it explicitly so horizontal
864
- // scrolling inside the table re-syncs the floating header.
910
+ // so the upward walk above misses it. Add it explicitly. In Mode A
911
+ // (`fixedHeight`) it is the only vertical scroll source — mark it as
912
+ // scrollsY in that case so virtualization picks it up.
865
913
  const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
866
914
  if (tableScrollEl) {
867
- this.__scrollAncestors.unshift({ target: tableScrollEl, scrollsY: false, scrollsX: true });
915
+ this.__scrollAncestors.unshift({
916
+ target: tableScrollEl,
917
+ scrollsY: this.fixedHeight,
918
+ scrollsX: true,
919
+ });
868
920
  }
869
921
 
870
922
  // Track resize of the real table so we can mirror its width and column widths.
871
923
  this.__floatingResizeObserver = new ResizeObserver(() => {
872
- this.__syncFloatingHeader();
924
+ if (!this.fixedHeight) this.__syncFloatingHeader();
925
+ if (this.virtualized) this.__computeVirtualRange();
873
926
  });
874
927
  this.__floatingResizeObserver.observe(realTable);
875
928
 
876
- this.__floatingScrollHandler = () => this.__syncFloatingHeader();
929
+ this.__floatingScrollHandler = () => {
930
+ if (!this.fixedHeight) this.__syncFloatingHeader();
931
+ // Recompute virtual range on every scroll — cheap (one rect read +
932
+ // some math) and necessary so rows materialize before they're seen.
933
+ if (this.virtualized) this.__computeVirtualRange();
934
+ };
877
935
  for (const a of this.__scrollAncestors) {
878
936
  a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
879
937
  }
880
938
  window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
881
939
 
882
- this.__syncFloatingHeader();
940
+ if (!this.fixedHeight) this.__syncFloatingHeader();
941
+ if (this.virtualized) this.__computeVirtualRange();
883
942
  }
884
943
 
885
944
  private teardownFloatingHeader() {
@@ -898,35 +957,99 @@ export class DeesTable<T> extends DeesElement {
898
957
  if (fh) fh.classList.remove('active');
899
958
  }
900
959
 
960
+ // ─── Virtualization ─────────────────────────────────────────────────
961
+
962
+ /**
963
+ * Computes the visible row range based on the table's position in its
964
+ * nearest vertical scroll ancestor (or the viewport). Updates
965
+ * `__virtualRange` if it changed; that triggers a Lit re-render.
966
+ */
967
+ private __computeVirtualRange() {
968
+ if (!this.virtualized) return;
969
+ const view: T[] = (this as any)._lastViewData ?? [];
970
+ const total = view.length;
971
+ if (total === 0) {
972
+ if (this.__virtualRange.start !== 0 || this.__virtualRange.end !== 0) {
973
+ this.__virtualRange = { start: 0, end: 0 };
974
+ }
975
+ return;
976
+ }
977
+ const realTable = this.__realTableEl;
978
+ if (!realTable) return;
979
+ const tableRect = realTable.getBoundingClientRect();
980
+
981
+ // Find the innermost vertical scroll ancestor (rect + content height).
982
+ let viewportTop = 0;
983
+ let viewportBottom = window.innerHeight;
984
+ for (const a of this.__scrollAncestors) {
985
+ if (a.target === window || !a.scrollsY) continue;
986
+ const r = (a.target as Element).getBoundingClientRect();
987
+ const cs = getComputedStyle(a.target as Element);
988
+ const bt = parseFloat(cs.borderTopWidth) || 0;
989
+ const bb = parseFloat(cs.borderBottomWidth) || 0;
990
+ viewportTop = Math.max(viewportTop, r.top + bt);
991
+ viewportBottom = Math.min(viewportBottom, r.bottom - bb);
992
+ }
993
+
994
+ const rowH = Math.max(1, this.__rowHeight);
995
+ // Distance from the table top to the visible window top, in px of body
996
+ // content (so any header offset above the rows is excluded).
997
+ const headerHeight = realTable.tHead?.getBoundingClientRect().height ?? 0;
998
+ const bodyTop = tableRect.top + headerHeight;
999
+ const offsetIntoBody = Math.max(0, viewportTop - bodyTop);
1000
+ const visiblePx = Math.max(0, viewportBottom - Math.max(viewportTop, bodyTop));
1001
+
1002
+ const startRaw = Math.floor(offsetIntoBody / rowH);
1003
+ const visibleCount = Math.ceil(visiblePx / rowH) + 1;
1004
+ const start = Math.max(0, startRaw - this.virtualOverscan);
1005
+ const end = Math.min(total, startRaw + visibleCount + this.virtualOverscan);
1006
+
1007
+ if (start !== this.__virtualRange.start || end !== this.__virtualRange.end) {
1008
+ this.__virtualRange = { start, end };
1009
+ }
1010
+ }
1011
+
1012
+ /**
1013
+ * Measures the height of the first rendered body row and stores it for
1014
+ * subsequent virtualization math. Idempotent — only measures once per
1015
+ * `data`/`columns` pair (cleared in `updated()` when those change).
1016
+ */
1017
+ private __measureRowHeight() {
1018
+ if (!this.virtualized || this.__rowHeightMeasured) return;
1019
+ const tbody = this.shadowRoot?.querySelector('tbody') as HTMLTableSectionElement | null;
1020
+ if (!tbody) return;
1021
+ const firstRow = Array.from(tbody.rows).find((r) => r.hasAttribute('data-row-idx'));
1022
+ if (!firstRow) return;
1023
+ const h = firstRow.getBoundingClientRect().height;
1024
+ if (h > 0) {
1025
+ this.__rowHeight = h;
1026
+ this.__rowHeightMeasured = true;
1027
+ }
1028
+ }
1029
+
901
1030
  /**
902
1031
  * Single function that drives both activation and geometry of the floating
903
- * header. Called on scroll, resize, table-resize, and after each render.
1032
+ * header. Called on scroll, resize, table-resize, and after relevant
1033
+ * renders.
1034
+ *
1035
+ * Activation is decided from the *real* header geometry, so this function
1036
+ * works even when the clone subtree hasn't been rendered yet (it's only
1037
+ * rendered when `__floatingActive` is true). The first activation flips
1038
+ * `__floatingActive`; the next render materializes the clone; the next
1039
+ * call here mirrors widths and positions.
904
1040
  */
905
1041
  private __syncFloatingHeader() {
906
1042
  const fh = this.__floatingHeaderEl;
907
1043
  const realTable = this.__realTableEl;
908
- const floatTable = this.__floatingTableEl;
909
- if (!fh || !realTable || !floatTable) return;
1044
+ if (!fh || !realTable) return;
910
1045
 
911
1046
  const tableRect = realTable.getBoundingClientRect();
912
1047
  const stick = this.__getStickContext();
913
-
914
- // Mirror table layout + per-cell widths so columns line up.
915
- floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
916
1048
  const realHeadRows = realTable.tHead?.rows;
917
- const floatHeadRows = floatTable.tHead?.rows;
918
1049
  let headerHeight = 0;
919
- if (realHeadRows && floatHeadRows) {
920
- for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
1050
+ if (realHeadRows) {
1051
+ for (let r = 0; r < realHeadRows.length; r++) {
921
1052
  headerHeight += realHeadRows[r].getBoundingClientRect().height;
922
- const realCells = realHeadRows[r].cells;
923
- const floatCells = floatHeadRows[r].cells;
924
- for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
925
- const w = realCells[c].getBoundingClientRect().width;
926
- (floatCells[c] as HTMLElement).style.width = `${w}px`;
927
- (floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
928
- (floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
929
- }
930
1053
  }
931
1054
  }
932
1055
 
@@ -938,9 +1061,34 @@ export class DeesTable<T> extends DeesElement {
938
1061
  if (shouldBeActive !== this.__floatingActive) {
939
1062
  this.__floatingActive = shouldBeActive;
940
1063
  fh.classList.toggle('active', shouldBeActive);
1064
+ if (shouldBeActive) {
1065
+ // Clone subtree doesn't exist yet — wait for the next render to
1066
+ // materialize it, then complete geometry sync.
1067
+ this.updateComplete.then(() => this.__syncFloatingHeader());
1068
+ return;
1069
+ }
941
1070
  }
942
1071
  if (!shouldBeActive) return;
943
1072
 
1073
+ // Mirror table layout + per-cell widths so columns line up. The clone
1074
+ // exists at this point because __floatingActive === true.
1075
+ const floatTable = this.__floatingTableEl;
1076
+ if (!floatTable) return;
1077
+ floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
1078
+ const floatHeadRows = floatTable.tHead?.rows;
1079
+ if (realHeadRows && floatHeadRows) {
1080
+ for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
1081
+ const realCells = realHeadRows[r].cells;
1082
+ const floatCells = floatHeadRows[r].cells;
1083
+ for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
1084
+ const w = realCells[c].getBoundingClientRect().width;
1085
+ (floatCells[c] as HTMLElement).style.width = `${w}px`;
1086
+ (floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
1087
+ (floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
1088
+ }
1089
+ }
1090
+ }
1091
+
944
1092
  // Position the floating header. Clip horizontally to the scroll context
945
1093
  // so a horizontally-scrolled inner container's header doesn't bleed
946
1094
  // outside the container's border.
@@ -970,24 +1118,55 @@ export class DeesTable<T> extends DeesElement {
970
1118
 
971
1119
  public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
972
1120
  super.updated(changedProperties);
973
- this.determineColumnWidths();
974
- // (Re)wire the floating header whenever the relevant props change or
975
- // the table markup may have appeared/disappeared.
1121
+
1122
+ // Only re-measure column widths when the data or schema actually changed
1123
+ // (or on first paint). `determineColumnWidths` is the single biggest
1124
+ // first-paint cost — it forces multiple layout flushes per row.
1125
+ const dataOrColsChanged =
1126
+ !this.__columnsSizedFor ||
1127
+ this.__columnsSizedFor.data !== this.data ||
1128
+ this.__columnsSizedFor.columns !== this.columns;
1129
+ if (dataOrColsChanged) {
1130
+ this.__columnsSizedFor = { data: this.data, columns: this.columns };
1131
+ this.determineColumnWidths();
1132
+ // Force re-measure of row height; structure may have changed.
1133
+ this.__rowHeightMeasured = false;
1134
+ }
1135
+
1136
+ // Virtualization: measure row height after the first paint with rows,
1137
+ // then compute the visible range. Both ops only run when `virtualized`
1138
+ // is true, so the cost is zero for normal tables.
1139
+ if (this.virtualized) {
1140
+ this.__measureRowHeight();
1141
+ this.__computeVirtualRange();
1142
+ }
1143
+
1144
+ // (Re)wire the scroll watchers (used by both the floating header in
1145
+ // Mode B and by virtualization). Skip entirely only when neither
1146
+ // feature needs them.
976
1147
  if (
977
1148
  changedProperties.has('fixedHeight') ||
1149
+ changedProperties.has('virtualized') ||
978
1150
  changedProperties.has('data') ||
979
1151
  changedProperties.has('columns') ||
980
1152
  !this.__floatingScrollHandler
981
1153
  ) {
982
- if (!this.fixedHeight && this.data.length > 0) {
1154
+ const needsScrollWatchers = (!this.fixedHeight || this.virtualized) && this.data.length > 0;
1155
+ if (needsScrollWatchers) {
983
1156
  this.setupFloatingHeader();
984
1157
  } else {
985
1158
  this.teardownFloatingHeader();
986
1159
  }
987
1160
  }
988
- // Keep the floating header in sync after any re-render
989
- // (column widths may have changed).
990
- if (!this.fixedHeight && this.data.length > 0) {
1161
+ // Only sync the floating header geometry when it's actually showing or
1162
+ // the table layout-affecting state changed. Avoids per-render layout
1163
+ // reads (getBoundingClientRect on every header cell) for typical updates
1164
+ // like sort changes or selection toggles.
1165
+ if (
1166
+ !this.fixedHeight &&
1167
+ this.data.length > 0 &&
1168
+ (this.__floatingActive || dataOrColsChanged)
1169
+ ) {
991
1170
  this.__syncFloatingHeader();
992
1171
  }
993
1172
  if (this.searchable) {
@@ -1502,6 +1681,187 @@ export class DeesTable<T> extends DeesElement {
1502
1681
  this.requestUpdate();
1503
1682
  }
1504
1683
 
1684
+ // ─── Delegated tbody event handlers ─────────────────────────────────
1685
+ // Hoisted from per-<tr> closures to a single set of handlers on <tbody>.
1686
+ // Cuts ~7 closure allocations per row per render. Each handler resolves
1687
+ // the source row via `data-row-idx` (and `data-col-key` for cell-level
1688
+ // events) using the latest `_lastViewData`.
1689
+
1690
+ private __resolveRow(eventArg: Event): { item: T; rowIdx: number } | null {
1691
+ const path = (eventArg.composedPath?.() || []) as EventTarget[];
1692
+ let tr: HTMLTableRowElement | null = null;
1693
+ for (const t of path) {
1694
+ const el = t as HTMLElement;
1695
+ if (el?.tagName === 'TR' && el.hasAttribute('data-row-idx')) {
1696
+ tr = el as HTMLTableRowElement;
1697
+ break;
1698
+ }
1699
+ }
1700
+ if (!tr) return null;
1701
+ const rowIdx = Number(tr.getAttribute('data-row-idx'));
1702
+ const view: T[] = (this as any)._lastViewData ?? [];
1703
+ const item = view[rowIdx];
1704
+ if (!item) return null;
1705
+ return { item, rowIdx };
1706
+ }
1707
+
1708
+ private __resolveCell(eventArg: Event): { item: T; rowIdx: number; col: Column<T> } | null {
1709
+ const row = this.__resolveRow(eventArg);
1710
+ if (!row) return null;
1711
+ const path = (eventArg.composedPath?.() || []) as EventTarget[];
1712
+ let td: HTMLTableCellElement | null = null;
1713
+ for (const t of path) {
1714
+ const el = t as HTMLElement;
1715
+ if (el?.tagName === 'TD' && el.hasAttribute('data-col-key')) {
1716
+ td = el as HTMLTableCellElement;
1717
+ break;
1718
+ }
1719
+ }
1720
+ if (!td) return null;
1721
+ const colKey = td.getAttribute('data-col-key')!;
1722
+ const cols = this.__getEffectiveColumns();
1723
+ const col = cols.find((c) => String(c.key) === colKey);
1724
+ if (!col) return null;
1725
+ return { item: row.item, rowIdx: row.rowIdx, col };
1726
+ }
1727
+
1728
+ private __isInActionsCol(eventArg: Event): boolean {
1729
+ const path = (eventArg.composedPath?.() || []) as EventTarget[];
1730
+ for (const t of path) {
1731
+ const el = t as HTMLElement;
1732
+ if (el?.classList?.contains('actionsCol')) return true;
1733
+ }
1734
+ return false;
1735
+ }
1736
+
1737
+ private __isInEditor(eventArg: Event): boolean {
1738
+ const path = (eventArg.composedPath?.() || []) as EventTarget[];
1739
+ for (const t of path) {
1740
+ const el = t as HTMLElement;
1741
+ const tag = el?.tagName;
1742
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || el?.isContentEditable) return true;
1743
+ if (tag && tag.startsWith('DEES-INPUT-')) return true;
1744
+ }
1745
+ return false;
1746
+ }
1747
+
1748
+ private __onTbodyClick = (eventArg: MouseEvent) => {
1749
+ if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return;
1750
+ const cell = this.__resolveCell(eventArg);
1751
+ if (!cell) return;
1752
+ const view: T[] = (this as any)._lastViewData ?? [];
1753
+ // Cell focus (when editable)
1754
+ if (cell.col.editable || cell.col.editor) {
1755
+ this.__focusedCell = {
1756
+ rowId: this.getRowId(cell.item),
1757
+ colKey: String(cell.col.key),
1758
+ };
1759
+ }
1760
+ // Row selection (file-manager style)
1761
+ this.handleRowClick(eventArg, cell.item, cell.rowIdx, view);
1762
+ };
1763
+
1764
+ private __onTbodyDblclick = (eventArg: MouseEvent) => {
1765
+ if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return;
1766
+ const cell = this.__resolveCell(eventArg);
1767
+ if (!cell) return;
1768
+ const isEditable = !!(cell.col.editable || cell.col.editor);
1769
+ if (isEditable) {
1770
+ eventArg.stopPropagation();
1771
+ this.startEditing(cell.item, cell.col);
1772
+ return;
1773
+ }
1774
+ const dblAction = this.dataActions.find((a) => a.type?.includes('doubleClick'));
1775
+ if (dblAction) dblAction.actionFunc({ item: cell.item, table: this });
1776
+ };
1777
+
1778
+ private __onTbodyMousedown = (eventArg: MouseEvent) => {
1779
+ // Suppress browser's native shift-click text selection so range-select
1780
+ // doesn't highlight text mid-table.
1781
+ if (eventArg.shiftKey && this.selectionMode !== 'single') eventArg.preventDefault();
1782
+ };
1783
+
1784
+ private __onTbodyContextmenu = (eventArg: MouseEvent) => {
1785
+ if (this.__isInActionsCol(eventArg)) return;
1786
+ const row = this.__resolveRow(eventArg);
1787
+ if (!row) return;
1788
+ const item = row.item;
1789
+ // Match file-manager behavior: right-clicking a non-selected row makes
1790
+ // it the selection first.
1791
+ if (!this.isRowSelected(item)) {
1792
+ this.selectedDataRow = item;
1793
+ this.selectedIds.clear();
1794
+ this.selectedIds.add(this.getRowId(item));
1795
+ this.__selectionAnchorId = this.getRowId(item);
1796
+ this.emitSelectionChange();
1797
+ this.requestUpdate();
1798
+ }
1799
+ const userItems: plugins.tsclass.website.IMenuItem[] = this.getActionsForType('contextmenu').map(
1800
+ (action) => ({
1801
+ name: action.name,
1802
+ iconName: action.iconName as any,
1803
+ action: async () => {
1804
+ await action.actionFunc({ item, table: this });
1805
+ return null;
1806
+ },
1807
+ })
1808
+ );
1809
+ const defaultItems: plugins.tsclass.website.IMenuItem[] = [
1810
+ {
1811
+ name:
1812
+ this.selectedIds.size > 1
1813
+ ? `Copy ${this.selectedIds.size} rows as JSON`
1814
+ : 'Copy row as JSON',
1815
+ iconName: 'lucide:Copy' as any,
1816
+ action: async () => {
1817
+ this.copySelectionAsJson(item);
1818
+ return null;
1819
+ },
1820
+ },
1821
+ ];
1822
+ DeesContextmenu.openContextMenuWithOptions(eventArg, [...userItems, ...defaultItems]);
1823
+ };
1824
+
1825
+ private __onTbodyDragenter = (eventArg: DragEvent) => {
1826
+ eventArg.preventDefault();
1827
+ eventArg.stopPropagation();
1828
+ const row = this.__resolveRow(eventArg);
1829
+ if (!row) return;
1830
+ const tr = (eventArg.composedPath?.() || []).find(
1831
+ (t) => (t as HTMLElement)?.tagName === 'TR'
1832
+ ) as HTMLElement | undefined;
1833
+ if (tr) setTimeout(() => tr.classList.add('hasAttachment'), 0);
1834
+ };
1835
+
1836
+ private __onTbodyDragleave = (eventArg: DragEvent) => {
1837
+ eventArg.preventDefault();
1838
+ eventArg.stopPropagation();
1839
+ const tr = (eventArg.composedPath?.() || []).find(
1840
+ (t) => (t as HTMLElement)?.tagName === 'TR'
1841
+ ) as HTMLElement | undefined;
1842
+ if (tr) tr.classList.remove('hasAttachment');
1843
+ };
1844
+
1845
+ private __onTbodyDragover = (eventArg: DragEvent) => {
1846
+ eventArg.preventDefault();
1847
+ };
1848
+
1849
+ private __onTbodyDrop = async (eventArg: DragEvent) => {
1850
+ eventArg.preventDefault();
1851
+ const row = this.__resolveRow(eventArg);
1852
+ if (!row) return;
1853
+ const item = row.item;
1854
+ const newFiles: File[] = [];
1855
+ for (const file of Array.from(eventArg.dataTransfer!.files)) {
1856
+ this.files.push(file);
1857
+ newFiles.push(file);
1858
+ this.requestUpdate();
1859
+ }
1860
+ const existing: File[] | undefined = this.fileWeakMap.get(item as object);
1861
+ if (!existing) this.fileWeakMap.set(item as object, newFiles);
1862
+ else existing.push(...newFiles);
1863
+ };
1864
+
1505
1865
  /**
1506
1866
  * Handles row clicks with file-manager style selection semantics:
1507
1867
  * - plain click: select only this row, set anchor