@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.
- package/dist_bundle/bundle.js +741 -226
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.d.ts +117 -4
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.demo.js +41 -10
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.js +828 -207
- package/dist_ts_web/elements/00group-dataview/dees-table/styles.js +23 -26
- package/dist_ts_web/elements/00group-dataview/dees-table/types.d.ts +31 -0
- package/dist_watch/bundle.js +739 -224
- package/dist_watch/bundle.js.map +3 -3
- package/package.json +1 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts +40 -9
- package/ts_web/elements/00group-dataview/dees-table/dees-table.ts +844 -202
- package/ts_web/elements/00group-dataview/dees-table/styles.ts +26 -26
- package/ts_web/elements/00group-dataview/dees-table/types.ts +40 -0
|
@@ -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 {
|
|
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
|
-
|
|
242
|
-
|
|
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')
|
|
248
|
-
|
|
323
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || (t as HTMLElement)?.isContentEditable) {
|
|
324
|
+
inEditor = true;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
249
327
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
293
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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"
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
755
|
-
//
|
|
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({
|
|
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 = () =>
|
|
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
|
|
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
|
-
|
|
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
|
|
811
|
-
for (let r = 0; r < realHeadRows.length
|
|
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
|
-
|
|
865
|
-
//
|
|
866
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
880
|
-
//
|
|
881
|
-
|
|
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
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
-
//
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
}
|