|
|
|
|
@@ -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') {
|
|
|
|
|
|