@design.estate/dees-catalog 3.64.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.
@@ -1,13 +1,25 @@
1
1
  import * as plugins from '../../00plugins.js';
2
2
  import { demoFunc } from './dees-table.demo.js';
3
- import { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element';
3
+ import { customElement, html, DeesElement, property, state, type TemplateResult, directives } from '@design.estate/dees-element';
4
4
 
5
5
  import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
6
6
  import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
7
7
  import * as domtools from '@design.estate/dees-domtools';
8
8
  import { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.js';
9
9
  import { tableStyles } from './styles.js';
10
- import type { Column, ISortDescriptor, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
10
+ import type {
11
+ Column,
12
+ ISortDescriptor,
13
+ ITableAction,
14
+ ITableActionDataArg,
15
+ TCellEditorType,
16
+ TDisplayFunction,
17
+ } from './types.js';
18
+ import '../../00group-input/dees-input-text/index.js';
19
+ import '../../00group-input/dees-input-checkbox/index.js';
20
+ import '../../00group-input/dees-input-dropdown/index.js';
21
+ import '../../00group-input/dees-input-datepicker/index.js';
22
+ import '../../00group-input/dees-input-tags/index.js';
11
23
  import {
12
24
  computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
13
25
  computeEffectiveColumns as computeEffectiveColumnsFn,
@@ -138,11 +150,6 @@ export class DeesTable<T> extends DeesElement {
138
150
  })
139
151
  accessor selectedDataRow!: T;
140
152
 
141
- @property({
142
- type: Array,
143
- })
144
- accessor editableFields: string[] = [];
145
-
146
153
  @property({
147
154
  type: Boolean,
148
155
  reflect: true,
@@ -192,6 +199,21 @@ export class DeesTable<T> extends DeesElement {
192
199
  @property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
193
200
  accessor showSelectionCheckbox: boolean = false;
194
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
+
195
217
  /**
196
218
  * When set, the table renders inside a fixed-height scroll container
197
219
  * (`max-height: var(--table-max-height, 360px)`) and the header sticks
@@ -224,6 +246,60 @@ export class DeesTable<T> extends DeesElement {
224
246
  */
225
247
  private __selectionAnchorId?: string;
226
248
 
249
+ /**
250
+ * Cell currently focused for keyboard navigation. When set, the cell shows
251
+ * a focus ring and Enter/F2 enters edit mode. Independent from row selection.
252
+ */
253
+ @state()
254
+ private accessor __focusedCell: { rowId: string; colKey: string } | undefined = undefined;
255
+
256
+ /**
257
+ * Cell currently being edited. When set, that cell renders an editor
258
+ * (dees-input-*) instead of its display content.
259
+ */
260
+ @state()
261
+ private accessor __editingCell: { rowId: string; colKey: string } | undefined = undefined;
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
+
227
303
  constructor() {
228
304
  super();
229
305
  // Make the host focusable so it can receive Ctrl/Cmd+C for copy.
@@ -238,24 +314,84 @@ export class DeesTable<T> extends DeesElement {
238
314
  * receive the copy.
239
315
  */
240
316
  private __handleHostKeydown = (eventArg: KeyboardEvent) => {
241
- const isCopy = (eventArg.metaKey || eventArg.ctrlKey) && (eventArg.key === 'c' || eventArg.key === 'C');
242
- if (!isCopy) return;
243
- // Don't hijack copy when the user is selecting text in an input/textarea.
317
+ // Detect whether the keydown originated inside an editor (input/textarea
318
+ // or contenteditable). Used to skip both copy hijacking and grid nav.
244
319
  const path = (eventArg.composedPath?.() || []) as EventTarget[];
320
+ let inEditor = false;
245
321
  for (const t of path) {
246
322
  const tag = (t as HTMLElement)?.tagName;
247
- if (tag === 'INPUT' || tag === 'TEXTAREA') return;
248
- if ((t as HTMLElement)?.isContentEditable) return;
323
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || (t as HTMLElement)?.isContentEditable) {
324
+ inEditor = true;
325
+ break;
326
+ }
249
327
  }
250
- const rows: T[] = [];
251
- if (this.selectedIds.size > 0) {
252
- for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
253
- } else if (this.selectedDataRow) {
254
- rows.push(this.selectedDataRow);
328
+
329
+ // Ctrl/Cmd+C → copy selected rows as JSON (unless typing in an input).
330
+ const isCopy =
331
+ (eventArg.metaKey || eventArg.ctrlKey) && (eventArg.key === 'c' || eventArg.key === 'C');
332
+ if (isCopy) {
333
+ if (inEditor) return;
334
+ const rows: T[] = [];
335
+ if (this.selectedIds.size > 0) {
336
+ for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
337
+ } else if (this.selectedDataRow) {
338
+ rows.push(this.selectedDataRow);
339
+ }
340
+ if (rows.length === 0) return;
341
+ eventArg.preventDefault();
342
+ this.__writeRowsAsJson(rows);
343
+ return;
344
+ }
345
+
346
+ // Cell navigation only when no editor is open.
347
+ if (inEditor || this.__editingCell) return;
348
+ switch (eventArg.key) {
349
+ case 'ArrowLeft':
350
+ eventArg.preventDefault();
351
+ this.moveFocusedCell(-1, 0, false);
352
+ return;
353
+ case 'ArrowRight':
354
+ eventArg.preventDefault();
355
+ this.moveFocusedCell(+1, 0, false);
356
+ return;
357
+ case 'ArrowUp':
358
+ eventArg.preventDefault();
359
+ this.moveFocusedCell(0, -1, false);
360
+ return;
361
+ case 'ArrowDown':
362
+ eventArg.preventDefault();
363
+ this.moveFocusedCell(0, +1, false);
364
+ return;
365
+ case 'Enter':
366
+ case 'F2': {
367
+ if (!this.__focusedCell) return;
368
+ const view: T[] = (this as any)._lastViewData ?? [];
369
+ const item = view.find((r) => this.getRowId(r) === this.__focusedCell!.rowId);
370
+ if (!item) return;
371
+ const allCols: Column<T>[] =
372
+ Array.isArray(this.columns) && this.columns.length > 0
373
+ ? computeEffectiveColumnsFn(
374
+ this.columns,
375
+ this.augmentFromDisplayFunction,
376
+ this.displayFunction,
377
+ this.data
378
+ )
379
+ : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
380
+ const col = allCols.find((c) => String(c.key) === this.__focusedCell!.colKey);
381
+ if (!col || !this.__isColumnEditable(col)) return;
382
+ eventArg.preventDefault();
383
+ this.startEditing(item, col);
384
+ return;
385
+ }
386
+ case 'Escape':
387
+ if (this.__focusedCell) {
388
+ this.__focusedCell = undefined;
389
+ this.requestUpdate();
390
+ }
391
+ return;
392
+ default:
393
+ return;
255
394
  }
256
- if (rows.length === 0) return;
257
- eventArg.preventDefault();
258
- this.__writeRowsAsJson(rows);
259
395
  };
260
396
 
261
397
  /**
@@ -287,28 +423,106 @@ export class DeesTable<T> extends DeesElement {
287
423
 
288
424
  public static styles = tableStyles;
289
425
 
290
- 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>[] {
291
432
  const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
292
- const effectiveColumns: Column<T>[] = usingColumns
293
- ? 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
+ )
294
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
+ }
295
460
 
296
- const lucenePred = compileLucenePredicate<T>(
297
- this.filterText,
298
- this.searchMode === 'data' ? 'data' : 'table',
299
- effectiveColumns
300
- );
301
-
302
- 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(
303
482
  this.data,
304
483
  effectiveColumns,
305
484
  this.sortBy,
306
485
  this.filterText,
307
486
  this.columnFilters,
308
- this.searchMode === 'data' ? 'data' : 'table',
487
+ searchMode,
309
488
  lucenePred || undefined
310
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);
311
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
+ }
312
526
  return html`
313
527
  <dees-tile>
314
528
  <div slot="header" class="header">
@@ -379,98 +593,25 @@ export class DeesTable<T> extends DeesElement {
379
593
  <thead>
380
594
  ${this.renderHeaderRows(effectiveColumns)}
381
595
  </thead>
382
- <tbody>
383
- ${viewData.map((itemArg, rowIndex) => {
384
- const getTr = (elementArg: HTMLElement): HTMLElement => {
385
- if (elementArg.tagName === 'TR') {
386
- return elementArg;
387
- } else {
388
- return getTr(elementArg.parentElement!);
389
- }
390
- };
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);
391
612
  return html`
392
613
  <tr
393
- @click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
394
- @mousedown=${(e: MouseEvent) => {
395
- // Prevent the browser's native shift-click text
396
- // selection so range-select doesn't highlight text.
397
- if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
398
- }}
399
- @dragenter=${async (eventArg: DragEvent) => {
400
- eventArg.preventDefault();
401
- eventArg.stopPropagation();
402
- const realTarget = getTr(eventArg.target as HTMLElement);
403
- setTimeout(() => {
404
- realTarget.classList.add('hasAttachment');
405
- }, 0);
406
- }}
407
- @dragleave=${async (eventArg: DragEvent) => {
408
- eventArg.preventDefault();
409
- eventArg.stopPropagation();
410
- const realTarget = getTr(eventArg.target as HTMLElement);
411
- realTarget.classList.remove('hasAttachment');
412
- }}
413
- @dragover=${async (eventArg: DragEvent) => {
414
- eventArg.preventDefault();
415
- }}
416
- @drop=${async (eventArg: DragEvent) => {
417
- eventArg.preventDefault();
418
- const newFiles: File[] = [];
419
- for (const file of Array.from(eventArg.dataTransfer!.files)) {
420
- this.files.push(file);
421
- newFiles.push(file);
422
- this.requestUpdate();
423
- }
424
- const result: File[] = this.fileWeakMap.get(itemArg as object);
425
- if (!result) {
426
- this.fileWeakMap.set(itemArg as object, newFiles);
427
- } else {
428
- result.push(...newFiles);
429
- }
430
- }}
431
- @contextmenu=${async (eventArg: MouseEvent) => {
432
- // If the right-clicked row isn't part of the
433
- // current selection, treat it like a plain click
434
- // first so the context menu acts on a sensible
435
- // selection (matches file-manager behavior).
436
- if (!this.isRowSelected(itemArg)) {
437
- this.selectedDataRow = itemArg;
438
- this.selectedIds.clear();
439
- this.selectedIds.add(this.getRowId(itemArg));
440
- this.__selectionAnchorId = this.getRowId(itemArg);
441
- this.emitSelectionChange();
442
- this.requestUpdate();
443
- }
444
- const userItems: plugins.tsclass.website.IMenuItem[] =
445
- this.getActionsForType('contextmenu').map((action) => ({
446
- name: action.name,
447
- iconName: action.iconName as any,
448
- action: async () => {
449
- await action.actionFunc({
450
- item: itemArg,
451
- table: this,
452
- });
453
- return null;
454
- },
455
- }));
456
- const defaultItems: plugins.tsclass.website.IMenuItem[] = [
457
- {
458
- name:
459
- this.selectedIds.size > 1
460
- ? `Copy ${this.selectedIds.size} rows as JSON`
461
- : 'Copy row as JSON',
462
- iconName: 'lucide:Copy' as any,
463
- action: async () => {
464
- this.copySelectionAsJson(itemArg);
465
- return null;
466
- },
467
- },
468
- ];
469
- DeesContextmenu.openContextMenuWithOptions(eventArg, [
470
- ...userItems,
471
- ...defaultItems,
472
- ]);
473
- }}
614
+ data-row-idx=${rowIndex}
474
615
  class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
475
616
  >
476
617
  ${this.showSelectionCheckbox
@@ -492,20 +633,28 @@ export class DeesTable<T> extends DeesElement {
492
633
  ? col.renderer(value, itemArg, { rowIndex, colIndex, column: col })
493
634
  : value;
494
635
  const editKey = String(col.key);
636
+ const isEditable = !!(col.editable || col.editor);
637
+ const isFocused =
638
+ this.__focusedCell?.rowId === rowId &&
639
+ this.__focusedCell?.colKey === editKey;
640
+ const isEditing =
641
+ this.__editingCell?.rowId === rowId &&
642
+ this.__editingCell?.colKey === editKey;
643
+ const cellClasses = [
644
+ isEditable ? 'editable' : '',
645
+ isFocused && !isEditing ? 'focused' : '',
646
+ isEditing ? 'editingCell' : '',
647
+ ]
648
+ .filter(Boolean)
649
+ .join(' ');
495
650
  return html`
496
651
  <td
497
- @dblclick=${(e: Event) => {
498
- const dblAction = this.dataActions.find((actionArg) =>
499
- actionArg.type?.includes('doubleClick')
500
- );
501
- if (this.editableFields.includes(editKey)) {
502
- this.handleCellEditing(e, itemArg, editKey);
503
- } else if (dblAction) {
504
- dblAction.actionFunc({ item: itemArg, table: this });
505
- }
506
- }}
652
+ class=${cellClasses}
653
+ data-col-key=${editKey}
507
654
  >
508
- <div class="innerCellContainer">${content}</div>
655
+ <div class="innerCellContainer">
656
+ ${isEditing ? this.renderCellEditor(itemArg, col) : content}
657
+ </div>
509
658
  </td>
510
659
  `;
511
660
  })}
@@ -537,15 +686,20 @@ export class DeesTable<T> extends DeesElement {
537
686
  })()}
538
687
  </tr>`;
539
688
  })}
689
+ ${useVirtual && bottomSpacerHeight > 0
690
+ ? html`<tr aria-hidden="true" style="height:${bottomSpacerHeight}px"><td></td></tr>`
691
+ : html``}
540
692
  </tbody>
541
693
  </table>
542
694
  </div>
543
695
  <div class="floatingHeader" aria-hidden="true">
544
- <table>
545
- <thead>
546
- ${this.renderHeaderRows(effectiveColumns)}
547
- </thead>
548
- </table>
696
+ ${this.__floatingActive
697
+ ? html`<table>
698
+ <thead>
699
+ ${this.renderHeaderRows(effectiveColumns)}
700
+ </thead>
701
+ </table>`
702
+ : html``}
549
703
  </div>
550
704
  `
551
705
  : html` <div class="noDataSet">No data set!</div> `}
@@ -662,7 +816,8 @@ export class DeesTable<T> extends DeesElement {
662
816
  // ─── Floating header (page-sticky) lifecycle ─────────────────────────
663
817
  private __floatingResizeObserver?: ResizeObserver;
664
818
  private __floatingScrollHandler?: () => void;
665
- 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.
666
821
  private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
667
822
 
668
823
  private get __floatingHeaderEl(): HTMLDivElement | null {
@@ -745,32 +900,45 @@ export class DeesTable<T> extends DeesElement {
745
900
 
746
901
  private setupFloatingHeader() {
747
902
  this.teardownFloatingHeader();
748
- if (this.fixedHeight) return;
903
+ // Skip entirely only when neither feature needs scroll watchers.
904
+ if (this.fixedHeight && !this.virtualized) return;
749
905
  const realTable = this.__realTableEl;
750
906
  if (!realTable) return;
751
907
 
752
908
  this.__scrollAncestors = this.__collectScrollAncestors();
753
909
  // .tableScroll is a descendant (inside our shadow root), not an ancestor,
754
- // so the upward walk above misses it. Add it explicitly so horizontal
755
- // 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.
756
913
  const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
757
914
  if (tableScrollEl) {
758
- this.__scrollAncestors.unshift({ target: tableScrollEl, scrollsY: false, scrollsX: true });
915
+ this.__scrollAncestors.unshift({
916
+ target: tableScrollEl,
917
+ scrollsY: this.fixedHeight,
918
+ scrollsX: true,
919
+ });
759
920
  }
760
921
 
761
922
  // Track resize of the real table so we can mirror its width and column widths.
762
923
  this.__floatingResizeObserver = new ResizeObserver(() => {
763
- this.__syncFloatingHeader();
924
+ if (!this.fixedHeight) this.__syncFloatingHeader();
925
+ if (this.virtualized) this.__computeVirtualRange();
764
926
  });
765
927
  this.__floatingResizeObserver.observe(realTable);
766
928
 
767
- 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
+ };
768
935
  for (const a of this.__scrollAncestors) {
769
936
  a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
770
937
  }
771
938
  window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
772
939
 
773
- this.__syncFloatingHeader();
940
+ if (!this.fixedHeight) this.__syncFloatingHeader();
941
+ if (this.virtualized) this.__computeVirtualRange();
774
942
  }
775
943
 
776
944
  private teardownFloatingHeader() {
@@ -789,35 +957,99 @@ export class DeesTable<T> extends DeesElement {
789
957
  if (fh) fh.classList.remove('active');
790
958
  }
791
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
+
792
1030
  /**
793
1031
  * Single function that drives both activation and geometry of the floating
794
- * 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.
795
1040
  */
796
1041
  private __syncFloatingHeader() {
797
1042
  const fh = this.__floatingHeaderEl;
798
1043
  const realTable = this.__realTableEl;
799
- const floatTable = this.__floatingTableEl;
800
- if (!fh || !realTable || !floatTable) return;
1044
+ if (!fh || !realTable) return;
801
1045
 
802
1046
  const tableRect = realTable.getBoundingClientRect();
803
1047
  const stick = this.__getStickContext();
804
-
805
- // Mirror table layout + per-cell widths so columns line up.
806
- floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
807
1048
  const realHeadRows = realTable.tHead?.rows;
808
- const floatHeadRows = floatTable.tHead?.rows;
809
1049
  let headerHeight = 0;
810
- if (realHeadRows && floatHeadRows) {
811
- for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
1050
+ if (realHeadRows) {
1051
+ for (let r = 0; r < realHeadRows.length; r++) {
812
1052
  headerHeight += realHeadRows[r].getBoundingClientRect().height;
813
- const realCells = realHeadRows[r].cells;
814
- const floatCells = floatHeadRows[r].cells;
815
- for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
816
- const w = realCells[c].getBoundingClientRect().width;
817
- (floatCells[c] as HTMLElement).style.width = `${w}px`;
818
- (floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
819
- (floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
820
- }
821
1053
  }
822
1054
  }
823
1055
 
@@ -829,9 +1061,34 @@ export class DeesTable<T> extends DeesElement {
829
1061
  if (shouldBeActive !== this.__floatingActive) {
830
1062
  this.__floatingActive = shouldBeActive;
831
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
+ }
832
1070
  }
833
1071
  if (!shouldBeActive) return;
834
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
+
835
1092
  // Position the floating header. Clip horizontally to the scroll context
836
1093
  // so a horizontally-scrolled inner container's header doesn't bleed
837
1094
  // outside the container's border.
@@ -861,24 +1118,55 @@ export class DeesTable<T> extends DeesElement {
861
1118
 
862
1119
  public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
863
1120
  super.updated(changedProperties);
864
- this.determineColumnWidths();
865
- // (Re)wire the floating header whenever the relevant props change or
866
- // 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.
867
1147
  if (
868
1148
  changedProperties.has('fixedHeight') ||
1149
+ changedProperties.has('virtualized') ||
869
1150
  changedProperties.has('data') ||
870
1151
  changedProperties.has('columns') ||
871
1152
  !this.__floatingScrollHandler
872
1153
  ) {
873
- if (!this.fixedHeight && this.data.length > 0) {
1154
+ const needsScrollWatchers = (!this.fixedHeight || this.virtualized) && this.data.length > 0;
1155
+ if (needsScrollWatchers) {
874
1156
  this.setupFloatingHeader();
875
1157
  } else {
876
1158
  this.teardownFloatingHeader();
877
1159
  }
878
1160
  }
879
- // Keep the floating header in sync after any re-render
880
- // (column widths may have changed).
881
- 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
+ ) {
882
1170
  this.__syncFloatingHeader();
883
1171
  }
884
1172
  if (this.searchable) {
@@ -1393,6 +1681,187 @@ export class DeesTable<T> extends DeesElement {
1393
1681
  this.requestUpdate();
1394
1682
  }
1395
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
+
1396
1865
  /**
1397
1866
  * Handles row clicks with file-manager style selection semantics:
1398
1867
  * - plain click: select only this row, set anchor
@@ -1524,43 +1993,216 @@ export class DeesTable<T> extends DeesElement {
1524
1993
  return actions;
1525
1994
  }
1526
1995
 
1527
- async handleCellEditing(event: Event, itemArg: T, key: string) {
1528
- await this.domtoolsPromise;
1529
- const target = event.target as HTMLElement;
1530
- const originalColor = target.style.color;
1531
- target.style.color = 'transparent';
1532
- const transformedItem = this.displayFunction(itemArg);
1533
- const initialValue = ((transformedItem as any)[key] ?? (itemArg as any)[key] ?? '') as string;
1534
- // Create an input element
1535
- const input = document.createElement('input');
1536
- input.type = 'text';
1537
- input.value = initialValue;
1538
-
1539
- const blurInput = async (blurArg = true, saveArg = false) => {
1540
- if (blurArg) {
1541
- input.blur();
1996
+ // ─── Cell editing ─────────────────────────────────────────────────────
1997
+
1998
+ /** True if the column has any in-cell editor configured. */
1999
+ private __isColumnEditable(col: Column<T>): boolean {
2000
+ return !!(col.editable || col.editor);
2001
+ }
2002
+
2003
+ /** Effective columns filtered to those that can be edited (visible only). */
2004
+ private __editableColumns(effectiveColumns: Column<T>[]): Column<T>[] {
2005
+ return effectiveColumns.filter((c) => !c.hidden && this.__isColumnEditable(c));
2006
+ }
2007
+
2008
+ /**
2009
+ * Opens the editor on the given cell. Sets focus + editing state and
2010
+ * focuses the freshly rendered editor on the next frame.
2011
+ */
2012
+ public startEditing(item: T, col: Column<T>) {
2013
+ if (!this.__isColumnEditable(col)) return;
2014
+ const rowId = this.getRowId(item);
2015
+ const colKey = String(col.key);
2016
+ this.__focusedCell = { rowId, colKey };
2017
+ this.__editingCell = { rowId, colKey };
2018
+ this.requestUpdate();
2019
+ this.updateComplete.then(() => {
2020
+ const el = this.shadowRoot?.querySelector(
2021
+ '.editingCell dees-input-text, .editingCell dees-input-checkbox, ' +
2022
+ '.editingCell dees-input-dropdown, .editingCell dees-input-datepicker, ' +
2023
+ '.editingCell dees-input-tags'
2024
+ ) as any;
2025
+ el?.focus?.();
2026
+ });
2027
+ }
2028
+
2029
+ /** Closes the editor without committing. */
2030
+ public cancelCellEdit() {
2031
+ this.__editingCell = undefined;
2032
+ this.requestUpdate();
2033
+ }
2034
+
2035
+ /**
2036
+ * Commits an editor value to the row. Runs `parse` then `validate`. On
2037
+ * validation failure, fires `cellEditError` and leaves the editor open.
2038
+ * On success, mutates `data` in place, fires `cellEdit`, and closes the
2039
+ * editor.
2040
+ */
2041
+ public commitCellEdit(item: T, col: Column<T>, editorValue: any) {
2042
+ const key = String(col.key);
2043
+ const oldValue = (item as any)[col.key];
2044
+ const parsed = col.parse ? col.parse(editorValue, item) : editorValue;
2045
+ if (col.validate) {
2046
+ const result = col.validate(parsed, item);
2047
+ if (typeof result === 'string') {
2048
+ this.dispatchEvent(
2049
+ new CustomEvent('cellEditError', {
2050
+ detail: { row: item, key, value: parsed, message: result },
2051
+ bubbles: true,
2052
+ composed: true,
2053
+ })
2054
+ );
2055
+ return;
1542
2056
  }
1543
- if (saveArg) {
1544
- (itemArg as any)[key] = input.value as any; // Convert string to T (you might need better type casting depending on your data structure)
1545
- this.changeSubject.next(this);
2057
+ }
2058
+ if (parsed !== oldValue) {
2059
+ (item as any)[col.key] = parsed;
2060
+ this.dispatchEvent(
2061
+ new CustomEvent('cellEdit', {
2062
+ detail: { row: item, key, oldValue, newValue: parsed },
2063
+ bubbles: true,
2064
+ composed: true,
2065
+ })
2066
+ );
2067
+ this.changeSubject.next(this);
2068
+ }
2069
+ this.__editingCell = undefined;
2070
+ this.requestUpdate();
2071
+ }
2072
+
2073
+ /** Renders the appropriate dees-input-* component for this column. */
2074
+ private renderCellEditor(item: T, col: Column<T>): TemplateResult {
2075
+ const raw = (item as any)[col.key];
2076
+ const value = col.format ? col.format(raw, item) : raw;
2077
+ const editorType: TCellEditorType = col.editor ?? 'text';
2078
+ const onTextCommit = (target: any) => this.commitCellEdit(item, col, target.value);
2079
+
2080
+ switch (editorType) {
2081
+ case 'checkbox':
2082
+ return html`<dees-input-checkbox
2083
+ .value=${!!value}
2084
+ @newValue=${(e: CustomEvent<boolean>) => {
2085
+ e.stopPropagation();
2086
+ this.commitCellEdit(item, col, e.detail);
2087
+ }}
2088
+ ></dees-input-checkbox>`;
2089
+
2090
+ case 'dropdown': {
2091
+ const options = (col.editorOptions?.options as any[]) ?? [];
2092
+ const selected =
2093
+ options.find((o: any) => (o?.option ?? o?.key ?? o) === value) ?? null;
2094
+ return html`<dees-input-dropdown
2095
+ .options=${options}
2096
+ .selectedOption=${selected}
2097
+ @selectedOption=${(e: CustomEvent<any>) => {
2098
+ e.stopPropagation();
2099
+ const detail = e.detail;
2100
+ const newRaw = detail?.option ?? detail?.key ?? detail;
2101
+ this.commitCellEdit(item, col, newRaw);
2102
+ }}
2103
+ ></dees-input-dropdown>`;
1546
2104
  }
1547
- input.remove();
1548
- target.style.color = originalColor;
1549
- this.requestUpdate();
1550
- };
1551
2105
 
1552
- // When the input loses focus or the Enter key is pressed, update the data
1553
- input.addEventListener('blur', () => {
1554
- blurInput(false, false);
1555
- });
1556
- input.addEventListener('keydown', (e: KeyboardEvent) => {
1557
- if (e.key === 'Enter') {
1558
- blurInput(true, true); // This will trigger the blur event handler above
2106
+ case 'date':
2107
+ return html`<dees-input-datepicker
2108
+ .value=${value}
2109
+ @focusout=${(e: any) => onTextCommit(e.target)}
2110
+ @keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
2111
+ ></dees-input-datepicker>`;
2112
+
2113
+ case 'tags':
2114
+ return html`<dees-input-tags
2115
+ .value=${(value as any) ?? []}
2116
+ @focusout=${(e: any) => onTextCommit(e.target)}
2117
+ @keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
2118
+ ></dees-input-tags>`;
2119
+
2120
+ case 'number':
2121
+ case 'text':
2122
+ default:
2123
+ return html`<dees-input-text
2124
+ .value=${value == null ? '' : String(value)}
2125
+ @focusout=${(e: any) => onTextCommit(e.target)}
2126
+ @keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
2127
+ ></dees-input-text>`;
2128
+ }
2129
+ }
2130
+
2131
+ /**
2132
+ * Centralized keydown handler for text-style editors. Handles Esc (cancel),
2133
+ * Enter (commit + move down) and Tab/Shift+Tab (commit + move horizontally).
2134
+ */
2135
+ private __handleEditorKey(eventArg: KeyboardEvent, item: T, col: Column<T>) {
2136
+ if (eventArg.key === 'Escape') {
2137
+ eventArg.preventDefault();
2138
+ eventArg.stopPropagation();
2139
+ this.cancelCellEdit();
2140
+ // Restore focus to the host so arrow-key navigation can resume.
2141
+ this.focus();
2142
+ } else if (eventArg.key === 'Enter') {
2143
+ eventArg.preventDefault();
2144
+ eventArg.stopPropagation();
2145
+ const target = eventArg.target as any;
2146
+ this.commitCellEdit(item, col, target.value);
2147
+ this.moveFocusedCell(0, +1, true);
2148
+ } else if (eventArg.key === 'Tab') {
2149
+ eventArg.preventDefault();
2150
+ eventArg.stopPropagation();
2151
+ const target = eventArg.target as any;
2152
+ this.commitCellEdit(item, col, target.value);
2153
+ this.moveFocusedCell(eventArg.shiftKey ? -1 : +1, 0, true);
2154
+ }
2155
+ }
2156
+
2157
+ /**
2158
+ * Moves the focused cell by `dx` columns and `dy` rows along the editable
2159
+ * grid. Wraps row-end → next row when moving horizontally. If
2160
+ * `andStartEditing` is true, opens the editor on the new cell.
2161
+ */
2162
+ public moveFocusedCell(dx: number, dy: number, andStartEditing: boolean) {
2163
+ const view: T[] = (this as any)._lastViewData ?? [];
2164
+ if (view.length === 0) return;
2165
+ // Recompute editable columns from the latest effective set.
2166
+ const allCols: Column<T>[] = Array.isArray(this.columns) && this.columns.length > 0
2167
+ ? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
2168
+ : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
2169
+ const editableCols = this.__editableColumns(allCols);
2170
+ if (editableCols.length === 0) return;
2171
+
2172
+ let rowIdx = 0;
2173
+ let colIdx = 0;
2174
+ if (this.__focusedCell) {
2175
+ rowIdx = view.findIndex((r) => this.getRowId(r) === this.__focusedCell!.rowId);
2176
+ colIdx = editableCols.findIndex((c) => String(c.key) === this.__focusedCell!.colKey);
2177
+ if (rowIdx < 0) rowIdx = 0;
2178
+ if (colIdx < 0) colIdx = 0;
2179
+ }
2180
+
2181
+ if (dx !== 0) {
2182
+ colIdx += dx;
2183
+ while (colIdx >= editableCols.length) {
2184
+ colIdx -= editableCols.length;
2185
+ rowIdx += 1;
1559
2186
  }
1560
- });
2187
+ while (colIdx < 0) {
2188
+ colIdx += editableCols.length;
2189
+ rowIdx -= 1;
2190
+ }
2191
+ }
2192
+ if (dy !== 0) rowIdx += dy;
1561
2193
 
1562
- // Replace the cell's content with the input
1563
- target.appendChild(input);
1564
- input.focus();
2194
+ // Clamp to grid bounds.
2195
+ if (rowIdx < 0 || rowIdx >= view.length) {
2196
+ this.cancelCellEdit();
2197
+ return;
2198
+ }
2199
+ const item = view[rowIdx];
2200
+ const col = editableCols[colIdx];
2201
+ this.__focusedCell = { rowId: this.getRowId(item), colKey: String(col.key) };
2202
+ if (andStartEditing) {
2203
+ this.startEditing(item, col);
2204
+ } else {
2205
+ this.requestUpdate();
2206
+ }
1565
2207
  }
1566
2208
  }