@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design.estate/dees-catalog",
3
- "version": "3.62.0",
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.62.0',
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
- @property({ type: Boolean, reflect: true, attribute: 'sticky-header' })
188
- accessor stickyHeader: boolean = false;
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
- <tr>
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
- this.selectedDataRow = itemArg;
382
- if (this.selectionMode === 'single') {
383
- const id = this.getRowId(itemArg);
384
- this.selectedIds.clear();
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
- DeesContextmenu.openContextMenuWithOptions(
424
- eventArg,
425
- this.getActionsForType('contextmenu').map((action) => {
426
- const menuItem: plugins.tsclass.website.IMenuItem = {
427
- name: action.name,
428
- iconName: action.iconName as any,
429
- action: async () => {
430
- await action.actionFunc({
431
- item: itemArg,
432
- table: this,
433
- });
434
- return null;
435
- },
436
- };
437
- return menuItem;
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.selectionMode !== 'none'
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') {