From b3f098b41e6ccdd43f5da2970487628809e5e785 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 7 Apr 2026 14:34:19 +0000 Subject: [PATCH] feat(dees-table): add file-manager style row selection and JSON copy support --- changelog.md | 8 + ts_web/00_commitinfo_data.ts | 2 +- .../00group-dataview/dees-table/dees-table.ts | 219 +++++++++++++++--- .../00group-dataview/dees-table/styles.ts | 1 + 4 files changed, 199 insertions(+), 31 deletions(-) diff --git a/changelog.md b/changelog.md index 1109567..e197239 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-07 - 3.64.0 - feat(dees-table) +add file-manager style row selection and JSON copy support + +- adds optional selection checkbox rendering via the show-selection-checkbox property +- supports plain, ctrl/cmd, and shift-click row selection with range selection behavior +- adds Ctrl/Cmd+C and context menu actions to copy selected rows as formatted JSON +- updates row selection styling to prevent native text selection during range selection + ## 2026-04-07 - 3.63.0 - feat(dees-table) add floating header support with fixed-height table mode diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 26641e1..d532ed7 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.63.0', + version: '3.64.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-dataview/dees-table/dees-table.ts b/ts_web/elements/00group-dataview/dees-table/dees-table.ts index bff991a..610ec1a 100644 --- a/ts_web/elements/00group-dataview/dees-table/dees-table.ts +++ b/ts_web/elements/00group-dataview/dees-table/dees-table.ts @@ -184,6 +184,14 @@ export class DeesTable extends DeesElement { accessor columnFilters: Record = {}; @property({ type: Boolean, attribute: 'show-column-filters' }) accessor showColumnFilters: boolean = false; + /** + * When true, the table renders a leftmost checkbox column for click-driven + * (de)selection. Row selection by mouse (plain/shift/ctrl click) is always + * available regardless of this flag. + */ + @property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' }) + accessor showSelectionCheckbox: boolean = false; + /** * When set, the table renders inside a fixed-height scroll container * (`max-height: var(--table-max-height, 360px)`) and the header sticks @@ -209,9 +217,72 @@ export class DeesTable extends DeesElement { accessor selectedIds: Set = new Set(); private _rowIdMap = new WeakMap(); private _rowIdCounter = 0; + /** + * Anchor row id for shift+click range selection. Set whenever the user + * makes a non-range click (plain or cmd/ctrl) so the next shift+click + * can compute a contiguous range from this anchor. + */ + private __selectionAnchorId?: string; constructor() { super(); + // Make the host focusable so it can receive Ctrl/Cmd+C for copy. + if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0'); + this.addEventListener('keydown', this.__handleHostKeydown); + } + + /** + * Ctrl/Cmd+C copies the currently selected rows as a JSON array. Falls + * back to copying the focused-row (`selectedDataRow`) if no multi + * selection exists. No-op if a focused input/textarea would normally + * receive the copy. + */ + private __handleHostKeydown = (eventArg: KeyboardEvent) => { + const isCopy = (eventArg.metaKey || eventArg.ctrlKey) && (eventArg.key === 'c' || eventArg.key === 'C'); + if (!isCopy) return; + // Don't hijack copy when the user is selecting text in an input/textarea. + const path = (eventArg.composedPath?.() || []) as EventTarget[]; + for (const t of path) { + const tag = (t as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if ((t as HTMLElement)?.isContentEditable) return; + } + const rows: T[] = []; + if (this.selectedIds.size > 0) { + for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r); + } else if (this.selectedDataRow) { + rows.push(this.selectedDataRow); + } + if (rows.length === 0) return; + eventArg.preventDefault(); + this.__writeRowsAsJson(rows); + }; + + /** + * Copies the current selection as a JSON array. If `fallbackRow` is given + * and there is no multi-selection, that row is copied instead. Used both + * by the Ctrl/Cmd+C handler and by the default context-menu action. + */ + public copySelectionAsJson(fallbackRow?: T) { + const rows: T[] = []; + if (this.selectedIds.size > 0) { + for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r); + } else if (fallbackRow) { + rows.push(fallbackRow); + } else if (this.selectedDataRow) { + rows.push(this.selectedDataRow); + } + if (rows.length === 0) return; + this.__writeRowsAsJson(rows); + } + + private __writeRowsAsJson(rows: T[]) { + try { + const json = JSON.stringify(rows, null, 2); + navigator.clipboard?.writeText(json); + } catch { + /* ignore — clipboard may be unavailable */ + } } public static styles = tableStyles; @@ -319,15 +390,11 @@ export class DeesTable extends DeesElement { }; return html` { - this.selectedDataRow = itemArg; - if (this.selectionMode === 'single') { - const id = this.getRowId(itemArg); - this.selectedIds.clear(); - this.selectedIds.add(id); - this.emitSelectionChange(); - this.requestUpdate(); - } + @click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)} + @mousedown=${(e: MouseEvent) => { + // Prevent the browser's native shift-click text + // selection so range-select doesn't highlight text. + if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault(); }} @dragenter=${async (eventArg: DragEvent) => { eventArg.preventDefault(); @@ -362,27 +429,51 @@ export class DeesTable extends DeesElement { } }} @contextmenu=${async (eventArg: MouseEvent) => { - DeesContextmenu.openContextMenuWithOptions( - eventArg, - this.getActionsForType('contextmenu').map((action) => { - const menuItem: plugins.tsclass.website.IMenuItem = { - name: action.name, - iconName: action.iconName as any, - action: async () => { - await action.actionFunc({ - item: itemArg, - table: this, - }); - return null; - }, - }; - return menuItem; - }) - ); + // If the right-clicked row isn't part of the + // current selection, treat it like a plain click + // first so the context menu acts on a sensible + // selection (matches file-manager behavior). + if (!this.isRowSelected(itemArg)) { + this.selectedDataRow = itemArg; + this.selectedIds.clear(); + this.selectedIds.add(this.getRowId(itemArg)); + this.__selectionAnchorId = this.getRowId(itemArg); + this.emitSelectionChange(); + this.requestUpdate(); + } + const userItems: plugins.tsclass.website.IMenuItem[] = + this.getActionsForType('contextmenu').map((action) => ({ + name: action.name, + iconName: action.iconName as any, + action: async () => { + await action.actionFunc({ + item: itemArg, + table: this, + }); + return null; + }, + })); + const defaultItems: plugins.tsclass.website.IMenuItem[] = [ + { + name: + this.selectedIds.size > 1 + ? `Copy ${this.selectedIds.size} rows as JSON` + : 'Copy row as JSON', + iconName: 'lucide:Copy' as any, + action: async () => { + this.copySelectionAsJson(itemArg); + return null; + }, + }, + ]; + DeesContextmenu.openContextMenuWithOptions(eventArg, [ + ...userItems, + ...defaultItems, + ]); }} - class="${itemArg === this.selectedDataRow ? 'selected' : ''}" + class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}" > - ${this.selectionMode !== 'none' + ${this.showSelectionCheckbox ? html` extends DeesElement { private renderHeaderRows(effectiveColumns: Column[]): TemplateResult { return html` - ${this.selectionMode !== 'none' + ${this.showSelectionCheckbox ? html` ${this.selectionMode === 'multi' @@ -547,7 +638,7 @@ export class DeesTable extends DeesElement { ${this.showColumnFilters ? html` - ${this.selectionMode !== 'none' + ${this.showSelectionCheckbox ? html`` : html``} ${effectiveColumns @@ -1302,6 +1393,74 @@ export class DeesTable extends DeesElement { this.requestUpdate(); } + /** + * Handles row clicks with file-manager style selection semantics: + * - plain click: select only this row, set anchor + * - cmd/ctrl+click: toggle this row in/out, set anchor + * - shift+click: select the contiguous range from the anchor to this row + * + * Multi-row click selection is always available (`selectionMode === 'none'` + * and `'multi'` both behave this way) so consumers can always copy a set + * of rows. Only `selectionMode === 'single'` restricts to one row. + */ + private handleRowClick(eventArg: MouseEvent, item: T, rowIndex: number, view: T[]) { + const id = this.getRowId(item); + + if (this.selectionMode === 'single') { + this.selectedDataRow = item; + this.selectedIds.clear(); + this.selectedIds.add(id); + this.__selectionAnchorId = id; + this.emitSelectionChange(); + this.requestUpdate(); + return; + } + + // multi + const isToggle = eventArg.metaKey || eventArg.ctrlKey; + const isRange = eventArg.shiftKey; + + if (isRange && this.__selectionAnchorId !== undefined) { + // Clear any text selection the browser may have created. + window.getSelection?.()?.removeAllRanges(); + const anchorIdx = view.findIndex((r) => this.getRowId(r) === this.__selectionAnchorId); + if (anchorIdx >= 0) { + const [a, b] = anchorIdx <= rowIndex ? [anchorIdx, rowIndex] : [rowIndex, anchorIdx]; + this.selectedIds.clear(); + for (let i = a; i <= b; i++) this.selectedIds.add(this.getRowId(view[i])); + } else { + // Anchor no longer in view (filter changed, etc.) — fall back to single select. + this.selectedIds.clear(); + this.selectedIds.add(id); + this.__selectionAnchorId = id; + } + this.selectedDataRow = item; + } else if (isToggle) { + const wasSelected = this.selectedIds.has(id); + if (wasSelected) { + this.selectedIds.delete(id); + // If we just deselected the focused row, move focus to another + // selected row (or clear it) so the highlight goes away. + if (this.selectedDataRow === item) { + const remaining = view.find((r) => this.selectedIds.has(this.getRowId(r))); + this.selectedDataRow = remaining as T; + } + } else { + this.selectedIds.add(id); + this.selectedDataRow = item; + } + this.__selectionAnchorId = id; + } else { + this.selectedDataRow = item; + this.selectedIds.clear(); + this.selectedIds.add(id); + this.__selectionAnchorId = id; + } + + this.emitSelectionChange(); + this.requestUpdate(); + } + private setRowSelected(row: T, checked: boolean) { const id = this.getRowId(row); if (this.selectionMode === 'single') { diff --git a/ts_web/elements/00group-dataview/dees-table/styles.ts b/ts_web/elements/00group-dataview/dees-table/styles.ts index 76cc048..d222c4c 100644 --- a/ts_web/elements/00group-dataview/dees-table/styles.ts +++ b/ts_web/elements/00group-dataview/dees-table/styles.ts @@ -196,6 +196,7 @@ export const tableStyles: CSSResult[] = [ tbody tr { transition: background-color 0.15s ease; position: relative; + user-select: none; } /* Default horizontal lines (bottom border only) */