Files
dees-catalog/ts_web/elements/dees-table/dees-table.ts
Juergen Kunz 3f3677ebaa feat: implement DeesTable component with schema-first columns API, data actions, and customizable styles
- Added DeesTable class extending DeesElement
- Introduced properties for headings, data, actions, and columns
- Implemented rendering logic for table headers, rows, and cells
- Added support for sorting, searching, and context menus
- Included customizable styles for table layout and appearance
- Integrated editable fields and drag-and-drop file handling
- Enhanced accessibility with ARIA attributes for sorting
2025-09-14 19:57:50 +00:00

1043 lines
32 KiB
TypeScript

import * as plugins from '../00plugins.js';
import { demoFunc } from './dees-table.demo.js';
import { cssGeistFontFamily } from '../00fonts.js';
import {
customElement,
html,
DeesElement,
property,
type TemplateResult,
cssManager,
css,
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';
declare global {
interface HTMLElementTagNameMap {
'dees-table': DeesTable<any>;
}
}
// interfaces
export interface ITableAction<T = any> {
name: string;
iconName: TIconKey;
/**
* the table behaviour to use for this action
* e.g. upload: allows to upload files to the table
*/
useTableBehaviour?: 'upload' | 'cancelUpload' | 'none';
/**
* the type of the action
*/
type: (
| 'inRow'
| 'contextmenu'
| 'doubleClick'
| 'footer'
| 'header'
| 'preview'
| 'keyCombination'
)[];
/**
* allows to check if the action is relevant for the given item
* @param itemArg
* @returns
*/
actionRelevancyCheckFunc?: (itemArg: T) => boolean;
/**
* the actual action function implementation
* @param itemArg
* @returns
*/
actionFunc: (actionDataArg: ITableActionDataArg<T>) => Promise<any>;
}
export interface ITableActionDataArg<T> {
item: T;
table: DeesTable<T>;
}
// schema-first columns API (Phase 1)
export interface Column<T = any> {
/** key in the raw item or a computed key name */
key: keyof T | string;
/** header label or template; defaults to key */
header?: string | TemplateResult;
/** compute the cell value when not reading directly by key */
value?: (row: T) => any;
/** optional cell renderer */
renderer?: (value: any, row: T, ctx: { rowIndex: number; colIndex: number; column: Column<T> }) => TemplateResult | string;
/** reserved for future phases; present to sketch intent */
sortable?: boolean;
hidden?: boolean;
}
export type TDisplayFunction<T = any> = (itemArg: T) => object;
// 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;
constructor() {
super();
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
}
.mainbox {
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
font-family: ${cssGeistFontFamily};
font-weight: 400;
font-size: 14px;
display: block;
width: 100%;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 8px;
overflow: hidden;
cursor: default;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
min-height: 64px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.headingContainer {
flex: 1;
}
.heading {
line-height: 1.5;
}
.heading1 {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
letter-spacing: -0.025em;
}
.heading2 {
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
margin-top: 2px;
}
.headingSeparation {
display: none;
}
.headerActions {
user-select: none;
display: flex;
flex-direction: row;
gap: 8px;
}
.headerAction {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
background: transparent;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.headerAction:hover {
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.headerAction dees-icon {
width: 14px;
height: 14px;
}
.searchGrid {
display: grid;
grid-gap: 16px;
grid-template-columns: 1fr 200px;
padding: 16px 24px;
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(0 0% 3.9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
transition: all 0.2s ease;
}
.searchGrid.hidden {
height: 0px;
opacity: 0;
overflow: hidden;
padding: 0px 24px;
border-bottom-width: 0px;
}
table {
width: 100%;
caption-side: bottom;
font-size: 14px;
border-collapse: separate;
border-spacing: 0;
}
.noDataSet {
padding: 48px 24px;
text-align: center;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
}
thead {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
tbody tr {
transition: background-color 0.15s ease;
position: relative;
}
/* Default horizontal lines (bottom border only) */
tbody tr {
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
tbody tr:last-child {
border-bottom: none;
}
/* Full horizontal lines when enabled */
:host([show-horizontal-lines]) tbody tr {
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
:host([show-horizontal-lines]) tbody tr:first-child {
border-top: none;
}
:host([show-horizontal-lines]) tbody tr:last-child {
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
tbody tr:hover {
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.5)', 'hsl(0 0% 14.9% / 0.5)')};
}
/* Column hover effect for better traceability */
td {
position: relative;
}
td::after {
content: '';
position: absolute;
top: -1000px;
bottom: -1000px;
left: 0;
right: 0;
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.3)', 'hsl(0 0% 14.9% / 0.3)')};
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: -1;
}
td:hover::after {
opacity: 1;
}
/* Grid mode - shows both vertical and horizontal lines */
:host([show-grid]) th {
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-left: none;
border-top: none;
}
:host([show-grid]) td {
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-left: none;
border-top: none;
}
:host([show-grid]) th:first-child,
:host([show-grid]) td:first-child {
border-left: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
:host([show-grid]) tbody tr:first-child td {
border-top: none;
}
tbody tr.selected {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
}
tbody tr.hasAttachment {
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 76.2% 36.3% / 0.1)')};
}
th {
height: 48px;
padding: 12px 24px;
text-align: left;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
letter-spacing: -0.01em;
}
:host([show-vertical-lines]) th {
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
td {
padding: 12px 24px;
vertical-align: middle;
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
}
:host([show-vertical-lines]) td {
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
th:first-child,
td:first-child {
padding-left: 24px;
}
th:last-child,
td:last-child {
padding-right: 24px;
}
:host([show-vertical-lines]) th:last-child,
:host([show-vertical-lines]) td:last-child {
border-right: none;
}
.innerCellContainer {
position: relative;
min-height: 24px;
line-height: 24px;
}
td input {
position: absolute;
top: 4px;
bottom: 4px;
left: 20px;
right: 20px;
width: calc(100% - 40px);
height: calc(100% - 8px);
padding: 0 12px;
outline: none;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
font-family: inherit;
font-size: inherit;
font-weight: inherit;
transition: all 0.15s ease;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
td input:focus {
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.2)', 'hsl(217.2 91.2% 59.8% / 0.2)')};
}
.actionsContainer {
display: flex;
flex-direction: row;
gap: 4px;
}
.action {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
cursor: pointer;
transition: all 0.15s ease;
}
.action:hover {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.action:active {
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 11.8%)')};
}
.action dees-icon {
width: 16px;
height: 16px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
height: 52px;
padding: 0 24px;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.tableStatistics {
font-weight: 500;
}
.footerActions {
display: flex;
gap: 8px;
}
.footerActions .footerAction {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
}
.footerActions .footerAction:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.footerActions .footerAction dees-icon {
width: 14px;
height: 14px;
}
`,
];
public render(): TemplateResult {
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
const effectiveColumns: Column<T>[] = usingColumns
? this.computeEffectiveColumns()
: this.computeColumnsFromDisplayFunction();
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`
<table>
<thead>
<tr>
${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>
</thead>
<tbody>
${this.getViewData(effectiveColumns).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;
}}
@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' : ''}"
>
${effectiveColumns
.filter((c) => !c.hidden)
.map((col, colIndex) => {
const value = this.getCellValue(itemArg, col);
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>
`
: 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();
};
}
}
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';
}
private computeColumnsFromDisplayFunction(): Column<T>[] {
if (!this.data || this.data.length === 0) return [];
const firstTransformedItem = this.displayFunction(this.data[0]);
const keys: string[] = Object.keys(firstTransformedItem);
return keys.map((key) => ({
key,
header: key,
value: (row: T) => this.displayFunction(row)[key],
}));
}
private computeEffectiveColumns(): Column<T>[] {
const base = (this.columns || []).slice();
if (!this.augmentFromDisplayFunction) return base;
const fromDisplay = this.computeColumnsFromDisplayFunction();
const existingKeys = new Set(base.map((c) => String(c.key)));
for (const col of fromDisplay) {
if (!existingKeys.has(String(col.key))) {
base.push(col);
}
}
return base;
}
private getCellValue(row: T, col: Column<T>): any {
return col.value ? col.value(row) : (row as any)[col.key as any];
}
private getViewData(effectiveColumns: Column<T>[]): T[] {
if (!this.sortKey || !this.sortDir) return this.data;
const col = effectiveColumns.find((c) => String(c.key) === this.sortKey);
if (!col) return this.data;
const arr = this.data.slice();
const dir = this.sortDir === 'asc' ? 1 : -1;
arr.sort((a, b) => {
const va = this.getCellValue(a, col);
const vb = this.getCellValue(b, col);
if (va == null && vb == null) return 0;
if (va == null) return -1 * dir;
if (vb == null) return 1 * dir;
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
const sa = String(va).toLowerCase();
const sb = String(vb).toLowerCase();
if (sa < sb) return -1 * dir;
if (sa > sb) return 1 * dir;
return 0;
});
return arr;
}
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>`;
}
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();
}
}