feat(components): add large set of new UI components and demos, reorganize groups, and bump a few dependencies
This commit is contained in:
843
ts_web/elements/00group-dataview/dees-table/dees-table.ts
Normal file
843
ts_web/elements/00group-dataview/dees-table/dees-table.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
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 '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { type TIconKey } from '../../00group-utility/dees-icon/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';
|
||||
import { themeDefaultStyles } from '../../00theme.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;
|
||||
public static demoGroups = ['Data View'];
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
accessor heading1: string = 'heading 1';
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
accessor heading2: string = 'heading 2';
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
accessor data: T[] = [];
|
||||
|
||||
// dees-form compatibility -----------------------------------------
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
accessor key: string;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
accessor label: string;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
accessor disabled: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
accessor 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,
|
||||
})
|
||||
accessor dataName: string;
|
||||
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
accessor searchable: boolean = true;
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
accessor dataActions: ITableAction<T>[] = [];
|
||||
|
||||
// schema-first columns API
|
||||
@property({ attribute: false })
|
||||
accessor 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 })
|
||||
accessor rowKey: keyof T | ((row: T) => string) | undefined = undefined;
|
||||
|
||||
/**
|
||||
* When true and columns are provided, merge any missing columns discovered
|
||||
* via displayFunction into the effective schema.
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
accessor augmentFromDisplayFunction: boolean = false;
|
||||
|
||||
@property({
|
||||
attribute: false,
|
||||
})
|
||||
accessor displayFunction: TDisplayFunction = (itemArg: T) => itemArg as any;
|
||||
|
||||
@property({
|
||||
attribute: false,
|
||||
})
|
||||
accessor reverseDisplayFunction: (itemArg: any) => T = (itemArg: any) => itemArg as T;
|
||||
|
||||
@property({
|
||||
type: Object,
|
||||
})
|
||||
accessor selectedDataRow: T;
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
accessor editableFields: string[] = [];
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'show-vertical-lines'
|
||||
})
|
||||
accessor showVerticalLines: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'show-horizontal-lines'
|
||||
})
|
||||
accessor showHorizontalLines: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'show-grid'
|
||||
})
|
||||
accessor 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 })
|
||||
accessor sortKey: string | undefined = undefined;
|
||||
@property({ attribute: false })
|
||||
accessor sortDir: 'asc' | 'desc' | null = null;
|
||||
|
||||
// simple client-side filtering (Phase 1)
|
||||
@property({ type: String })
|
||||
accessor filterText: string = '';
|
||||
// per-column quick filters
|
||||
@property({ attribute: false })
|
||||
accessor columnFilters: Record<string, string> = {};
|
||||
@property({ type: Boolean, attribute: 'show-column-filters' })
|
||||
accessor showColumnFilters: boolean = false;
|
||||
@property({ type: Boolean, reflect: true, attribute: 'sticky-header' })
|
||||
accessor stickyHeader: boolean = false;
|
||||
|
||||
// search row state
|
||||
@property({ type: String })
|
||||
accessor searchMode: 'table' | 'data' | 'server' = 'table';
|
||||
private __searchTextSub?: { unsubscribe?: () => void };
|
||||
private __searchModeSub?: { unsubscribe?: () => void };
|
||||
|
||||
// selection (Phase 1)
|
||||
@property({ type: String })
|
||||
accessor selectionMode: 'none' | 'single' | 'multi' = 'none';
|
||||
@property({ attribute: false })
|
||||
accessor 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 class="actionsCol">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 class="actionsCol">
|
||||
<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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user