feat(dees-table): add file-manager style row selection and JSON copy support
This commit is contained in:
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -184,6 +184,14 @@ export class DeesTable<T> extends DeesElement {
|
||||
accessor columnFilters: Record<string, string> = {};
|
||||
@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<T> extends DeesElement {
|
||||
accessor selectedIds: Set<string> = new Set();
|
||||
private _rowIdMap = new WeakMap<object, string>();
|
||||
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<T> extends DeesElement {
|
||||
};
|
||||
return html`
|
||||
<tr
|
||||
@click=${() => {
|
||||
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<T> 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`<td style="width:42px; text-align:center;">
|
||||
<dees-input-checkbox
|
||||
.value=${this.isRowSelected(itemArg)}
|
||||
@@ -502,7 +593,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult {
|
||||
return html`
|
||||
<tr>
|
||||
${this.selectionMode !== 'none'
|
||||
${this.showSelectionCheckbox
|
||||
? html`
|
||||
<th style="width:42px; text-align:center;">
|
||||
${this.selectionMode === 'multi'
|
||||
@@ -547,7 +638,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
</tr>
|
||||
${this.showColumnFilters
|
||||
? html`<tr class="filtersRow">
|
||||
${this.selectionMode !== 'none'
|
||||
${this.showSelectionCheckbox
|
||||
? html`<th style="width:42px;"></th>`
|
||||
: html``}
|
||||
${effectiveColumns
|
||||
@@ -1302,6 +1393,74 @@ export class DeesTable<T> 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') {
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user