842 lines
29 KiB
TypeScript
842 lines
29 KiB
TypeScript
import * as plugins from '../00plugins.js';
|
|
import { demoFunc } from './dees-table.demo.js';
|
|
import { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element';
|
|
|
|
import { DeesContextmenu } from '../dees-contextmenu.js';
|
|
import * as domtools from '@design.estate/dees-domtools';
|
|
import { type TIconKey } from '../dees-icon.js';
|
|
import { tableStyles } from './styles.js';
|
|
import type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
|
import {
|
|
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
|
|
computeEffectiveColumns as computeEffectiveColumnsFn,
|
|
getCellValue as getCellValueFn,
|
|
getViewData as getViewDataFn,
|
|
} from './data.js';
|
|
import { compileLucenePredicate } from './lucene.js';
|
|
|
|
export type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'dees-table': DeesTable<any>;
|
|
}
|
|
}
|
|
|
|
// interfaces moved to ./types.ts and re-exported above
|
|
|
|
// the table implementation
|
|
@customElement('dees-table')
|
|
export class DeesTable<T> extends DeesElement {
|
|
public static demo = demoFunc;
|
|
|
|
// INSTANCE
|
|
@property({
|
|
type: String,
|
|
})
|
|
public heading1: string = 'heading 1';
|
|
|
|
@property({
|
|
type: String,
|
|
})
|
|
public heading2: string = 'heading 2';
|
|
|
|
@property({
|
|
type: Array,
|
|
})
|
|
public data: T[] = [];
|
|
|
|
// dees-form compatibility -----------------------------------------
|
|
@property({
|
|
type: String,
|
|
})
|
|
public key: string;
|
|
|
|
@property({
|
|
type: String,
|
|
})
|
|
public label: string;
|
|
|
|
@property({
|
|
type: Boolean,
|
|
})
|
|
public disabled: boolean = false;
|
|
|
|
@property({
|
|
type: Boolean,
|
|
})
|
|
public required: boolean = false;
|
|
|
|
get value() {
|
|
return this.data;
|
|
}
|
|
set value(_valueArg) {}
|
|
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesTable<T>>();
|
|
// end dees-form compatibility -----------------------------------------
|
|
|
|
/**
|
|
* What does a row of data represent?
|
|
*/
|
|
@property({
|
|
type: String,
|
|
reflect: true,
|
|
})
|
|
public dataName: string;
|
|
|
|
|
|
@property({
|
|
type: Boolean,
|
|
})
|
|
searchable: boolean = true;
|
|
|
|
@property({
|
|
type: Array,
|
|
})
|
|
public dataActions: ITableAction<T>[] = [];
|
|
|
|
// schema-first columns API
|
|
@property({ attribute: false })
|
|
public columns: Column<T>[] = [];
|
|
|
|
/**
|
|
* Stable row identity for selection and updates. If provided as a function,
|
|
* it is only usable as a property (not via attribute).
|
|
*/
|
|
@property({ attribute: false })
|
|
public rowKey?: keyof T | ((row: T) => string);
|
|
|
|
/**
|
|
* When true and columns are provided, merge any missing columns discovered
|
|
* via displayFunction into the effective schema.
|
|
*/
|
|
@property({ type: Boolean })
|
|
public augmentFromDisplayFunction: boolean = false;
|
|
|
|
@property({
|
|
attribute: false,
|
|
})
|
|
public displayFunction: TDisplayFunction = (itemArg: T) => itemArg as any;
|
|
|
|
@property({
|
|
attribute: false,
|
|
})
|
|
public reverseDisplayFunction: (itemArg: any) => T = (itemArg: any) => itemArg as T;
|
|
|
|
@property({
|
|
type: Object,
|
|
})
|
|
public selectedDataRow: T;
|
|
|
|
@property({
|
|
type: Array,
|
|
})
|
|
public editableFields: string[] = [];
|
|
|
|
@property({
|
|
type: Boolean,
|
|
reflect: true,
|
|
attribute: 'show-vertical-lines'
|
|
})
|
|
public showVerticalLines: boolean = false;
|
|
|
|
@property({
|
|
type: Boolean,
|
|
reflect: true,
|
|
attribute: 'show-horizontal-lines'
|
|
})
|
|
public showHorizontalLines: boolean = false;
|
|
|
|
@property({
|
|
type: Boolean,
|
|
reflect: true,
|
|
attribute: 'show-grid'
|
|
})
|
|
public showGrid: boolean = true;
|
|
|
|
public files: File[] = [];
|
|
public fileWeakMap = new WeakMap();
|
|
|
|
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
|
|
|
|
// simple client-side sorting (Phase 1)
|
|
@property({ attribute: false })
|
|
private sortKey?: string;
|
|
@property({ attribute: false })
|
|
private sortDir: 'asc' | 'desc' | null = null;
|
|
|
|
// simple client-side filtering (Phase 1)
|
|
@property({ type: String })
|
|
public filterText: string = '';
|
|
// per-column quick filters
|
|
@property({ attribute: false })
|
|
public columnFilters: Record<string, string> = {};
|
|
@property({ type: Boolean, attribute: 'show-column-filters' })
|
|
public showColumnFilters: boolean = false;
|
|
@property({ type: Boolean, reflect: true, attribute: 'sticky-header' })
|
|
public stickyHeader: boolean = false;
|
|
|
|
// search row state
|
|
@property({ type: String })
|
|
public searchMode: 'table' | 'data' | 'server' = 'table';
|
|
private __searchTextSub?: { unsubscribe?: () => void };
|
|
private __searchModeSub?: { unsubscribe?: () => void };
|
|
|
|
// selection (Phase 1)
|
|
@property({ type: String })
|
|
public selectionMode: 'none' | 'single' | 'multi' = 'none';
|
|
@property({ attribute: false })
|
|
private selectedIds: Set<string> = new Set();
|
|
private _rowIdMap = new WeakMap<object, string>();
|
|
private _rowIdCounter = 0;
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
public static styles = tableStyles;
|
|
|
|
public render(): TemplateResult {
|
|
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
|
|
const effectiveColumns: Column<T>[] = usingColumns
|
|
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
|
|
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
|
|
|
|
const lucenePred = compileLucenePredicate<T>(
|
|
this.filterText,
|
|
this.searchMode === 'data' ? 'data' : 'table',
|
|
effectiveColumns
|
|
);
|
|
|
|
const viewData = getViewDataFn(
|
|
this.data,
|
|
effectiveColumns,
|
|
this.sortKey,
|
|
this.sortDir,
|
|
this.filterText,
|
|
this.columnFilters,
|
|
this.searchMode === 'data' ? 'data' : 'table',
|
|
lucenePred || undefined
|
|
);
|
|
(this as any)._lastViewData = viewData;
|
|
return html`
|
|
<div class="mainbox">
|
|
<!-- the heading part -->
|
|
<div class="header">
|
|
<div class="headingContainer">
|
|
<div class="heading heading1">${this.label || this.heading1}</div>
|
|
<div class="heading heading2">${this.heading2}</div>
|
|
</div>
|
|
<div class="headerActions">
|
|
${directives.resolveExec(async () => {
|
|
const resultArray: TemplateResult[] = [];
|
|
for (const action of this.dataActions) {
|
|
if (!action.type.includes('header')) continue;
|
|
resultArray.push(
|
|
html`<div
|
|
class="headerAction"
|
|
@click=${() => {
|
|
action.actionFunc({
|
|
item: this.selectedDataRow,
|
|
table: this,
|
|
});
|
|
}}
|
|
>
|
|
${action.iconName
|
|
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
|
|
${action.name}`
|
|
: action.name}
|
|
</div>`
|
|
);
|
|
}
|
|
return resultArray;
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div class="headingSeparation"></div>
|
|
<div class="searchGrid hidden">
|
|
<dees-input-text
|
|
.label=${'lucene syntax search'}
|
|
.description=${`
|
|
You can use the lucene syntax to search for data, e.g.:
|
|
|
|
\`\`\`
|
|
name: "john" AND age: 18
|
|
\`\`\`
|
|
|
|
`}
|
|
></dees-input-text>
|
|
<dees-input-multitoggle
|
|
.label=${'search mode'}
|
|
.options=${['table', 'data', 'server']}
|
|
.selectedOption=${'table'}
|
|
.description=${`
|
|
There are three basic modes:
|
|
|
|
* table: only searches data already in the table
|
|
* data: searches original data, ignoring table transforms
|
|
* server: searches data on the server
|
|
|
|
`}
|
|
></dees-input-multitoggle>
|
|
</div>
|
|
|
|
<!-- the actual table -->
|
|
<style></style>
|
|
${this.data.length > 0
|
|
? html`
|
|
<div class="tableScroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
${this.selectionMode !== 'none'
|
|
? html`
|
|
<th style="width:42px; text-align:center;">
|
|
${this.selectionMode === 'multi'
|
|
? html`
|
|
<dees-input-checkbox
|
|
.value=${this.areAllVisibleSelected()}
|
|
.indeterminate=${this.isVisibleSelectionIndeterminate()}
|
|
@newValue=${(e: CustomEvent<boolean>) => {
|
|
e.stopPropagation();
|
|
this.setSelectVisible(e.detail === true);
|
|
}}
|
|
></dees-input-checkbox>
|
|
`
|
|
: html``}
|
|
</th>
|
|
`
|
|
: html``}
|
|
${effectiveColumns
|
|
.filter((c) => !c.hidden)
|
|
.map((col) => {
|
|
const isSortable = !!col.sortable;
|
|
const ariaSort = this.getAriaSort(col);
|
|
return html`
|
|
<th
|
|
role="columnheader"
|
|
aria-sort=${ariaSort}
|
|
style="${isSortable ? 'cursor: pointer;' : ''}"
|
|
@click=${() => (isSortable ? this.toggleSort(col) : null)}
|
|
>
|
|
${col.header ?? (col.key as any)}
|
|
${this.renderSortIndicator(col)}
|
|
</th>`;
|
|
})}
|
|
${(() => {
|
|
if (this.dataActions && this.dataActions.length > 0) {
|
|
return html` <th>Actions</th> `;
|
|
}
|
|
})()}
|
|
</tr>
|
|
${this.showColumnFilters
|
|
? html`<tr class="filtersRow">
|
|
${this.selectionMode !== 'none'
|
|
? html`<th style="width:42px;"></th>`
|
|
: html``}
|
|
${effectiveColumns
|
|
.filter((c) => !c.hidden)
|
|
.map((col) => {
|
|
const key = String(col.key);
|
|
if (col.filterable === false) return html`<th></th>`;
|
|
return html`<th>
|
|
<input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
|
|
@input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
|
|
</th>`;
|
|
})}
|
|
${(() => {
|
|
if (this.dataActions && this.dataActions.length > 0) {
|
|
return html` <th></th> `;
|
|
}
|
|
})()}
|
|
</tr>`
|
|
: html``}
|
|
</thead>
|
|
<tbody>
|
|
${viewData.map((itemArg, rowIndex) => {
|
|
const getTr = (elementArg: HTMLElement): HTMLElement => {
|
|
if (elementArg.tagName === 'TR') {
|
|
return elementArg;
|
|
} else {
|
|
return getTr(elementArg.parentElement);
|
|
}
|
|
};
|
|
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();
|
|
}
|
|
}}
|
|
@dragenter=${async (eventArg: DragEvent) => {
|
|
eventArg.preventDefault();
|
|
eventArg.stopPropagation();
|
|
const realTarget = getTr(eventArg.target as HTMLElement);
|
|
setTimeout(() => {
|
|
realTarget.classList.add('hasAttachment');
|
|
}, 0);
|
|
}}
|
|
@dragleave=${async (eventArg: DragEvent) => {
|
|
eventArg.preventDefault();
|
|
eventArg.stopPropagation();
|
|
const realTarget = getTr(eventArg.target as HTMLElement);
|
|
realTarget.classList.remove('hasAttachment');
|
|
}}
|
|
@dragover=${async (eventArg: DragEvent) => {
|
|
eventArg.preventDefault();
|
|
}}
|
|
@drop=${async (eventArg: DragEvent) => {
|
|
eventArg.preventDefault();
|
|
const newFiles = [];
|
|
for (const file of Array.from(eventArg.dataTransfer.files)) {
|
|
this.files.push(file);
|
|
newFiles.push(file);
|
|
this.requestUpdate();
|
|
}
|
|
const result: File[] = this.fileWeakMap.get(itemArg as object);
|
|
if (!result) {
|
|
this.fileWeakMap.set(itemArg as object, newFiles);
|
|
} else {
|
|
result.push(...newFiles);
|
|
}
|
|
}}
|
|
@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;
|
|
})
|
|
);
|
|
}}
|
|
class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
|
|
>
|
|
${this.selectionMode !== 'none'
|
|
? html`<td style="width:42px; text-align:center;">
|
|
<dees-input-checkbox
|
|
.value=${this.isRowSelected(itemArg)}
|
|
@newValue=${(e: CustomEvent<boolean>) => {
|
|
e.stopPropagation();
|
|
this.setRowSelected(itemArg, e.detail === true);
|
|
}}
|
|
></dees-input-checkbox>
|
|
</td>`
|
|
: html``}
|
|
${effectiveColumns
|
|
.filter((c) => !c.hidden)
|
|
.map((col, colIndex) => {
|
|
const value = getCellValueFn(itemArg, col, this.displayFunction);
|
|
const content = col.renderer
|
|
? col.renderer(value, itemArg, { rowIndex, colIndex, column: col })
|
|
: value;
|
|
const editKey = String(col.key);
|
|
return html`
|
|
<td
|
|
@dblclick=${(e: Event) => {
|
|
const dblAction = this.dataActions.find((actionArg) =>
|
|
actionArg.type.includes('doubleClick')
|
|
);
|
|
if (this.editableFields.includes(editKey)) {
|
|
this.handleCellEditing(e, itemArg, editKey);
|
|
} else if (dblAction) {
|
|
dblAction.actionFunc({ item: itemArg, table: this });
|
|
}
|
|
}}
|
|
>
|
|
<div class="innerCellContainer">${content}</div>
|
|
</td>
|
|
`;
|
|
})}
|
|
${(() => {
|
|
if (this.dataActions && this.dataActions.length > 0) {
|
|
return html`
|
|
<td>
|
|
<div class="actionsContainer">
|
|
${this.getActionsForType('inRow').map(
|
|
(actionArg) => html`
|
|
<div
|
|
class="action"
|
|
@click=${() =>
|
|
actionArg.actionFunc({
|
|
item: itemArg,
|
|
table: this,
|
|
})}
|
|
>
|
|
${actionArg.iconName
|
|
? html` <dees-icon .icon=${actionArg.iconName}></dees-icon> `
|
|
: actionArg.name}
|
|
</div>
|
|
`
|
|
)}
|
|
</div>
|
|
</td>
|
|
`;
|
|
}
|
|
})()}
|
|
</tr>`;
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`
|
|
: html` <div class="noDataSet">No data set!</div> `}
|
|
<div class="footer">
|
|
<div class="tableStatistics">
|
|
${this.data.length} ${this.dataName || 'data rows'} (total) |
|
|
${this.selectedDataRow ? '# ' + `${this.data.indexOf(this.selectedDataRow) + 1}` : `No`}
|
|
selected
|
|
</div>
|
|
<div class="footerActions">
|
|
${directives.resolveExec(async () => {
|
|
const resultArray: TemplateResult[] = [];
|
|
for (const action of this.dataActions) {
|
|
if (!action.type.includes('footer')) continue;
|
|
resultArray.push(
|
|
html`<div
|
|
class="footerAction"
|
|
@click=${() => {
|
|
action.actionFunc({
|
|
item: this.selectedDataRow,
|
|
table: this,
|
|
});
|
|
}}
|
|
>
|
|
${action.iconName
|
|
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
|
|
${action.name}`
|
|
: action.name}
|
|
</div>`
|
|
);
|
|
}
|
|
return resultArray;
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
public async firstUpdated() {
|
|
|
|
}
|
|
|
|
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
|
super.updated(changedProperties);
|
|
this.determineColumnWidths();
|
|
if (this.searchable) {
|
|
const existing = this.dataActions.find((actionArg) => actionArg.type.includes('header') && actionArg.name === 'Search');
|
|
if (!existing) {
|
|
this.dataActions.unshift({
|
|
name: 'Search',
|
|
iconName: 'magnifyingGlass',
|
|
type: ['header'],
|
|
actionFunc: async () => {
|
|
console.log('open search');
|
|
const searchGrid = this.shadowRoot.querySelector('.searchGrid');
|
|
searchGrid.classList.toggle('hidden');
|
|
}
|
|
});
|
|
console.log(this.dataActions);
|
|
this.requestUpdate();
|
|
};
|
|
// wire search inputs
|
|
this.wireSearchInputs();
|
|
}
|
|
}
|
|
|
|
private __debounceTimer?: any;
|
|
private debounceRun(fn: () => void, ms = 200) {
|
|
if (this.__debounceTimer) clearTimeout(this.__debounceTimer);
|
|
this.__debounceTimer = setTimeout(fn, ms);
|
|
}
|
|
|
|
private wireSearchInputs() {
|
|
const searchTextEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-text');
|
|
const searchModeEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-multitoggle');
|
|
if (searchTextEl && !this.__searchTextSub) {
|
|
this.__searchTextSub = searchTextEl.changeSubject.subscribe((el: any) => {
|
|
const val: string = el?.value ?? '';
|
|
this.debounceRun(() => {
|
|
if (this.searchMode === 'server') {
|
|
this.dispatchEvent(
|
|
new CustomEvent('searchRequest', {
|
|
detail: { query: val, mode: 'server' },
|
|
bubbles: true,
|
|
})
|
|
);
|
|
} else {
|
|
this.setFilterText(val);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
if (searchModeEl && !this.__searchModeSub) {
|
|
this.__searchModeSub = searchModeEl.changeSubject.subscribe((el: any) => {
|
|
const mode: string = el?.selectedOption || el?.value || 'table';
|
|
if (mode === 'table' || mode === 'data' || mode === 'server') {
|
|
this.searchMode = mode as any;
|
|
// When switching modes, re-apply current text input
|
|
const val: string = searchTextEl?.value ?? '';
|
|
this.debounceRun(() => {
|
|
if (this.searchMode === 'server') {
|
|
this.dispatchEvent(new CustomEvent('searchRequest', { detail: { query: val, mode: 'server' }, bubbles: true }));
|
|
} else {
|
|
this.setFilterText(val);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
public async determineColumnWidths() {
|
|
const domtools = await this.domtoolsPromise;
|
|
await domtools.convenience.smartdelay.delayFor(0);
|
|
// Get the table element
|
|
const table = this.shadowRoot.querySelector('table');
|
|
if (!table) return;
|
|
|
|
// Get the first row's cells to measure the widths
|
|
const cells = table.rows[0].cells;
|
|
|
|
const handleColumnByIndex = async (i: number, waitForRenderArg: boolean = false) => {
|
|
const done = plugins.smartpromise.defer();
|
|
const cell = cells[i];
|
|
|
|
// Get computed width
|
|
const width = window.getComputedStyle(cell).width;
|
|
if (cell.textContent.includes('Actions')) {
|
|
const neededWidth =
|
|
this.dataActions.filter((actionArg) => actionArg.type.includes('inRow')).length * 36;
|
|
cell.style.width = `${Math.max(neededWidth, 68)}px`;
|
|
} else {
|
|
cell.style.width = width;
|
|
}
|
|
if (waitForRenderArg) {
|
|
requestAnimationFrame(() => {
|
|
done.resolve();
|
|
});
|
|
await done.promise;
|
|
}
|
|
};
|
|
|
|
if (cells[cells.length - 1].textContent.includes('Actions')) {
|
|
await handleColumnByIndex(cells.length - 1, true);
|
|
}
|
|
|
|
for (let i = 0; i < cells.length; i++) {
|
|
if (cells[i].textContent.includes('Actions')) {
|
|
continue;
|
|
}
|
|
await handleColumnByIndex(i);
|
|
}
|
|
table.style.tableLayout = 'fixed';
|
|
}
|
|
|
|
// compute helpers moved to ./data.ts
|
|
|
|
private toggleSort(col: Column<T>) {
|
|
const key = String(col.key);
|
|
if (this.sortKey !== key) {
|
|
this.sortKey = key;
|
|
this.sortDir = 'asc';
|
|
} else {
|
|
if (this.sortDir === 'asc') this.sortDir = 'desc';
|
|
else if (this.sortDir === 'desc') {
|
|
this.sortDir = null;
|
|
this.sortKey = undefined;
|
|
} else this.sortDir = 'asc';
|
|
}
|
|
this.dispatchEvent(
|
|
new CustomEvent('sortChange', {
|
|
detail: { key: this.sortKey, dir: this.sortDir },
|
|
bubbles: true,
|
|
})
|
|
);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
private getAriaSort(col: Column<T>): 'none' | 'ascending' | 'descending' {
|
|
if (String(col.key) !== this.sortKey || !this.sortDir) return 'none';
|
|
return this.sortDir === 'asc' ? 'ascending' : 'descending';
|
|
}
|
|
|
|
private renderSortIndicator(col: Column<T>) {
|
|
if (String(col.key) !== this.sortKey || !this.sortDir) return html``;
|
|
return html`<span style="margin-left:6px; opacity:0.7;">${this.sortDir === 'asc' ? '▲' : '▼'}</span>`;
|
|
}
|
|
|
|
// filtering helpers
|
|
public setFilterText(value: string) {
|
|
const prev = this.filterText;
|
|
this.filterText = value ?? '';
|
|
if (prev !== this.filterText) {
|
|
this.dispatchEvent(
|
|
new CustomEvent('filterChange', {
|
|
detail: { text: this.filterText, columns: { ...this.columnFilters } },
|
|
bubbles: true,
|
|
})
|
|
);
|
|
this.requestUpdate();
|
|
}
|
|
}
|
|
|
|
public setColumnFilter(key: string, value: string) {
|
|
this.columnFilters = { ...this.columnFilters, [key]: value };
|
|
this.dispatchEvent(
|
|
new CustomEvent('filterChange', {
|
|
detail: { text: this.filterText, columns: { ...this.columnFilters } },
|
|
bubbles: true,
|
|
})
|
|
);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
// selection helpers
|
|
private getRowId(row: T): string {
|
|
if (this.rowKey) {
|
|
if (typeof this.rowKey === 'function') return this.rowKey(row);
|
|
return String((row as any)[this.rowKey]);
|
|
}
|
|
const key = row as any as object;
|
|
if (!this._rowIdMap.has(key)) {
|
|
this._rowIdMap.set(key, String(++this._rowIdCounter));
|
|
}
|
|
return this._rowIdMap.get(key);
|
|
}
|
|
|
|
private isRowSelected(row: T): boolean {
|
|
return this.selectedIds.has(this.getRowId(row));
|
|
}
|
|
|
|
private toggleRowSelected(row: T) {
|
|
const id = this.getRowId(row);
|
|
if (this.selectionMode === 'single') {
|
|
this.selectedIds.clear();
|
|
this.selectedIds.add(id);
|
|
} else if (this.selectionMode === 'multi') {
|
|
if (this.selectedIds.has(id)) this.selectedIds.delete(id);
|
|
else this.selectedIds.add(id);
|
|
}
|
|
this.emitSelectionChange();
|
|
this.requestUpdate();
|
|
}
|
|
|
|
private setRowSelected(row: T, checked: boolean) {
|
|
const id = this.getRowId(row);
|
|
if (this.selectionMode === 'single') {
|
|
this.selectedIds.clear();
|
|
if (checked) this.selectedIds.add(id);
|
|
} else if (this.selectionMode === 'multi') {
|
|
if (checked) this.selectedIds.add(id);
|
|
else this.selectedIds.delete(id);
|
|
}
|
|
this.emitSelectionChange();
|
|
this.requestUpdate();
|
|
}
|
|
|
|
private areAllVisibleSelected(): boolean {
|
|
const view: T[] = (this as any)._lastViewData || [];
|
|
if (view.length === 0) return false;
|
|
for (const r of view) {
|
|
if (!this.selectedIds.has(this.getRowId(r))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private isVisibleSelectionIndeterminate(): boolean {
|
|
const view: T[] = (this as any)._lastViewData || [];
|
|
if (view.length === 0) return false;
|
|
let count = 0;
|
|
for (const r of view) {
|
|
if (this.selectedIds.has(this.getRowId(r))) count++;
|
|
}
|
|
return count > 0 && count < view.length;
|
|
}
|
|
|
|
private setSelectVisible(checked: boolean) {
|
|
const view: T[] = (this as any)._lastViewData || [];
|
|
if (checked) {
|
|
for (const r of view) this.selectedIds.add(this.getRowId(r));
|
|
} else {
|
|
for (const r of view) this.selectedIds.delete(this.getRowId(r));
|
|
}
|
|
this.emitSelectionChange();
|
|
this.requestUpdate();
|
|
}
|
|
|
|
private emitSelectionChange() {
|
|
const selectedIds = Array.from(this.selectedIds);
|
|
const selectedRows = this.data.filter((r) => this.selectedIds.has(this.getRowId(r)));
|
|
this.dispatchEvent(
|
|
new CustomEvent('selectionChange', {
|
|
detail: { selectedIds, selectedRows },
|
|
bubbles: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
getActionsForType(typeArg: ITableAction['type'][0]) {
|
|
const actions: ITableAction[] = [];
|
|
for (const action of this.dataActions) {
|
|
if (!action.type.includes(typeArg)) continue;
|
|
actions.push(action);
|
|
}
|
|
return actions;
|
|
}
|
|
|
|
async handleCellEditing(event: Event, itemArg: T, key: string) {
|
|
await this.domtoolsPromise;
|
|
const target = event.target as HTMLElement;
|
|
const originalColor = target.style.color;
|
|
target.style.color = 'transparent';
|
|
const transformedItem = this.displayFunction(itemArg);
|
|
const initialValue = ((transformedItem as any)[key] ?? (itemArg as any)[key] ?? '') as string;
|
|
// Create an input element
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.value = initialValue;
|
|
|
|
const blurInput = async (blurArg = true, saveArg = false) => {
|
|
if (blurArg) {
|
|
input.blur();
|
|
}
|
|
if (saveArg) {
|
|
itemArg[key] = input.value as any; // Convert string to T (you might need better type casting depending on your data structure)
|
|
this.changeSubject.next(this);
|
|
}
|
|
input.remove();
|
|
target.style.color = originalColor;
|
|
this.requestUpdate();
|
|
};
|
|
|
|
// When the input loses focus or the Enter key is pressed, update the data
|
|
input.addEventListener('blur', () => {
|
|
blurInput(false, false);
|
|
});
|
|
input.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
blurInput(true, true); // This will trigger the blur event handler above
|
|
}
|
|
});
|
|
|
|
// Replace the cell's content with the input
|
|
target.appendChild(input);
|
|
input.focus();
|
|
}
|
|
}
|