feat(dees-table): add file-manager style row selection and JSON copy support
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
Reference in New Issue
Block a user