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 { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.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, ISortDescriptor, 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'; import '../../00group-layout/dees-tile/dees-tile.js'; export type { Column, ISortDescriptor, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js'; /** Returns the English ordinal label for a 1-based position (e.g. 1 → "1st"). */ function ordinalLabel(n: number): string { const s = ['th', 'st', 'nd', 'rd']; const v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); } declare global { interface HTMLElementTagNameMap { 'dees-table': DeesTable; } } // interfaces moved to ./types.ts and re-exported above // the table implementation @customElement('dees-table') export class DeesTable 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>(); // 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[] = []; // schema-first columns API @property({ attribute: false }) accessor columns: Column[] = []; /** * 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(); /** * Multi-column sort cascade. The first entry is the primary sort key, * subsequent entries are tiebreakers in priority order. */ @property({ attribute: false }) accessor sortBy: ISortDescriptor[] = []; // simple client-side filtering (Phase 1) @property({ type: String }) accessor filterText: string = ''; // per-column quick filters @property({ attribute: false }) accessor columnFilters: Record = {}; @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 = new Set(); private _rowIdMap = new WeakMap(); 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[] = usingColumns ? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data) : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data); const lucenePred = compileLucenePredicate( this.filterText, this.searchMode === 'data' ? 'data' : 'table', effectiveColumns ); const viewData = getViewDataFn( this.data, effectiveColumns, this.sortBy, this.filterText, this.columnFilters, this.searchMode === 'data' ? 'data' : 'table', lucenePred || undefined ); (this as any)._lastViewData = viewData; return html`
${this.label || this.heading1}
${this.heading2}
${directives.resolveExec(async () => { const resultArray: TemplateResult[] = []; for (const action of this.dataActions) { if (!action.type?.includes('header')) continue; resultArray.push( html`
{ action.actionFunc({ item: this.selectedDataRow, table: this, }); }} > ${action.iconName ? html` ${action.name}` : action.name}
` ); } return resultArray; })}
${this.data.length > 0 ? html`
${this.selectionMode !== 'none' ? html` ` : html``} ${effectiveColumns .filter((c) => !c.hidden) .map((col) => { const isSortable = !!col.sortable; const ariaSort = this.getAriaSort(col); return html` `; })} ${(() => { if (this.dataActions && this.dataActions.length > 0) { return html` `; } })()} ${this.showColumnFilters ? html` ${this.selectionMode !== 'none' ? html`` : html``} ${effectiveColumns .filter((c) => !c.hidden) .map((col) => { const key = String(col.key); if (col.filterable === false) return html``; return html``; })} ${(() => { if (this.dataActions && this.dataActions.length > 0) { return html` `; } })()} ` : html``} ${viewData.map((itemArg, rowIndex) => { const getTr = (elementArg: HTMLElement): HTMLElement => { if (elementArg.tagName === 'TR') { return elementArg; } else { return getTr(elementArg.parentElement!); } }; return html` { 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: File[] = []; 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`` : 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` `; })} ${(() => { if (this.dataActions && this.dataActions.length > 0) { return html` `; } })()} `; })}
${this.selectionMode === 'multi' ? html` ) => { e.stopPropagation(); this.setSelectVisible(e.detail === true); }} > ` : html``} isSortable ? this.handleHeaderClick(eventArg, col, effectiveColumns) : null} @contextmenu=${(eventArg: MouseEvent) => isSortable ? this.openHeaderContextMenu(eventArg, col, effectiveColumns) : null} > ${col.header ?? (col.key as any)} ${this.renderSortIndicator(col)} Actions
this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
) => { e.stopPropagation(); this.setRowSelected(itemArg, e.detail === true); }} > { 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 }); } }} >
${content}
${this.getActionsForType('inRow').map( (actionArg) => html`
actionArg.actionFunc({ item: itemArg, table: this, })} > ${actionArg.iconName ? html` ` : actionArg.name}
` )}
` : html`
No data set!
`}
`; } public async firstUpdated() { } public async updated(changedProperties: Map): Promise { 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: 'lucide:Search', 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 // ─── sort: public API ──────────────────────────────────────────────── /** Returns the descriptor for `key` if the column is currently in the cascade. */ public getSortDescriptor(key: string): ISortDescriptor | undefined { return this.sortBy.find((d) => d.key === key); } /** Returns the 0-based priority of `key` in the cascade, or -1 if not present. */ public getSortPriority(key: string): number { return this.sortBy.findIndex((d) => d.key === key); } /** Replaces the cascade with a single sort entry. */ public setSort(key: string, dir: 'asc' | 'desc'): void { this.sortBy = [{ key, dir }]; this.emitSortChange(); this.requestUpdate(); } /** * Inserts (or moves) `key` to a 0-based position in the cascade. If the key is * already present elsewhere, its previous entry is removed before insertion so * a column appears at most once. */ public addSortAt(key: string, position: number, dir: 'asc' | 'desc'): void { const next = this.sortBy.filter((d) => d.key !== key); const clamped = Math.max(0, Math.min(position, next.length)); next.splice(clamped, 0, { key, dir }); this.sortBy = next; this.emitSortChange(); this.requestUpdate(); } /** Appends `key` to the end of the cascade (or moves it there if already present). */ public appendSort(key: string, dir: 'asc' | 'desc'): void { const next = this.sortBy.filter((d) => d.key !== key); next.push({ key, dir }); this.sortBy = next; this.emitSortChange(); this.requestUpdate(); } /** Removes `key` from the cascade. No-op if not present. */ public removeSort(key: string): void { if (!this.sortBy.some((d) => d.key === key)) return; this.sortBy = this.sortBy.filter((d) => d.key !== key); this.emitSortChange(); this.requestUpdate(); } /** Empties the cascade. */ public clearSorts(): void { if (this.sortBy.length === 0) return; this.sortBy = []; this.emitSortChange(); this.requestUpdate(); } private emitSortChange() { this.dispatchEvent( new CustomEvent('sortChange', { detail: { sortBy: this.sortBy.map((d) => ({ ...d })) }, bubbles: true, }) ); } // ─── sort: header interaction handlers ─────────────────────────────── /** * Plain left-click on a sortable header. Cycles `none → asc → desc → none` * collapsing the cascade to a single column. If a multi-column cascade is * active, asks the user to confirm the destructive replacement first. A * Shift+click bypasses the modal and routes through the multi-sort cycle. */ private async handleHeaderClick( eventArg: MouseEvent, col: Column, _effectiveColumns: Column[] ) { if (eventArg.shiftKey) { this.handleHeaderShiftClick(col); return; } const proceed = await this.confirmReplaceCascade(col); if (!proceed) return; this.cycleSingleSort(col); } /** * Cycles a single column through `none → asc → desc → none`, collapsing the * cascade. Used by both plain click and the menu's "Sort Ascending/Descending" * shortcuts (after confirmation). */ private cycleSingleSort(col: Column) { const key = String(col.key); const current = this.sortBy.length === 1 && this.sortBy[0].key === key ? this.sortBy[0].dir : null; if (current === 'asc') this.setSort(key, 'desc'); else if (current === 'desc') this.clearSorts(); else this.setSort(key, 'asc'); } /** * Shift+click cycle on a sortable header. Edits the cascade in place without * destroying other sort keys: append → flip dir → remove. */ private handleHeaderShiftClick(col: Column) { const key = String(col.key); const existing = this.getSortDescriptor(key); if (!existing) { this.appendSort(key, 'asc'); } else if (existing.dir === 'asc') { this.sortBy = this.sortBy.map((d) => (d.key === key ? { key, dir: 'desc' } : d)); this.emitSortChange(); this.requestUpdate(); } else { this.removeSort(key); } } /** * Opens a confirmation modal when the cascade has more than one entry and the * user attempts a destructive single-sort replacement. Resolves to `true` if * the user accepts, `false` if they cancel. If the cascade has 0 or 1 entries * the modal is skipped and we resolve to `true` immediately. */ private confirmReplaceCascade(targetCol: Column): Promise { if (this.sortBy.length <= 1) return Promise.resolve(true); return new Promise((resolve) => { let settled = false; const settle = (result: boolean) => { if (settled) return; settled = true; resolve(result); }; const summary = this.sortBy .map((d, i) => { const c = (this as any)._lookupColumnByKey?.(d.key) as Column | undefined; const label = c?.header ?? d.key; return html`
  • ${i + 1}. ${label} ${d.dir === 'asc' ? '▲' : '▼'}
  • `; }); DeesModal.createAndShow({ heading: 'Replace multi-column sort?', width: 'small', showCloseButton: true, content: html`

    You currently have a ${this.sortBy.length}-column sort active:

      ${summary}

    Continuing will discard the cascade and replace it with a single sort on ${targetCol.header ?? String(targetCol.key)}.

    `, menuOptions: [ { name: 'Cancel', iconName: 'lucide:x', action: async (modal) => { settle(false); await modal!.destroy(); return null; }, }, { name: 'Replace', iconName: 'lucide:check', action: async (modal) => { settle(true); await modal!.destroy(); return null; }, }, ], }); }); } /** * Looks up a column by its string key in the currently effective column set. * Used by the modal helper to render human-friendly labels. */ private _lookupColumnByKey(key: string): Column | undefined { const usingColumns = Array.isArray(this.columns) && this.columns.length > 0; const effective = usingColumns ? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data) : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data); return effective.find((c) => String(c.key) === key); } /** * Opens the header context menu for explicit multi-sort priority control. */ private openHeaderContextMenu( eventArg: MouseEvent, col: Column, effectiveColumns: Column[] ) { const items = this.buildHeaderMenuItems(col, effectiveColumns); DeesContextmenu.openContextMenuWithOptions(eventArg, items as any); } /** * Builds the dynamic context-menu structure for a single column header. */ private buildHeaderMenuItems(col: Column, effectiveColumns: Column[]) { const key = String(col.key); const existing = this.getSortDescriptor(key); const cascadeLen = this.sortBy.length; // Maximum exposed slot: one beyond the current cascade, capped at the // number of sortable columns. If the column is already in the cascade we // never need to grow the slot count. const sortableColumnCount = effectiveColumns.filter((c) => !!c.sortable).length; const maxSlot = Math.min( Math.max(cascadeLen + (existing ? 0 : 1), 1), Math.max(sortableColumnCount, 1) ); const items: any[] = []; // Single-sort shortcuts. These are destructive when a cascade is active, so // they go through confirmReplaceCascade just like a plain click. items.push({ name: 'Sort Ascending', iconName: cascadeLen === 1 && existing?.dir === 'asc' ? 'lucide:check' : 'lucide:arrowUp', action: async () => { if (await this.confirmReplaceCascade(col)) this.setSort(key, 'asc'); return null; }, }); items.push({ name: 'Sort Descending', iconName: cascadeLen === 1 && existing?.dir === 'desc' ? 'lucide:check' : 'lucide:arrowDown', action: async () => { if (await this.confirmReplaceCascade(col)) this.setSort(key, 'desc'); return null; }, }); items.push({ divider: true }); // Priority slot entries (1..maxSlot). Each slot has an asc/desc submenu. for (let slot = 1; slot <= maxSlot; slot++) { const ordinal = ordinalLabel(slot); const isCurrentSlot = existing && this.getSortPriority(key) === slot - 1; items.push({ name: `Set as ${ordinal} sort`, iconName: isCurrentSlot ? 'lucide:check' : 'lucide:listOrdered', submenu: [ { name: 'Ascending', iconName: 'lucide:arrowUp', action: async () => { this.addSortAt(key, slot - 1, 'asc'); return null; }, }, { name: 'Descending', iconName: 'lucide:arrowDown', action: async () => { this.addSortAt(key, slot - 1, 'desc'); return null; }, }, ], }); } items.push({ divider: true }); items.push({ name: 'Append to sort', iconName: 'lucide:plus', submenu: [ { name: 'Ascending', iconName: 'lucide:arrowUp', action: async () => { this.appendSort(key, 'asc'); return null; }, }, { name: 'Descending', iconName: 'lucide:arrowDown', action: async () => { this.appendSort(key, 'desc'); return null; }, }, ], }); if (existing) { items.push({ divider: true }); items.push({ name: 'Remove from sort', iconName: 'lucide:minus', action: async () => { this.removeSort(key); return null; }, }); } if (cascadeLen > 0) { if (!existing) items.push({ divider: true }); items.push({ name: 'Clear all sorts', iconName: 'lucide:trash', action: async () => { this.clearSorts(); return null; }, }); } return items; } // ─── sort: indicator + ARIA ────────────────────────────────────────── private getAriaSort(col: Column): 'none' | 'ascending' | 'descending' { // ARIA sort reflects only the primary sort key (standard grid pattern). const primary = this.sortBy[0]; if (!primary || primary.key !== String(col.key)) return 'none'; return primary.dir === 'asc' ? 'ascending' : 'descending'; } private renderSortIndicator(col: Column) { const idx = this.getSortPriority(String(col.key)); if (idx < 0) return html``; const desc = this.sortBy[idx]; const arrow = desc.dir === 'asc' ? '▲' : '▼'; if (this.sortBy.length === 1) { return html`${arrow}`; } return html`${arrow}${idx + 1}`; } // 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 as any)[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(); } }