@design.estate/dees-catalog 3.62.0 → 3.64.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 +440 -107
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.d.ts +83 -1
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.js +501 -99
- package/dist_ts_web/elements/00group-dataview/dees-table/styles.js +47 -18
- package/dist_watch/bundle.js +438 -105
- 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.ts +496 -99
- package/ts_web/elements/00group-dataview/dees-table/styles.ts +46 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@design.estate/dees-catalog",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.64.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
|
6
6
|
"main": "dist_ts_web/index.js",
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@design.estate/dees-catalog',
|
|
6
|
-
version: '3.
|
|
6
|
+
version: '3.64.0',
|
|
7
7
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
|
8
8
|
}
|
|
@@ -184,8 +184,25 @@ export class DeesTable<T> extends DeesElement {
|
|
|
184
184
|
accessor columnFilters: Record<string, string> = {};
|
|
185
185
|
@property({ type: Boolean, attribute: 'show-column-filters' })
|
|
186
186
|
accessor showColumnFilters: boolean = false;
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
/**
|
|
188
|
+
* When true, the table renders a leftmost checkbox column for click-driven
|
|
189
|
+
* (de)selection. Row selection by mouse (plain/shift/ctrl click) is always
|
|
190
|
+
* available regardless of this flag.
|
|
191
|
+
*/
|
|
192
|
+
@property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
|
|
193
|
+
accessor showSelectionCheckbox: boolean = false;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* When set, the table renders inside a fixed-height scroll container
|
|
197
|
+
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
|
|
198
|
+
* within that box via plain CSS sticky.
|
|
199
|
+
*
|
|
200
|
+
* When unset (the default), the table flows naturally and a JS-managed
|
|
201
|
+
* floating header keeps the column headers visible while the table is
|
|
202
|
+
* scrolled past in any ancestor scroll container (page or otherwise).
|
|
203
|
+
*/
|
|
204
|
+
@property({ type: Boolean, reflect: true, attribute: 'fixed-height' })
|
|
205
|
+
accessor fixedHeight: boolean = false;
|
|
189
206
|
|
|
190
207
|
// search row state
|
|
191
208
|
@property({ type: String })
|
|
@@ -200,9 +217,72 @@ export class DeesTable<T> extends DeesElement {
|
|
|
200
217
|
accessor selectedIds: Set<string> = new Set();
|
|
201
218
|
private _rowIdMap = new WeakMap<object, string>();
|
|
202
219
|
private _rowIdCounter = 0;
|
|
220
|
+
/**
|
|
221
|
+
* Anchor row id for shift+click range selection. Set whenever the user
|
|
222
|
+
* makes a non-range click (plain or cmd/ctrl) so the next shift+click
|
|
223
|
+
* can compute a contiguous range from this anchor.
|
|
224
|
+
*/
|
|
225
|
+
private __selectionAnchorId?: string;
|
|
203
226
|
|
|
204
227
|
constructor() {
|
|
205
228
|
super();
|
|
229
|
+
// Make the host focusable so it can receive Ctrl/Cmd+C for copy.
|
|
230
|
+
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
|
|
231
|
+
this.addEventListener('keydown', this.__handleHostKeydown);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Ctrl/Cmd+C copies the currently selected rows as a JSON array. Falls
|
|
236
|
+
* back to copying the focused-row (`selectedDataRow`) if no multi
|
|
237
|
+
* selection exists. No-op if a focused input/textarea would normally
|
|
238
|
+
* receive the copy.
|
|
239
|
+
*/
|
|
240
|
+
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.
|
|
244
|
+
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
|
245
|
+
for (const t of path) {
|
|
246
|
+
const tag = (t as HTMLElement)?.tagName;
|
|
247
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
248
|
+
if ((t as HTMLElement)?.isContentEditable) return;
|
|
249
|
+
}
|
|
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);
|
|
255
|
+
}
|
|
256
|
+
if (rows.length === 0) return;
|
|
257
|
+
eventArg.preventDefault();
|
|
258
|
+
this.__writeRowsAsJson(rows);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Copies the current selection as a JSON array. If `fallbackRow` is given
|
|
263
|
+
* and there is no multi-selection, that row is copied instead. Used both
|
|
264
|
+
* by the Ctrl/Cmd+C handler and by the default context-menu action.
|
|
265
|
+
*/
|
|
266
|
+
public copySelectionAsJson(fallbackRow?: T) {
|
|
267
|
+
const rows: T[] = [];
|
|
268
|
+
if (this.selectedIds.size > 0) {
|
|
269
|
+
for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
|
|
270
|
+
} else if (fallbackRow) {
|
|
271
|
+
rows.push(fallbackRow);
|
|
272
|
+
} else if (this.selectedDataRow) {
|
|
273
|
+
rows.push(this.selectedDataRow);
|
|
274
|
+
}
|
|
275
|
+
if (rows.length === 0) return;
|
|
276
|
+
this.__writeRowsAsJson(rows);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private __writeRowsAsJson(rows: T[]) {
|
|
280
|
+
try {
|
|
281
|
+
const json = JSON.stringify(rows, null, 2);
|
|
282
|
+
navigator.clipboard?.writeText(json);
|
|
283
|
+
} catch {
|
|
284
|
+
/* ignore — clipboard may be unavailable */
|
|
285
|
+
}
|
|
206
286
|
}
|
|
207
287
|
|
|
208
288
|
public static styles = tableStyles;
|
|
@@ -297,74 +377,7 @@ export class DeesTable<T> extends DeesElement {
|
|
|
297
377
|
<div class="tableScroll">
|
|
298
378
|
<table>
|
|
299
379
|
<thead>
|
|
300
|
-
|
|
301
|
-
${this.selectionMode !== 'none'
|
|
302
|
-
? html`
|
|
303
|
-
<th style="width:42px; text-align:center;">
|
|
304
|
-
${this.selectionMode === 'multi'
|
|
305
|
-
? html`
|
|
306
|
-
<dees-input-checkbox
|
|
307
|
-
.value=${this.areAllVisibleSelected()}
|
|
308
|
-
.indeterminate=${this.isVisibleSelectionIndeterminate()}
|
|
309
|
-
@newValue=${(e: CustomEvent<boolean>) => {
|
|
310
|
-
e.stopPropagation();
|
|
311
|
-
this.setSelectVisible(e.detail === true);
|
|
312
|
-
}}
|
|
313
|
-
></dees-input-checkbox>
|
|
314
|
-
`
|
|
315
|
-
: html``}
|
|
316
|
-
</th>
|
|
317
|
-
`
|
|
318
|
-
: html``}
|
|
319
|
-
${effectiveColumns
|
|
320
|
-
.filter((c) => !c.hidden)
|
|
321
|
-
.map((col) => {
|
|
322
|
-
const isSortable = !!col.sortable;
|
|
323
|
-
const ariaSort = this.getAriaSort(col);
|
|
324
|
-
return html`
|
|
325
|
-
<th
|
|
326
|
-
role="columnheader"
|
|
327
|
-
aria-sort=${ariaSort}
|
|
328
|
-
style="${isSortable ? 'cursor: pointer;' : ''}"
|
|
329
|
-
@click=${(eventArg: MouseEvent) =>
|
|
330
|
-
isSortable ? this.handleHeaderClick(eventArg, col, effectiveColumns) : null}
|
|
331
|
-
@contextmenu=${(eventArg: MouseEvent) =>
|
|
332
|
-
isSortable
|
|
333
|
-
? this.openHeaderContextMenu(eventArg, col, effectiveColumns)
|
|
334
|
-
: null}
|
|
335
|
-
>
|
|
336
|
-
${col.header ?? (col.key as any)}
|
|
337
|
-
${this.renderSortIndicator(col)}
|
|
338
|
-
</th>`;
|
|
339
|
-
})}
|
|
340
|
-
${(() => {
|
|
341
|
-
if (this.dataActions && this.dataActions.length > 0) {
|
|
342
|
-
return html` <th class="actionsCol">Actions</th> `;
|
|
343
|
-
}
|
|
344
|
-
})()}
|
|
345
|
-
</tr>
|
|
346
|
-
${this.showColumnFilters
|
|
347
|
-
? html`<tr class="filtersRow">
|
|
348
|
-
${this.selectionMode !== 'none'
|
|
349
|
-
? html`<th style="width:42px;"></th>`
|
|
350
|
-
: html``}
|
|
351
|
-
${effectiveColumns
|
|
352
|
-
.filter((c) => !c.hidden)
|
|
353
|
-
.map((col) => {
|
|
354
|
-
const key = String(col.key);
|
|
355
|
-
if (col.filterable === false) return html`<th></th>`;
|
|
356
|
-
return html`<th>
|
|
357
|
-
<input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
|
|
358
|
-
@input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
|
|
359
|
-
</th>`;
|
|
360
|
-
})}
|
|
361
|
-
${(() => {
|
|
362
|
-
if (this.dataActions && this.dataActions.length > 0) {
|
|
363
|
-
return html` <th></th> `;
|
|
364
|
-
}
|
|
365
|
-
})()}
|
|
366
|
-
</tr>`
|
|
367
|
-
: html``}
|
|
380
|
+
${this.renderHeaderRows(effectiveColumns)}
|
|
368
381
|
</thead>
|
|
369
382
|
<tbody>
|
|
370
383
|
${viewData.map((itemArg, rowIndex) => {
|
|
@@ -377,15 +390,11 @@ export class DeesTable<T> extends DeesElement {
|
|
|
377
390
|
};
|
|
378
391
|
return html`
|
|
379
392
|
<tr
|
|
380
|
-
@click=${() =>
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
this.selectedIds.add(id);
|
|
386
|
-
this.emitSelectionChange();
|
|
387
|
-
this.requestUpdate();
|
|
388
|
-
}
|
|
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();
|
|
389
398
|
}}
|
|
390
399
|
@dragenter=${async (eventArg: DragEvent) => {
|
|
391
400
|
eventArg.preventDefault();
|
|
@@ -420,27 +429,51 @@ export class DeesTable<T> extends DeesElement {
|
|
|
420
429
|
}
|
|
421
430
|
}}
|
|
422
431
|
@contextmenu=${async (eventArg: MouseEvent) => {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
+
]);
|
|
440
473
|
}}
|
|
441
|
-
class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
|
|
474
|
+
class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
|
|
442
475
|
>
|
|
443
|
-
${this.
|
|
476
|
+
${this.showSelectionCheckbox
|
|
444
477
|
? html`<td style="width:42px; text-align:center;">
|
|
445
478
|
<dees-input-checkbox
|
|
446
479
|
.value=${this.isRowSelected(itemArg)}
|
|
@@ -507,6 +540,13 @@ export class DeesTable<T> extends DeesElement {
|
|
|
507
540
|
</tbody>
|
|
508
541
|
</table>
|
|
509
542
|
</div>
|
|
543
|
+
<div class="floatingHeader" aria-hidden="true">
|
|
544
|
+
<table>
|
|
545
|
+
<thead>
|
|
546
|
+
${this.renderHeaderRows(effectiveColumns)}
|
|
547
|
+
</thead>
|
|
548
|
+
</table>
|
|
549
|
+
</div>
|
|
510
550
|
`
|
|
511
551
|
: html` <div class="noDataSet">No data set!</div> `}
|
|
512
552
|
<div slot="footer" class="footer">
|
|
@@ -545,13 +585,302 @@ export class DeesTable<T> extends DeesElement {
|
|
|
545
585
|
`;
|
|
546
586
|
}
|
|
547
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Renders the header rows. Used twice per render: once inside the real
|
|
590
|
+
* `<thead>` and once inside the floating-header clone, so sort indicators
|
|
591
|
+
* and filter inputs stay in sync automatically.
|
|
592
|
+
*/
|
|
593
|
+
private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult {
|
|
594
|
+
return html`
|
|
595
|
+
<tr>
|
|
596
|
+
${this.showSelectionCheckbox
|
|
597
|
+
? html`
|
|
598
|
+
<th style="width:42px; text-align:center;">
|
|
599
|
+
${this.selectionMode === 'multi'
|
|
600
|
+
? html`
|
|
601
|
+
<dees-input-checkbox
|
|
602
|
+
.value=${this.areAllVisibleSelected()}
|
|
603
|
+
.indeterminate=${this.isVisibleSelectionIndeterminate()}
|
|
604
|
+
@newValue=${(e: CustomEvent<boolean>) => {
|
|
605
|
+
e.stopPropagation();
|
|
606
|
+
this.setSelectVisible(e.detail === true);
|
|
607
|
+
}}
|
|
608
|
+
></dees-input-checkbox>
|
|
609
|
+
`
|
|
610
|
+
: html``}
|
|
611
|
+
</th>
|
|
612
|
+
`
|
|
613
|
+
: html``}
|
|
614
|
+
${effectiveColumns
|
|
615
|
+
.filter((c) => !c.hidden)
|
|
616
|
+
.map((col) => {
|
|
617
|
+
const isSortable = !!col.sortable;
|
|
618
|
+
const ariaSort = this.getAriaSort(col);
|
|
619
|
+
return html`
|
|
620
|
+
<th
|
|
621
|
+
role="columnheader"
|
|
622
|
+
aria-sort=${ariaSort}
|
|
623
|
+
style="${isSortable ? 'cursor: pointer;' : ''}"
|
|
624
|
+
@click=${(eventArg: MouseEvent) =>
|
|
625
|
+
isSortable ? this.handleHeaderClick(eventArg, col, effectiveColumns) : null}
|
|
626
|
+
@contextmenu=${(eventArg: MouseEvent) =>
|
|
627
|
+
isSortable
|
|
628
|
+
? this.openHeaderContextMenu(eventArg, col, effectiveColumns)
|
|
629
|
+
: null}
|
|
630
|
+
>
|
|
631
|
+
${col.header ?? (col.key as any)}
|
|
632
|
+
${this.renderSortIndicator(col)}
|
|
633
|
+
</th>`;
|
|
634
|
+
})}
|
|
635
|
+
${this.dataActions && this.dataActions.length > 0
|
|
636
|
+
? html`<th class="actionsCol">Actions</th>`
|
|
637
|
+
: html``}
|
|
638
|
+
</tr>
|
|
639
|
+
${this.showColumnFilters
|
|
640
|
+
? html`<tr class="filtersRow">
|
|
641
|
+
${this.showSelectionCheckbox
|
|
642
|
+
? html`<th style="width:42px;"></th>`
|
|
643
|
+
: html``}
|
|
644
|
+
${effectiveColumns
|
|
645
|
+
.filter((c) => !c.hidden)
|
|
646
|
+
.map((col) => {
|
|
647
|
+
const key = String(col.key);
|
|
648
|
+
if (col.filterable === false) return html`<th></th>`;
|
|
649
|
+
return html`<th>
|
|
650
|
+
<input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
|
|
651
|
+
@input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
|
|
652
|
+
</th>`;
|
|
653
|
+
})}
|
|
654
|
+
${this.dataActions && this.dataActions.length > 0
|
|
655
|
+
? html`<th></th>`
|
|
656
|
+
: html``}
|
|
657
|
+
</tr>`
|
|
658
|
+
: html``}
|
|
659
|
+
`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ─── Floating header (page-sticky) lifecycle ─────────────────────────
|
|
663
|
+
private __floatingResizeObserver?: ResizeObserver;
|
|
664
|
+
private __floatingScrollHandler?: () => void;
|
|
665
|
+
private __floatingActive = false;
|
|
666
|
+
private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
|
|
667
|
+
|
|
668
|
+
private get __floatingHeaderEl(): HTMLDivElement | null {
|
|
669
|
+
return this.shadowRoot?.querySelector('.floatingHeader') ?? null;
|
|
670
|
+
}
|
|
671
|
+
private get __realTableEl(): HTMLTableElement | null {
|
|
672
|
+
return this.shadowRoot?.querySelector('.tableScroll > table') ?? null;
|
|
673
|
+
}
|
|
674
|
+
private get __floatingTableEl(): HTMLTableElement | null {
|
|
675
|
+
return this.shadowRoot?.querySelector('.floatingHeader > table') ?? null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Walks up the DOM (and through shadow roots) collecting every ancestor
|
|
680
|
+
* element whose computed `overflow-y` makes it a scroll container, plus
|
|
681
|
+
* `window` at the end. We listen for scroll on all of them so the floating
|
|
682
|
+
* header reacts whether the user scrolls the page or any nested container.
|
|
683
|
+
*/
|
|
684
|
+
private __collectScrollAncestors(): Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> {
|
|
685
|
+
const result: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
|
|
686
|
+
let node: Node | null = this as unknown as Node;
|
|
687
|
+
const scrollish = (v: string) => v === 'auto' || v === 'scroll' || v === 'overlay';
|
|
688
|
+
while (node) {
|
|
689
|
+
if (node instanceof Element) {
|
|
690
|
+
const style = getComputedStyle(node);
|
|
691
|
+
const sy = scrollish(style.overflowY);
|
|
692
|
+
const sx = scrollish(style.overflowX);
|
|
693
|
+
if (sy || sx) {
|
|
694
|
+
result.push({ target: node, scrollsY: sy, scrollsX: sx });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const parent = (node as any).assignedSlot
|
|
698
|
+
? (node as any).assignedSlot
|
|
699
|
+
: node.parentNode;
|
|
700
|
+
if (parent) {
|
|
701
|
+
node = parent;
|
|
702
|
+
} else if ((node as ShadowRoot).host) {
|
|
703
|
+
node = (node as ShadowRoot).host;
|
|
704
|
+
} else {
|
|
705
|
+
node = null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
result.push({ target: window, scrollsY: true, scrollsX: true });
|
|
709
|
+
return result;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Returns the "stick line" — the y-coordinate (in viewport space) at which
|
|
714
|
+
* the floating header should appear. Defaults to 0 (page top), but if the
|
|
715
|
+
* table is inside a scroll container we use that container's content-box
|
|
716
|
+
* top so the header sits inside the container's border/padding instead of
|
|
717
|
+
* floating over it.
|
|
718
|
+
*/
|
|
719
|
+
private __getStickContext(): { top: number; left: number; right: number } {
|
|
720
|
+
let top = 0;
|
|
721
|
+
let left = 0;
|
|
722
|
+
let right = window.innerWidth;
|
|
723
|
+
for (const a of this.__scrollAncestors) {
|
|
724
|
+
if (a.target === window) continue;
|
|
725
|
+
const el = a.target as Element;
|
|
726
|
+
const r = el.getBoundingClientRect();
|
|
727
|
+
const cs = getComputedStyle(el);
|
|
728
|
+
// Only constrain top from ancestors that actually scroll vertically —
|
|
729
|
+
// a horizontal-only scroll container (like .tableScroll) must not push
|
|
730
|
+
// the stick line down to its own top.
|
|
731
|
+
if (a.scrollsY) {
|
|
732
|
+
const bt = parseFloat(cs.borderTopWidth) || 0;
|
|
733
|
+
top = Math.max(top, r.top + bt);
|
|
734
|
+
}
|
|
735
|
+
// Same for horizontal clipping.
|
|
736
|
+
if (a.scrollsX) {
|
|
737
|
+
const bl = parseFloat(cs.borderLeftWidth) || 0;
|
|
738
|
+
const br = parseFloat(cs.borderRightWidth) || 0;
|
|
739
|
+
left = Math.max(left, r.left + bl);
|
|
740
|
+
right = Math.min(right, r.right - br);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return { top, left, right };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private setupFloatingHeader() {
|
|
747
|
+
this.teardownFloatingHeader();
|
|
748
|
+
if (this.fixedHeight) return;
|
|
749
|
+
const realTable = this.__realTableEl;
|
|
750
|
+
if (!realTable) return;
|
|
751
|
+
|
|
752
|
+
this.__scrollAncestors = this.__collectScrollAncestors();
|
|
753
|
+
// .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.
|
|
756
|
+
const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
|
|
757
|
+
if (tableScrollEl) {
|
|
758
|
+
this.__scrollAncestors.unshift({ target: tableScrollEl, scrollsY: false, scrollsX: true });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Track resize of the real table so we can mirror its width and column widths.
|
|
762
|
+
this.__floatingResizeObserver = new ResizeObserver(() => {
|
|
763
|
+
this.__syncFloatingHeader();
|
|
764
|
+
});
|
|
765
|
+
this.__floatingResizeObserver.observe(realTable);
|
|
766
|
+
|
|
767
|
+
this.__floatingScrollHandler = () => this.__syncFloatingHeader();
|
|
768
|
+
for (const a of this.__scrollAncestors) {
|
|
769
|
+
a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
|
|
770
|
+
}
|
|
771
|
+
window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
|
|
772
|
+
|
|
773
|
+
this.__syncFloatingHeader();
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private teardownFloatingHeader() {
|
|
777
|
+
this.__floatingResizeObserver?.disconnect();
|
|
778
|
+
this.__floatingResizeObserver = undefined;
|
|
779
|
+
if (this.__floatingScrollHandler) {
|
|
780
|
+
for (const a of this.__scrollAncestors) {
|
|
781
|
+
a.target.removeEventListener('scroll', this.__floatingScrollHandler);
|
|
782
|
+
}
|
|
783
|
+
window.removeEventListener('resize', this.__floatingScrollHandler);
|
|
784
|
+
this.__floatingScrollHandler = undefined;
|
|
785
|
+
}
|
|
786
|
+
this.__scrollAncestors = [];
|
|
787
|
+
this.__floatingActive = false;
|
|
788
|
+
const fh = this.__floatingHeaderEl;
|
|
789
|
+
if (fh) fh.classList.remove('active');
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Single function that drives both activation and geometry of the floating
|
|
794
|
+
* header. Called on scroll, resize, table-resize, and after each render.
|
|
795
|
+
*/
|
|
796
|
+
private __syncFloatingHeader() {
|
|
797
|
+
const fh = this.__floatingHeaderEl;
|
|
798
|
+
const realTable = this.__realTableEl;
|
|
799
|
+
const floatTable = this.__floatingTableEl;
|
|
800
|
+
if (!fh || !realTable || !floatTable) return;
|
|
801
|
+
|
|
802
|
+
const tableRect = realTable.getBoundingClientRect();
|
|
803
|
+
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
|
+
const realHeadRows = realTable.tHead?.rows;
|
|
808
|
+
const floatHeadRows = floatTable.tHead?.rows;
|
|
809
|
+
let headerHeight = 0;
|
|
810
|
+
if (realHeadRows && floatHeadRows) {
|
|
811
|
+
for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
|
|
812
|
+
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
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Active when the table top is above the stick line and the table bottom
|
|
825
|
+
// hasn't yet scrolled past it.
|
|
826
|
+
const shouldBeActive =
|
|
827
|
+
tableRect.top < stick.top && tableRect.bottom > stick.top + Math.min(headerHeight, 1);
|
|
828
|
+
|
|
829
|
+
if (shouldBeActive !== this.__floatingActive) {
|
|
830
|
+
this.__floatingActive = shouldBeActive;
|
|
831
|
+
fh.classList.toggle('active', shouldBeActive);
|
|
832
|
+
}
|
|
833
|
+
if (!shouldBeActive) return;
|
|
834
|
+
|
|
835
|
+
// Position the floating header. Clip horizontally to the scroll context
|
|
836
|
+
// so a horizontally-scrolled inner container's header doesn't bleed
|
|
837
|
+
// outside the container's border.
|
|
838
|
+
const clipLeft = Math.max(tableRect.left, stick.left);
|
|
839
|
+
const clipRight = Math.min(tableRect.right, stick.right);
|
|
840
|
+
const clipWidth = Math.max(0, clipRight - clipLeft);
|
|
841
|
+
|
|
842
|
+
fh.style.top = `${stick.top}px`;
|
|
843
|
+
fh.style.left = `${clipLeft}px`;
|
|
844
|
+
fh.style.width = `${clipWidth}px`;
|
|
845
|
+
|
|
846
|
+
// The inner table is positioned so the visible region matches the real
|
|
847
|
+
// table's left edge — shift it left when we clipped to the container.
|
|
848
|
+
floatTable.style.width = `${tableRect.width}px`;
|
|
849
|
+
floatTable.style.marginLeft = `${tableRect.left - clipLeft}px`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
public async disconnectedCallback() {
|
|
853
|
+
super.disconnectedCallback();
|
|
854
|
+
this.teardownFloatingHeader();
|
|
855
|
+
}
|
|
856
|
+
|
|
548
857
|
public async firstUpdated() {
|
|
549
|
-
|
|
858
|
+
// Floating-header observers are wired up in `updated()` once the
|
|
859
|
+
// table markup actually exists (it only renders when data.length > 0).
|
|
550
860
|
}
|
|
551
861
|
|
|
552
862
|
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
|
553
863
|
super.updated(changedProperties);
|
|
554
864
|
this.determineColumnWidths();
|
|
865
|
+
// (Re)wire the floating header whenever the relevant props change or
|
|
866
|
+
// the table markup may have appeared/disappeared.
|
|
867
|
+
if (
|
|
868
|
+
changedProperties.has('fixedHeight') ||
|
|
869
|
+
changedProperties.has('data') ||
|
|
870
|
+
changedProperties.has('columns') ||
|
|
871
|
+
!this.__floatingScrollHandler
|
|
872
|
+
) {
|
|
873
|
+
if (!this.fixedHeight && this.data.length > 0) {
|
|
874
|
+
this.setupFloatingHeader();
|
|
875
|
+
} else {
|
|
876
|
+
this.teardownFloatingHeader();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
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) {
|
|
882
|
+
this.__syncFloatingHeader();
|
|
883
|
+
}
|
|
555
884
|
if (this.searchable) {
|
|
556
885
|
const existing = this.dataActions.find((actionArg) => actionArg.type?.includes('header') && actionArg.name === 'Search');
|
|
557
886
|
if (!existing) {
|
|
@@ -1064,6 +1393,74 @@ export class DeesTable<T> extends DeesElement {
|
|
|
1064
1393
|
this.requestUpdate();
|
|
1065
1394
|
}
|
|
1066
1395
|
|
|
1396
|
+
/**
|
|
1397
|
+
* Handles row clicks with file-manager style selection semantics:
|
|
1398
|
+
* - plain click: select only this row, set anchor
|
|
1399
|
+
* - cmd/ctrl+click: toggle this row in/out, set anchor
|
|
1400
|
+
* - shift+click: select the contiguous range from the anchor to this row
|
|
1401
|
+
*
|
|
1402
|
+
* Multi-row click selection is always available (`selectionMode === 'none'`
|
|
1403
|
+
* and `'multi'` both behave this way) so consumers can always copy a set
|
|
1404
|
+
* of rows. Only `selectionMode === 'single'` restricts to one row.
|
|
1405
|
+
*/
|
|
1406
|
+
private handleRowClick(eventArg: MouseEvent, item: T, rowIndex: number, view: T[]) {
|
|
1407
|
+
const id = this.getRowId(item);
|
|
1408
|
+
|
|
1409
|
+
if (this.selectionMode === 'single') {
|
|
1410
|
+
this.selectedDataRow = item;
|
|
1411
|
+
this.selectedIds.clear();
|
|
1412
|
+
this.selectedIds.add(id);
|
|
1413
|
+
this.__selectionAnchorId = id;
|
|
1414
|
+
this.emitSelectionChange();
|
|
1415
|
+
this.requestUpdate();
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// multi
|
|
1420
|
+
const isToggle = eventArg.metaKey || eventArg.ctrlKey;
|
|
1421
|
+
const isRange = eventArg.shiftKey;
|
|
1422
|
+
|
|
1423
|
+
if (isRange && this.__selectionAnchorId !== undefined) {
|
|
1424
|
+
// Clear any text selection the browser may have created.
|
|
1425
|
+
window.getSelection?.()?.removeAllRanges();
|
|
1426
|
+
const anchorIdx = view.findIndex((r) => this.getRowId(r) === this.__selectionAnchorId);
|
|
1427
|
+
if (anchorIdx >= 0) {
|
|
1428
|
+
const [a, b] = anchorIdx <= rowIndex ? [anchorIdx, rowIndex] : [rowIndex, anchorIdx];
|
|
1429
|
+
this.selectedIds.clear();
|
|
1430
|
+
for (let i = a; i <= b; i++) this.selectedIds.add(this.getRowId(view[i]));
|
|
1431
|
+
} else {
|
|
1432
|
+
// Anchor no longer in view (filter changed, etc.) — fall back to single select.
|
|
1433
|
+
this.selectedIds.clear();
|
|
1434
|
+
this.selectedIds.add(id);
|
|
1435
|
+
this.__selectionAnchorId = id;
|
|
1436
|
+
}
|
|
1437
|
+
this.selectedDataRow = item;
|
|
1438
|
+
} else if (isToggle) {
|
|
1439
|
+
const wasSelected = this.selectedIds.has(id);
|
|
1440
|
+
if (wasSelected) {
|
|
1441
|
+
this.selectedIds.delete(id);
|
|
1442
|
+
// If we just deselected the focused row, move focus to another
|
|
1443
|
+
// selected row (or clear it) so the highlight goes away.
|
|
1444
|
+
if (this.selectedDataRow === item) {
|
|
1445
|
+
const remaining = view.find((r) => this.selectedIds.has(this.getRowId(r)));
|
|
1446
|
+
this.selectedDataRow = remaining as T;
|
|
1447
|
+
}
|
|
1448
|
+
} else {
|
|
1449
|
+
this.selectedIds.add(id);
|
|
1450
|
+
this.selectedDataRow = item;
|
|
1451
|
+
}
|
|
1452
|
+
this.__selectionAnchorId = id;
|
|
1453
|
+
} else {
|
|
1454
|
+
this.selectedDataRow = item;
|
|
1455
|
+
this.selectedIds.clear();
|
|
1456
|
+
this.selectedIds.add(id);
|
|
1457
|
+
this.__selectionAnchorId = id;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
this.emitSelectionChange();
|
|
1461
|
+
this.requestUpdate();
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1067
1464
|
private setRowSelected(row: T, checked: boolean) {
|
|
1068
1465
|
const id = this.getRowId(row);
|
|
1069
1466
|
if (this.selectionMode === 'single') {
|