feat(dees-table): add file-manager style row selection and JSON copy support

This commit is contained in:
2026-04-07 14:34:19 +00:00
parent a0d5462ff1
commit b3f098b41e
4 changed files with 199 additions and 31 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # 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) ## 2026-04-07 - 3.63.0 - feat(dees-table)
add floating header support with fixed-height table mode add floating header support with fixed-height table mode

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@design.estate/dees-catalog', 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.' description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
} }

View File

@@ -184,6 +184,14 @@ export class DeesTable<T> extends DeesElement {
accessor columnFilters: Record<string, string> = {}; accessor columnFilters: Record<string, string> = {};
@property({ type: Boolean, attribute: 'show-column-filters' }) @property({ type: Boolean, attribute: 'show-column-filters' })
accessor showColumnFilters: boolean = false; 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 * When set, the table renders inside a fixed-height scroll container
* (`max-height: var(--table-max-height, 360px)`) and the header sticks * (`max-height: var(--table-max-height, 360px)`) and the header sticks
@@ -209,9 +217,72 @@ export class DeesTable<T> extends DeesElement {
accessor selectedIds: Set<string> = new Set(); accessor selectedIds: Set<string> = new Set();
private _rowIdMap = new WeakMap<object, string>(); private _rowIdMap = new WeakMap<object, string>();
private _rowIdCounter = 0; 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() { constructor() {
super(); 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; public static styles = tableStyles;
@@ -319,15 +390,11 @@ export class DeesTable<T> extends DeesElement {
}; };
return html` return html`
<tr <tr
@click=${() => { @click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
this.selectedDataRow = itemArg; @mousedown=${(e: MouseEvent) => {
if (this.selectionMode === 'single') { // Prevent the browser's native shift-click text
const id = this.getRowId(itemArg); // selection so range-select doesn't highlight text.
this.selectedIds.clear(); if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
this.selectedIds.add(id);
this.emitSelectionChange();
this.requestUpdate();
}
}} }}
@dragenter=${async (eventArg: DragEvent) => { @dragenter=${async (eventArg: DragEvent) => {
eventArg.preventDefault(); eventArg.preventDefault();
@@ -362,27 +429,51 @@ export class DeesTable<T> extends DeesElement {
} }
}} }}
@contextmenu=${async (eventArg: MouseEvent) => { @contextmenu=${async (eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions( // If the right-clicked row isn't part of the
eventArg, // current selection, treat it like a plain click
this.getActionsForType('contextmenu').map((action) => { // first so the context menu acts on a sensible
const menuItem: plugins.tsclass.website.IMenuItem = { // selection (matches file-manager behavior).
name: action.name, if (!this.isRowSelected(itemArg)) {
iconName: action.iconName as any, this.selectedDataRow = itemArg;
action: async () => { this.selectedIds.clear();
await action.actionFunc({ this.selectedIds.add(this.getRowId(itemArg));
item: itemArg, this.__selectionAnchorId = this.getRowId(itemArg);
table: this, this.emitSelectionChange();
}); this.requestUpdate();
return null; }
}, const userItems: plugins.tsclass.website.IMenuItem[] =
}; this.getActionsForType('contextmenu').map((action) => ({
return menuItem; 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`<td style="width:42px; text-align:center;"> ? html`<td style="width:42px; text-align:center;">
<dees-input-checkbox <dees-input-checkbox
.value=${this.isRowSelected(itemArg)} .value=${this.isRowSelected(itemArg)}
@@ -502,7 +593,7 @@ export class DeesTable<T> extends DeesElement {
private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult { private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult {
return html` return html`
<tr> <tr>
${this.selectionMode !== 'none' ${this.showSelectionCheckbox
? html` ? html`
<th style="width:42px; text-align:center;"> <th style="width:42px; text-align:center;">
${this.selectionMode === 'multi' ${this.selectionMode === 'multi'
@@ -547,7 +638,7 @@ export class DeesTable<T> extends DeesElement {
</tr> </tr>
${this.showColumnFilters ${this.showColumnFilters
? html`<tr class="filtersRow"> ? html`<tr class="filtersRow">
${this.selectionMode !== 'none' ${this.showSelectionCheckbox
? html`<th style="width:42px;"></th>` ? html`<th style="width:42px;"></th>`
: html``} : html``}
${effectiveColumns ${effectiveColumns
@@ -1302,6 +1393,74 @@ export class DeesTable<T> extends DeesElement {
this.requestUpdate(); 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) { private setRowSelected(row: T, checked: boolean) {
const id = this.getRowId(row); const id = this.getRowId(row);
if (this.selectionMode === 'single') { if (this.selectionMode === 'single') {

View File

@@ -196,6 +196,7 @@ export const tableStyles: CSSResult[] = [
tbody tr { tbody tr {
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
position: relative; position: relative;
user-select: none;
} }
/* Default horizontal lines (bottom border only) */ /* Default horizontal lines (bottom border only) */