@design.estate/dees-catalog 3.61.2 → 3.63.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 +677 -128
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/00group-dataview/dees-table/data.d.ts +8 -2
- package/dist_ts_web/elements/00group-dataview/dees-table/data.js +40 -22
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.d.ts +110 -6
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.demo.js +39 -1
- package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.js +629 -114
- package/dist_ts_web/elements/00group-dataview/dees-table/styles.js +72 -18
- package/dist_ts_web/elements/00group-dataview/dees-table/types.d.ts +8 -0
- package/dist_watch/bundle.js +675 -126
- 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/data.ts +33 -17
- package/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts +38 -0
- package/ts_web/elements/00group-dataview/dees-table/dees-table.ts +656 -92
- package/ts_web/elements/00group-dataview/dees-table/styles.ts +71 -17
- package/ts_web/elements/00group-dataview/dees-table/types.ts +9 -0
|
@@ -3,10 +3,11 @@ import { demoFunc } from './dees-table.demo.js';
|
|
|
3
3
|
import { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element';
|
|
4
4
|
|
|
5
5
|
import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
|
|
6
|
+
import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
|
|
6
7
|
import * as domtools from '@design.estate/dees-domtools';
|
|
7
8
|
import { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.js';
|
|
8
9
|
import { tableStyles } from './styles.js';
|
|
9
|
-
import type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
|
10
|
+
import type { Column, ISortDescriptor, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
|
10
11
|
import {
|
|
11
12
|
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
|
|
12
13
|
computeEffectiveColumns as computeEffectiveColumnsFn,
|
|
@@ -17,7 +18,14 @@ import { compileLucenePredicate } from './lucene.js';
|
|
|
17
18
|
import { themeDefaultStyles } from '../../00theme.js';
|
|
18
19
|
import '../../00group-layout/dees-tile/dees-tile.js';
|
|
19
20
|
|
|
20
|
-
export type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
|
21
|
+
export type { Column, ISortDescriptor, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
|
22
|
+
|
|
23
|
+
/** Returns the English ordinal label for a 1-based position (e.g. 1 → "1st"). */
|
|
24
|
+
function ordinalLabel(n: number): string {
|
|
25
|
+
const s = ['th', 'st', 'nd', 'rd'];
|
|
26
|
+
const v = n % 100;
|
|
27
|
+
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
28
|
+
}
|
|
21
29
|
|
|
22
30
|
declare global {
|
|
23
31
|
interface HTMLElementTagNameMap {
|
|
@@ -161,11 +169,12 @@ export class DeesTable<T> extends DeesElement {
|
|
|
161
169
|
|
|
162
170
|
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
|
|
163
171
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Multi-column sort cascade. The first entry is the primary sort key,
|
|
174
|
+
* subsequent entries are tiebreakers in priority order.
|
|
175
|
+
*/
|
|
167
176
|
@property({ attribute: false })
|
|
168
|
-
accessor
|
|
177
|
+
accessor sortBy: ISortDescriptor[] = [];
|
|
169
178
|
|
|
170
179
|
// simple client-side filtering (Phase 1)
|
|
171
180
|
@property({ type: String })
|
|
@@ -175,8 +184,17 @@ export class DeesTable<T> extends DeesElement {
|
|
|
175
184
|
accessor columnFilters: Record<string, string> = {};
|
|
176
185
|
@property({ type: Boolean, attribute: 'show-column-filters' })
|
|
177
186
|
accessor showColumnFilters: boolean = false;
|
|
178
|
-
|
|
179
|
-
|
|
187
|
+
/**
|
|
188
|
+
* When set, the table renders inside a fixed-height scroll container
|
|
189
|
+
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
|
|
190
|
+
* within that box via plain CSS sticky.
|
|
191
|
+
*
|
|
192
|
+
* When unset (the default), the table flows naturally and a JS-managed
|
|
193
|
+
* floating header keeps the column headers visible while the table is
|
|
194
|
+
* scrolled past in any ancestor scroll container (page or otherwise).
|
|
195
|
+
*/
|
|
196
|
+
@property({ type: Boolean, reflect: true, attribute: 'fixed-height' })
|
|
197
|
+
accessor fixedHeight: boolean = false;
|
|
180
198
|
|
|
181
199
|
// search row state
|
|
182
200
|
@property({ type: String })
|
|
@@ -213,8 +231,7 @@ export class DeesTable<T> extends DeesElement {
|
|
|
213
231
|
const viewData = getViewDataFn(
|
|
214
232
|
this.data,
|
|
215
233
|
effectiveColumns,
|
|
216
|
-
this.
|
|
217
|
-
this.sortDir,
|
|
234
|
+
this.sortBy,
|
|
218
235
|
this.filterText,
|
|
219
236
|
this.columnFilters,
|
|
220
237
|
this.searchMode === 'data' ? 'data' : 'table',
|
|
@@ -289,69 +306,7 @@ export class DeesTable<T> extends DeesElement {
|
|
|
289
306
|
<div class="tableScroll">
|
|
290
307
|
<table>
|
|
291
308
|
<thead>
|
|
292
|
-
|
|
293
|
-
${this.selectionMode !== 'none'
|
|
294
|
-
? html`
|
|
295
|
-
<th style="width:42px; text-align:center;">
|
|
296
|
-
${this.selectionMode === 'multi'
|
|
297
|
-
? html`
|
|
298
|
-
<dees-input-checkbox
|
|
299
|
-
.value=${this.areAllVisibleSelected()}
|
|
300
|
-
.indeterminate=${this.isVisibleSelectionIndeterminate()}
|
|
301
|
-
@newValue=${(e: CustomEvent<boolean>) => {
|
|
302
|
-
e.stopPropagation();
|
|
303
|
-
this.setSelectVisible(e.detail === true);
|
|
304
|
-
}}
|
|
305
|
-
></dees-input-checkbox>
|
|
306
|
-
`
|
|
307
|
-
: html``}
|
|
308
|
-
</th>
|
|
309
|
-
`
|
|
310
|
-
: html``}
|
|
311
|
-
${effectiveColumns
|
|
312
|
-
.filter((c) => !c.hidden)
|
|
313
|
-
.map((col) => {
|
|
314
|
-
const isSortable = !!col.sortable;
|
|
315
|
-
const ariaSort = this.getAriaSort(col);
|
|
316
|
-
return html`
|
|
317
|
-
<th
|
|
318
|
-
role="columnheader"
|
|
319
|
-
aria-sort=${ariaSort}
|
|
320
|
-
style="${isSortable ? 'cursor: pointer;' : ''}"
|
|
321
|
-
@click=${() => (isSortable ? this.toggleSort(col) : null)}
|
|
322
|
-
>
|
|
323
|
-
${col.header ?? (col.key as any)}
|
|
324
|
-
${this.renderSortIndicator(col)}
|
|
325
|
-
</th>`;
|
|
326
|
-
})}
|
|
327
|
-
${(() => {
|
|
328
|
-
if (this.dataActions && this.dataActions.length > 0) {
|
|
329
|
-
return html` <th class="actionsCol">Actions</th> `;
|
|
330
|
-
}
|
|
331
|
-
})()}
|
|
332
|
-
</tr>
|
|
333
|
-
${this.showColumnFilters
|
|
334
|
-
? html`<tr class="filtersRow">
|
|
335
|
-
${this.selectionMode !== 'none'
|
|
336
|
-
? html`<th style="width:42px;"></th>`
|
|
337
|
-
: html``}
|
|
338
|
-
${effectiveColumns
|
|
339
|
-
.filter((c) => !c.hidden)
|
|
340
|
-
.map((col) => {
|
|
341
|
-
const key = String(col.key);
|
|
342
|
-
if (col.filterable === false) return html`<th></th>`;
|
|
343
|
-
return html`<th>
|
|
344
|
-
<input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
|
|
345
|
-
@input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
|
|
346
|
-
</th>`;
|
|
347
|
-
})}
|
|
348
|
-
${(() => {
|
|
349
|
-
if (this.dataActions && this.dataActions.length > 0) {
|
|
350
|
-
return html` <th></th> `;
|
|
351
|
-
}
|
|
352
|
-
})()}
|
|
353
|
-
</tr>`
|
|
354
|
-
: html``}
|
|
309
|
+
${this.renderHeaderRows(effectiveColumns)}
|
|
355
310
|
</thead>
|
|
356
311
|
<tbody>
|
|
357
312
|
${viewData.map((itemArg, rowIndex) => {
|
|
@@ -494,6 +449,13 @@ export class DeesTable<T> extends DeesElement {
|
|
|
494
449
|
</tbody>
|
|
495
450
|
</table>
|
|
496
451
|
</div>
|
|
452
|
+
<div class="floatingHeader" aria-hidden="true">
|
|
453
|
+
<table>
|
|
454
|
+
<thead>
|
|
455
|
+
${this.renderHeaderRows(effectiveColumns)}
|
|
456
|
+
</thead>
|
|
457
|
+
</table>
|
|
458
|
+
</div>
|
|
497
459
|
`
|
|
498
460
|
: html` <div class="noDataSet">No data set!</div> `}
|
|
499
461
|
<div slot="footer" class="footer">
|
|
@@ -532,13 +494,302 @@ export class DeesTable<T> extends DeesElement {
|
|
|
532
494
|
`;
|
|
533
495
|
}
|
|
534
496
|
|
|
497
|
+
/**
|
|
498
|
+
* Renders the header rows. Used twice per render: once inside the real
|
|
499
|
+
* `<thead>` and once inside the floating-header clone, so sort indicators
|
|
500
|
+
* and filter inputs stay in sync automatically.
|
|
501
|
+
*/
|
|
502
|
+
private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult {
|
|
503
|
+
return html`
|
|
504
|
+
<tr>
|
|
505
|
+
${this.selectionMode !== 'none'
|
|
506
|
+
? html`
|
|
507
|
+
<th style="width:42px; text-align:center;">
|
|
508
|
+
${this.selectionMode === 'multi'
|
|
509
|
+
? html`
|
|
510
|
+
<dees-input-checkbox
|
|
511
|
+
.value=${this.areAllVisibleSelected()}
|
|
512
|
+
.indeterminate=${this.isVisibleSelectionIndeterminate()}
|
|
513
|
+
@newValue=${(e: CustomEvent<boolean>) => {
|
|
514
|
+
e.stopPropagation();
|
|
515
|
+
this.setSelectVisible(e.detail === true);
|
|
516
|
+
}}
|
|
517
|
+
></dees-input-checkbox>
|
|
518
|
+
`
|
|
519
|
+
: html``}
|
|
520
|
+
</th>
|
|
521
|
+
`
|
|
522
|
+
: html``}
|
|
523
|
+
${effectiveColumns
|
|
524
|
+
.filter((c) => !c.hidden)
|
|
525
|
+
.map((col) => {
|
|
526
|
+
const isSortable = !!col.sortable;
|
|
527
|
+
const ariaSort = this.getAriaSort(col);
|
|
528
|
+
return html`
|
|
529
|
+
<th
|
|
530
|
+
role="columnheader"
|
|
531
|
+
aria-sort=${ariaSort}
|
|
532
|
+
style="${isSortable ? 'cursor: pointer;' : ''}"
|
|
533
|
+
@click=${(eventArg: MouseEvent) =>
|
|
534
|
+
isSortable ? this.handleHeaderClick(eventArg, col, effectiveColumns) : null}
|
|
535
|
+
@contextmenu=${(eventArg: MouseEvent) =>
|
|
536
|
+
isSortable
|
|
537
|
+
? this.openHeaderContextMenu(eventArg, col, effectiveColumns)
|
|
538
|
+
: null}
|
|
539
|
+
>
|
|
540
|
+
${col.header ?? (col.key as any)}
|
|
541
|
+
${this.renderSortIndicator(col)}
|
|
542
|
+
</th>`;
|
|
543
|
+
})}
|
|
544
|
+
${this.dataActions && this.dataActions.length > 0
|
|
545
|
+
? html`<th class="actionsCol">Actions</th>`
|
|
546
|
+
: html``}
|
|
547
|
+
</tr>
|
|
548
|
+
${this.showColumnFilters
|
|
549
|
+
? html`<tr class="filtersRow">
|
|
550
|
+
${this.selectionMode !== 'none'
|
|
551
|
+
? html`<th style="width:42px;"></th>`
|
|
552
|
+
: html``}
|
|
553
|
+
${effectiveColumns
|
|
554
|
+
.filter((c) => !c.hidden)
|
|
555
|
+
.map((col) => {
|
|
556
|
+
const key = String(col.key);
|
|
557
|
+
if (col.filterable === false) return html`<th></th>`;
|
|
558
|
+
return html`<th>
|
|
559
|
+
<input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
|
|
560
|
+
@input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
|
|
561
|
+
</th>`;
|
|
562
|
+
})}
|
|
563
|
+
${this.dataActions && this.dataActions.length > 0
|
|
564
|
+
? html`<th></th>`
|
|
565
|
+
: html``}
|
|
566
|
+
</tr>`
|
|
567
|
+
: html``}
|
|
568
|
+
`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ─── Floating header (page-sticky) lifecycle ─────────────────────────
|
|
572
|
+
private __floatingResizeObserver?: ResizeObserver;
|
|
573
|
+
private __floatingScrollHandler?: () => void;
|
|
574
|
+
private __floatingActive = false;
|
|
575
|
+
private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
|
|
576
|
+
|
|
577
|
+
private get __floatingHeaderEl(): HTMLDivElement | null {
|
|
578
|
+
return this.shadowRoot?.querySelector('.floatingHeader') ?? null;
|
|
579
|
+
}
|
|
580
|
+
private get __realTableEl(): HTMLTableElement | null {
|
|
581
|
+
return this.shadowRoot?.querySelector('.tableScroll > table') ?? null;
|
|
582
|
+
}
|
|
583
|
+
private get __floatingTableEl(): HTMLTableElement | null {
|
|
584
|
+
return this.shadowRoot?.querySelector('.floatingHeader > table') ?? null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Walks up the DOM (and through shadow roots) collecting every ancestor
|
|
589
|
+
* element whose computed `overflow-y` makes it a scroll container, plus
|
|
590
|
+
* `window` at the end. We listen for scroll on all of them so the floating
|
|
591
|
+
* header reacts whether the user scrolls the page or any nested container.
|
|
592
|
+
*/
|
|
593
|
+
private __collectScrollAncestors(): Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> {
|
|
594
|
+
const result: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
|
|
595
|
+
let node: Node | null = this as unknown as Node;
|
|
596
|
+
const scrollish = (v: string) => v === 'auto' || v === 'scroll' || v === 'overlay';
|
|
597
|
+
while (node) {
|
|
598
|
+
if (node instanceof Element) {
|
|
599
|
+
const style = getComputedStyle(node);
|
|
600
|
+
const sy = scrollish(style.overflowY);
|
|
601
|
+
const sx = scrollish(style.overflowX);
|
|
602
|
+
if (sy || sx) {
|
|
603
|
+
result.push({ target: node, scrollsY: sy, scrollsX: sx });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const parent = (node as any).assignedSlot
|
|
607
|
+
? (node as any).assignedSlot
|
|
608
|
+
: node.parentNode;
|
|
609
|
+
if (parent) {
|
|
610
|
+
node = parent;
|
|
611
|
+
} else if ((node as ShadowRoot).host) {
|
|
612
|
+
node = (node as ShadowRoot).host;
|
|
613
|
+
} else {
|
|
614
|
+
node = null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
result.push({ target: window, scrollsY: true, scrollsX: true });
|
|
618
|
+
return result;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Returns the "stick line" — the y-coordinate (in viewport space) at which
|
|
623
|
+
* the floating header should appear. Defaults to 0 (page top), but if the
|
|
624
|
+
* table is inside a scroll container we use that container's content-box
|
|
625
|
+
* top so the header sits inside the container's border/padding instead of
|
|
626
|
+
* floating over it.
|
|
627
|
+
*/
|
|
628
|
+
private __getStickContext(): { top: number; left: number; right: number } {
|
|
629
|
+
let top = 0;
|
|
630
|
+
let left = 0;
|
|
631
|
+
let right = window.innerWidth;
|
|
632
|
+
for (const a of this.__scrollAncestors) {
|
|
633
|
+
if (a.target === window) continue;
|
|
634
|
+
const el = a.target as Element;
|
|
635
|
+
const r = el.getBoundingClientRect();
|
|
636
|
+
const cs = getComputedStyle(el);
|
|
637
|
+
// Only constrain top from ancestors that actually scroll vertically —
|
|
638
|
+
// a horizontal-only scroll container (like .tableScroll) must not push
|
|
639
|
+
// the stick line down to its own top.
|
|
640
|
+
if (a.scrollsY) {
|
|
641
|
+
const bt = parseFloat(cs.borderTopWidth) || 0;
|
|
642
|
+
top = Math.max(top, r.top + bt);
|
|
643
|
+
}
|
|
644
|
+
// Same for horizontal clipping.
|
|
645
|
+
if (a.scrollsX) {
|
|
646
|
+
const bl = parseFloat(cs.borderLeftWidth) || 0;
|
|
647
|
+
const br = parseFloat(cs.borderRightWidth) || 0;
|
|
648
|
+
left = Math.max(left, r.left + bl);
|
|
649
|
+
right = Math.min(right, r.right - br);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return { top, left, right };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private setupFloatingHeader() {
|
|
656
|
+
this.teardownFloatingHeader();
|
|
657
|
+
if (this.fixedHeight) return;
|
|
658
|
+
const realTable = this.__realTableEl;
|
|
659
|
+
if (!realTable) return;
|
|
660
|
+
|
|
661
|
+
this.__scrollAncestors = this.__collectScrollAncestors();
|
|
662
|
+
// .tableScroll is a descendant (inside our shadow root), not an ancestor,
|
|
663
|
+
// so the upward walk above misses it. Add it explicitly so horizontal
|
|
664
|
+
// scrolling inside the table re-syncs the floating header.
|
|
665
|
+
const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
|
|
666
|
+
if (tableScrollEl) {
|
|
667
|
+
this.__scrollAncestors.unshift({ target: tableScrollEl, scrollsY: false, scrollsX: true });
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Track resize of the real table so we can mirror its width and column widths.
|
|
671
|
+
this.__floatingResizeObserver = new ResizeObserver(() => {
|
|
672
|
+
this.__syncFloatingHeader();
|
|
673
|
+
});
|
|
674
|
+
this.__floatingResizeObserver.observe(realTable);
|
|
675
|
+
|
|
676
|
+
this.__floatingScrollHandler = () => this.__syncFloatingHeader();
|
|
677
|
+
for (const a of this.__scrollAncestors) {
|
|
678
|
+
a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
|
|
679
|
+
}
|
|
680
|
+
window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
|
|
681
|
+
|
|
682
|
+
this.__syncFloatingHeader();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private teardownFloatingHeader() {
|
|
686
|
+
this.__floatingResizeObserver?.disconnect();
|
|
687
|
+
this.__floatingResizeObserver = undefined;
|
|
688
|
+
if (this.__floatingScrollHandler) {
|
|
689
|
+
for (const a of this.__scrollAncestors) {
|
|
690
|
+
a.target.removeEventListener('scroll', this.__floatingScrollHandler);
|
|
691
|
+
}
|
|
692
|
+
window.removeEventListener('resize', this.__floatingScrollHandler);
|
|
693
|
+
this.__floatingScrollHandler = undefined;
|
|
694
|
+
}
|
|
695
|
+
this.__scrollAncestors = [];
|
|
696
|
+
this.__floatingActive = false;
|
|
697
|
+
const fh = this.__floatingHeaderEl;
|
|
698
|
+
if (fh) fh.classList.remove('active');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Single function that drives both activation and geometry of the floating
|
|
703
|
+
* header. Called on scroll, resize, table-resize, and after each render.
|
|
704
|
+
*/
|
|
705
|
+
private __syncFloatingHeader() {
|
|
706
|
+
const fh = this.__floatingHeaderEl;
|
|
707
|
+
const realTable = this.__realTableEl;
|
|
708
|
+
const floatTable = this.__floatingTableEl;
|
|
709
|
+
if (!fh || !realTable || !floatTable) return;
|
|
710
|
+
|
|
711
|
+
const tableRect = realTable.getBoundingClientRect();
|
|
712
|
+
const stick = this.__getStickContext();
|
|
713
|
+
|
|
714
|
+
// Mirror table layout + per-cell widths so columns line up.
|
|
715
|
+
floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
|
|
716
|
+
const realHeadRows = realTable.tHead?.rows;
|
|
717
|
+
const floatHeadRows = floatTable.tHead?.rows;
|
|
718
|
+
let headerHeight = 0;
|
|
719
|
+
if (realHeadRows && floatHeadRows) {
|
|
720
|
+
for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
|
|
721
|
+
headerHeight += realHeadRows[r].getBoundingClientRect().height;
|
|
722
|
+
const realCells = realHeadRows[r].cells;
|
|
723
|
+
const floatCells = floatHeadRows[r].cells;
|
|
724
|
+
for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
|
|
725
|
+
const w = realCells[c].getBoundingClientRect().width;
|
|
726
|
+
(floatCells[c] as HTMLElement).style.width = `${w}px`;
|
|
727
|
+
(floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
|
|
728
|
+
(floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Active when the table top is above the stick line and the table bottom
|
|
734
|
+
// hasn't yet scrolled past it.
|
|
735
|
+
const shouldBeActive =
|
|
736
|
+
tableRect.top < stick.top && tableRect.bottom > stick.top + Math.min(headerHeight, 1);
|
|
737
|
+
|
|
738
|
+
if (shouldBeActive !== this.__floatingActive) {
|
|
739
|
+
this.__floatingActive = shouldBeActive;
|
|
740
|
+
fh.classList.toggle('active', shouldBeActive);
|
|
741
|
+
}
|
|
742
|
+
if (!shouldBeActive) return;
|
|
743
|
+
|
|
744
|
+
// Position the floating header. Clip horizontally to the scroll context
|
|
745
|
+
// so a horizontally-scrolled inner container's header doesn't bleed
|
|
746
|
+
// outside the container's border.
|
|
747
|
+
const clipLeft = Math.max(tableRect.left, stick.left);
|
|
748
|
+
const clipRight = Math.min(tableRect.right, stick.right);
|
|
749
|
+
const clipWidth = Math.max(0, clipRight - clipLeft);
|
|
750
|
+
|
|
751
|
+
fh.style.top = `${stick.top}px`;
|
|
752
|
+
fh.style.left = `${clipLeft}px`;
|
|
753
|
+
fh.style.width = `${clipWidth}px`;
|
|
754
|
+
|
|
755
|
+
// The inner table is positioned so the visible region matches the real
|
|
756
|
+
// table's left edge — shift it left when we clipped to the container.
|
|
757
|
+
floatTable.style.width = `${tableRect.width}px`;
|
|
758
|
+
floatTable.style.marginLeft = `${tableRect.left - clipLeft}px`;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
public async disconnectedCallback() {
|
|
762
|
+
super.disconnectedCallback();
|
|
763
|
+
this.teardownFloatingHeader();
|
|
764
|
+
}
|
|
765
|
+
|
|
535
766
|
public async firstUpdated() {
|
|
536
|
-
|
|
767
|
+
// Floating-header observers are wired up in `updated()` once the
|
|
768
|
+
// table markup actually exists (it only renders when data.length > 0).
|
|
537
769
|
}
|
|
538
770
|
|
|
539
771
|
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
|
540
772
|
super.updated(changedProperties);
|
|
541
773
|
this.determineColumnWidths();
|
|
774
|
+
// (Re)wire the floating header whenever the relevant props change or
|
|
775
|
+
// the table markup may have appeared/disappeared.
|
|
776
|
+
if (
|
|
777
|
+
changedProperties.has('fixedHeight') ||
|
|
778
|
+
changedProperties.has('data') ||
|
|
779
|
+
changedProperties.has('columns') ||
|
|
780
|
+
!this.__floatingScrollHandler
|
|
781
|
+
) {
|
|
782
|
+
if (!this.fixedHeight && this.data.length > 0) {
|
|
783
|
+
this.setupFloatingHeader();
|
|
784
|
+
} else {
|
|
785
|
+
this.teardownFloatingHeader();
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Keep the floating header in sync after any re-render
|
|
789
|
+
// (column widths may have changed).
|
|
790
|
+
if (!this.fixedHeight && this.data.length > 0) {
|
|
791
|
+
this.__syncFloatingHeader();
|
|
792
|
+
}
|
|
542
793
|
if (this.searchable) {
|
|
543
794
|
const existing = this.dataActions.find((actionArg) => actionArg.type?.includes('header') && actionArg.name === 'Search');
|
|
544
795
|
if (!existing) {
|
|
@@ -651,35 +902,348 @@ export class DeesTable<T> extends DeesElement {
|
|
|
651
902
|
|
|
652
903
|
// compute helpers moved to ./data.ts
|
|
653
904
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
905
|
+
// ─── sort: public API ────────────────────────────────────────────────
|
|
906
|
+
|
|
907
|
+
/** Returns the descriptor for `key` if the column is currently in the cascade. */
|
|
908
|
+
public getSortDescriptor(key: string): ISortDescriptor | undefined {
|
|
909
|
+
return this.sortBy.find((d) => d.key === key);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Returns the 0-based priority of `key` in the cascade, or -1 if not present. */
|
|
913
|
+
public getSortPriority(key: string): number {
|
|
914
|
+
return this.sortBy.findIndex((d) => d.key === key);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Replaces the cascade with a single sort entry. */
|
|
918
|
+
public setSort(key: string, dir: 'asc' | 'desc'): void {
|
|
919
|
+
this.sortBy = [{ key, dir }];
|
|
920
|
+
this.emitSortChange();
|
|
921
|
+
this.requestUpdate();
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Inserts (or moves) `key` to a 0-based position in the cascade. If the key is
|
|
926
|
+
* already present elsewhere, its previous entry is removed before insertion so
|
|
927
|
+
* a column appears at most once.
|
|
928
|
+
*/
|
|
929
|
+
public addSortAt(key: string, position: number, dir: 'asc' | 'desc'): void {
|
|
930
|
+
const next = this.sortBy.filter((d) => d.key !== key);
|
|
931
|
+
const clamped = Math.max(0, Math.min(position, next.length));
|
|
932
|
+
next.splice(clamped, 0, { key, dir });
|
|
933
|
+
this.sortBy = next;
|
|
934
|
+
this.emitSortChange();
|
|
935
|
+
this.requestUpdate();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Appends `key` to the end of the cascade (or moves it there if already present). */
|
|
939
|
+
public appendSort(key: string, dir: 'asc' | 'desc'): void {
|
|
940
|
+
const next = this.sortBy.filter((d) => d.key !== key);
|
|
941
|
+
next.push({ key, dir });
|
|
942
|
+
this.sortBy = next;
|
|
943
|
+
this.emitSortChange();
|
|
944
|
+
this.requestUpdate();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Removes `key` from the cascade. No-op if not present. */
|
|
948
|
+
public removeSort(key: string): void {
|
|
949
|
+
if (!this.sortBy.some((d) => d.key === key)) return;
|
|
950
|
+
this.sortBy = this.sortBy.filter((d) => d.key !== key);
|
|
951
|
+
this.emitSortChange();
|
|
952
|
+
this.requestUpdate();
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Empties the cascade. */
|
|
956
|
+
public clearSorts(): void {
|
|
957
|
+
if (this.sortBy.length === 0) return;
|
|
958
|
+
this.sortBy = [];
|
|
959
|
+
this.emitSortChange();
|
|
960
|
+
this.requestUpdate();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private emitSortChange() {
|
|
666
964
|
this.dispatchEvent(
|
|
667
965
|
new CustomEvent('sortChange', {
|
|
668
|
-
detail: {
|
|
966
|
+
detail: { sortBy: this.sortBy.map((d) => ({ ...d })) },
|
|
669
967
|
bubbles: true,
|
|
670
968
|
})
|
|
671
969
|
);
|
|
672
|
-
this.requestUpdate();
|
|
673
970
|
}
|
|
674
971
|
|
|
972
|
+
// ─── sort: header interaction handlers ───────────────────────────────
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Plain left-click on a sortable header. Cycles `none → asc → desc → none`
|
|
976
|
+
* collapsing the cascade to a single column. If a multi-column cascade is
|
|
977
|
+
* active, asks the user to confirm the destructive replacement first. A
|
|
978
|
+
* Shift+click bypasses the modal and routes through the multi-sort cycle.
|
|
979
|
+
*/
|
|
980
|
+
private async handleHeaderClick(
|
|
981
|
+
eventArg: MouseEvent,
|
|
982
|
+
col: Column<T>,
|
|
983
|
+
_effectiveColumns: Column<T>[]
|
|
984
|
+
) {
|
|
985
|
+
if (eventArg.shiftKey) {
|
|
986
|
+
this.handleHeaderShiftClick(col);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const proceed = await this.confirmReplaceCascade(col);
|
|
990
|
+
if (!proceed) return;
|
|
991
|
+
this.cycleSingleSort(col);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Cycles a single column through `none → asc → desc → none`, collapsing the
|
|
996
|
+
* cascade. Used by both plain click and the menu's "Sort Ascending/Descending"
|
|
997
|
+
* shortcuts (after confirmation).
|
|
998
|
+
*/
|
|
999
|
+
private cycleSingleSort(col: Column<T>) {
|
|
1000
|
+
const key = String(col.key);
|
|
1001
|
+
const current = this.sortBy.length === 1 && this.sortBy[0].key === key ? this.sortBy[0].dir : null;
|
|
1002
|
+
if (current === 'asc') this.setSort(key, 'desc');
|
|
1003
|
+
else if (current === 'desc') this.clearSorts();
|
|
1004
|
+
else this.setSort(key, 'asc');
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Shift+click cycle on a sortable header. Edits the cascade in place without
|
|
1009
|
+
* destroying other sort keys: append → flip dir → remove.
|
|
1010
|
+
*/
|
|
1011
|
+
private handleHeaderShiftClick(col: Column<T>) {
|
|
1012
|
+
const key = String(col.key);
|
|
1013
|
+
const existing = this.getSortDescriptor(key);
|
|
1014
|
+
if (!existing) {
|
|
1015
|
+
this.appendSort(key, 'asc');
|
|
1016
|
+
} else if (existing.dir === 'asc') {
|
|
1017
|
+
this.sortBy = this.sortBy.map((d) => (d.key === key ? { key, dir: 'desc' } : d));
|
|
1018
|
+
this.emitSortChange();
|
|
1019
|
+
this.requestUpdate();
|
|
1020
|
+
} else {
|
|
1021
|
+
this.removeSort(key);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Opens a confirmation modal when the cascade has more than one entry and the
|
|
1027
|
+
* user attempts a destructive single-sort replacement. Resolves to `true` if
|
|
1028
|
+
* the user accepts, `false` if they cancel. If the cascade has 0 or 1 entries
|
|
1029
|
+
* the modal is skipped and we resolve to `true` immediately.
|
|
1030
|
+
*/
|
|
1031
|
+
private confirmReplaceCascade(targetCol: Column<T>): Promise<boolean> {
|
|
1032
|
+
if (this.sortBy.length <= 1) return Promise.resolve(true);
|
|
1033
|
+
return new Promise((resolve) => {
|
|
1034
|
+
let settled = false;
|
|
1035
|
+
const settle = (result: boolean) => {
|
|
1036
|
+
if (settled) return;
|
|
1037
|
+
settled = true;
|
|
1038
|
+
resolve(result);
|
|
1039
|
+
};
|
|
1040
|
+
const summary = this.sortBy
|
|
1041
|
+
.map((d, i) => {
|
|
1042
|
+
const c = (this as any)._lookupColumnByKey?.(d.key) as Column<T> | undefined;
|
|
1043
|
+
const label = c?.header ?? d.key;
|
|
1044
|
+
return html`<li>${i + 1}. ${label} ${d.dir === 'asc' ? '▲' : '▼'}</li>`;
|
|
1045
|
+
});
|
|
1046
|
+
DeesModal.createAndShow({
|
|
1047
|
+
heading: 'Replace multi-column sort?',
|
|
1048
|
+
width: 'small',
|
|
1049
|
+
showCloseButton: true,
|
|
1050
|
+
content: html`
|
|
1051
|
+
<div style="font-size:13px; line-height:1.55;">
|
|
1052
|
+
<p style="margin:0 0 8px;">
|
|
1053
|
+
You currently have a ${this.sortBy.length}-column sort active:
|
|
1054
|
+
</p>
|
|
1055
|
+
<ul style="margin:0 0 12px; padding-left:18px;">${summary}</ul>
|
|
1056
|
+
<p style="margin:0;">
|
|
1057
|
+
Continuing will discard the cascade and replace it with a single sort on
|
|
1058
|
+
<strong>${targetCol.header ?? String(targetCol.key)}</strong>.
|
|
1059
|
+
</p>
|
|
1060
|
+
</div>
|
|
1061
|
+
`,
|
|
1062
|
+
menuOptions: [
|
|
1063
|
+
{
|
|
1064
|
+
name: 'Cancel',
|
|
1065
|
+
iconName: 'lucide:x',
|
|
1066
|
+
action: async (modal) => {
|
|
1067
|
+
settle(false);
|
|
1068
|
+
await modal!.destroy();
|
|
1069
|
+
return null;
|
|
1070
|
+
},
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
name: 'Replace',
|
|
1074
|
+
iconName: 'lucide:check',
|
|
1075
|
+
action: async (modal) => {
|
|
1076
|
+
settle(true);
|
|
1077
|
+
await modal!.destroy();
|
|
1078
|
+
return null;
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
],
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Looks up a column by its string key in the currently effective column set.
|
|
1088
|
+
* Used by the modal helper to render human-friendly labels.
|
|
1089
|
+
*/
|
|
1090
|
+
private _lookupColumnByKey(key: string): Column<T> | undefined {
|
|
1091
|
+
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
|
|
1092
|
+
const effective = usingColumns
|
|
1093
|
+
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
|
|
1094
|
+
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
|
|
1095
|
+
return effective.find((c) => String(c.key) === key);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Opens the header context menu for explicit multi-sort priority control.
|
|
1100
|
+
*/
|
|
1101
|
+
private openHeaderContextMenu(
|
|
1102
|
+
eventArg: MouseEvent,
|
|
1103
|
+
col: Column<T>,
|
|
1104
|
+
effectiveColumns: Column<T>[]
|
|
1105
|
+
) {
|
|
1106
|
+
const items = this.buildHeaderMenuItems(col, effectiveColumns);
|
|
1107
|
+
DeesContextmenu.openContextMenuWithOptions(eventArg, items as any);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Builds the dynamic context-menu structure for a single column header.
|
|
1112
|
+
*/
|
|
1113
|
+
private buildHeaderMenuItems(col: Column<T>, effectiveColumns: Column<T>[]) {
|
|
1114
|
+
const key = String(col.key);
|
|
1115
|
+
const existing = this.getSortDescriptor(key);
|
|
1116
|
+
const cascadeLen = this.sortBy.length;
|
|
1117
|
+
// Maximum exposed slot: one beyond the current cascade, capped at the
|
|
1118
|
+
// number of sortable columns. If the column is already in the cascade we
|
|
1119
|
+
// never need to grow the slot count.
|
|
1120
|
+
const sortableColumnCount = effectiveColumns.filter((c) => !!c.sortable).length;
|
|
1121
|
+
const maxSlot = Math.min(
|
|
1122
|
+
Math.max(cascadeLen + (existing ? 0 : 1), 1),
|
|
1123
|
+
Math.max(sortableColumnCount, 1)
|
|
1124
|
+
);
|
|
1125
|
+
|
|
1126
|
+
const items: any[] = [];
|
|
1127
|
+
|
|
1128
|
+
// Single-sort shortcuts. These are destructive when a cascade is active, so
|
|
1129
|
+
// they go through confirmReplaceCascade just like a plain click.
|
|
1130
|
+
items.push({
|
|
1131
|
+
name: 'Sort Ascending',
|
|
1132
|
+
iconName: cascadeLen === 1 && existing?.dir === 'asc' ? 'lucide:check' : 'lucide:arrowUp',
|
|
1133
|
+
action: async () => {
|
|
1134
|
+
if (await this.confirmReplaceCascade(col)) this.setSort(key, 'asc');
|
|
1135
|
+
return null;
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
items.push({
|
|
1139
|
+
name: 'Sort Descending',
|
|
1140
|
+
iconName: cascadeLen === 1 && existing?.dir === 'desc' ? 'lucide:check' : 'lucide:arrowDown',
|
|
1141
|
+
action: async () => {
|
|
1142
|
+
if (await this.confirmReplaceCascade(col)) this.setSort(key, 'desc');
|
|
1143
|
+
return null;
|
|
1144
|
+
},
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
items.push({ divider: true });
|
|
1148
|
+
|
|
1149
|
+
// Priority slot entries (1..maxSlot). Each slot has an asc/desc submenu.
|
|
1150
|
+
for (let slot = 1; slot <= maxSlot; slot++) {
|
|
1151
|
+
const ordinal = ordinalLabel(slot);
|
|
1152
|
+
const isCurrentSlot = existing && this.getSortPriority(key) === slot - 1;
|
|
1153
|
+
items.push({
|
|
1154
|
+
name: `Set as ${ordinal} sort`,
|
|
1155
|
+
iconName: isCurrentSlot ? 'lucide:check' : 'lucide:listOrdered',
|
|
1156
|
+
submenu: [
|
|
1157
|
+
{
|
|
1158
|
+
name: 'Ascending',
|
|
1159
|
+
iconName: 'lucide:arrowUp',
|
|
1160
|
+
action: async () => {
|
|
1161
|
+
this.addSortAt(key, slot - 1, 'asc');
|
|
1162
|
+
return null;
|
|
1163
|
+
},
|
|
1164
|
+
},
|
|
1165
|
+
{
|
|
1166
|
+
name: 'Descending',
|
|
1167
|
+
iconName: 'lucide:arrowDown',
|
|
1168
|
+
action: async () => {
|
|
1169
|
+
this.addSortAt(key, slot - 1, 'desc');
|
|
1170
|
+
return null;
|
|
1171
|
+
},
|
|
1172
|
+
},
|
|
1173
|
+
],
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
items.push({ divider: true });
|
|
1178
|
+
|
|
1179
|
+
items.push({
|
|
1180
|
+
name: 'Append to sort',
|
|
1181
|
+
iconName: 'lucide:plus',
|
|
1182
|
+
submenu: [
|
|
1183
|
+
{
|
|
1184
|
+
name: 'Ascending',
|
|
1185
|
+
iconName: 'lucide:arrowUp',
|
|
1186
|
+
action: async () => {
|
|
1187
|
+
this.appendSort(key, 'asc');
|
|
1188
|
+
return null;
|
|
1189
|
+
},
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
name: 'Descending',
|
|
1193
|
+
iconName: 'lucide:arrowDown',
|
|
1194
|
+
action: async () => {
|
|
1195
|
+
this.appendSort(key, 'desc');
|
|
1196
|
+
return null;
|
|
1197
|
+
},
|
|
1198
|
+
},
|
|
1199
|
+
],
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
if (existing) {
|
|
1203
|
+
items.push({ divider: true });
|
|
1204
|
+
items.push({
|
|
1205
|
+
name: 'Remove from sort',
|
|
1206
|
+
iconName: 'lucide:minus',
|
|
1207
|
+
action: async () => {
|
|
1208
|
+
this.removeSort(key);
|
|
1209
|
+
return null;
|
|
1210
|
+
},
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (cascadeLen > 0) {
|
|
1215
|
+
if (!existing) items.push({ divider: true });
|
|
1216
|
+
items.push({
|
|
1217
|
+
name: 'Clear all sorts',
|
|
1218
|
+
iconName: 'lucide:trash',
|
|
1219
|
+
action: async () => {
|
|
1220
|
+
this.clearSorts();
|
|
1221
|
+
return null;
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
return items;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// ─── sort: indicator + ARIA ──────────────────────────────────────────
|
|
1230
|
+
|
|
675
1231
|
private getAriaSort(col: Column<T>): 'none' | 'ascending' | 'descending' {
|
|
676
|
-
|
|
677
|
-
|
|
1232
|
+
// ARIA sort reflects only the primary sort key (standard grid pattern).
|
|
1233
|
+
const primary = this.sortBy[0];
|
|
1234
|
+
if (!primary || primary.key !== String(col.key)) return 'none';
|
|
1235
|
+
return primary.dir === 'asc' ? 'ascending' : 'descending';
|
|
678
1236
|
}
|
|
679
1237
|
|
|
680
1238
|
private renderSortIndicator(col: Column<T>) {
|
|
681
|
-
|
|
682
|
-
|
|
1239
|
+
const idx = this.getSortPriority(String(col.key));
|
|
1240
|
+
if (idx < 0) return html``;
|
|
1241
|
+
const desc = this.sortBy[idx];
|
|
1242
|
+
const arrow = desc.dir === 'asc' ? '▲' : '▼';
|
|
1243
|
+
if (this.sortBy.length === 1) {
|
|
1244
|
+
return html`<span class="sortArrow">${arrow}</span>`;
|
|
1245
|
+
}
|
|
1246
|
+
return html`<span class="sortArrow">${arrow}</span><span class="sortBadge">${idx + 1}</span>`;
|
|
683
1247
|
}
|
|
684
1248
|
|
|
685
1249
|
// filtering helpers
|