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
This commit is contained in:
2025-09-14 19:57:50 +00:00
parent edc15a727c
commit 3f3677ebaa
9 changed files with 721 additions and 266 deletions

View File

@@ -20,7 +20,7 @@ import { DeesInputMultitoggle } from './dees-input-multitoggle.js';
import { DeesInputPhone } from './dees-input-phone.js';
import { DeesInputTypelist } from './dees-input-typelist.js';
import { DeesFormSubmit } from './dees-form-submit.js';
import { DeesTable } from './dees-table.js';
import { DeesTable } from './dees-table/dees-table.js';
import { demoFunc } from './dees-form.demo.js';
// Unified set for form input types

View File

@@ -1,5 +1,5 @@
import { type ITableAction } from './dees-table.js';
import * as plugins from './00plugins.js';
import * as plugins from '../00plugins.js';
import { html, css, cssManager } from '@design.estate/dees-element';
interface ITableDemoData {
@@ -427,6 +427,46 @@ export const demoFunc = () => html`
dataName="items"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Schema-First Columns (New)</h2>
<p class="demo-description">Defines columns explicitly and renders via schema. No displayFunction needed.</p>
<dees-table
heading1="Users (Schema-First)"
heading2="Columns define rendering and order"
.columns=${[
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'Email', renderer: (v: string) => html`<dees-badge>${v}</dees-badge>` },
{ key: 'joinedAt', header: 'Joined', renderer: (v: string) => new Date(v).toLocaleDateString() },
]}
.data=${[
{ name: 'Alice', email: 'alice@example.com', joinedAt: '2022-08-01' },
{ name: 'Bob', email: 'bob@example.com', joinedAt: '2021-12-11' },
{ name: 'Carol', email: 'carol@example.com', joinedAt: '2023-03-22' },
]}
dataName="users"
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Partial Schema + Augment (New)</h2>
<p class="demo-description">Provides only the important columns; the rest are merged in from displayFunction.</p>
<dees-table
heading1="Users (Partial + Augment)"
heading2="Missing columns are derived"
.columns=${[
{ key: 'name', header: 'Name', sortable: true },
]}
.displayFunction=${(u: any) => ({ name: u.name, email: u.email, role: u.role })}
.augmentFromDisplayFunction=${true}
.data=${[
{ name: 'Erin', email: 'erin@example.com', role: 'Admin' },
{ name: 'Finn', email: 'finn@example.com', role: 'User' },
{ name: 'Gina', email: 'gina@example.com', role: 'User' },
]}
dataName="users"
></dees-table>
</div>
</div>
</div>
`;
`;

View File

@@ -1,6 +1,6 @@
import * as plugins from './00plugins.js';
import * as plugins from '../00plugins.js';
import { demoFunc } from './dees-table.demo.js';
import { cssGeistFontFamily } from './00fonts.js';
import { cssGeistFontFamily } from '../00fonts.js';
import {
customElement,
html,
@@ -12,10 +12,10 @@ import {
directives,
} from '@design.estate/dees-element';
import { DeesContextmenu } from './dees-contextmenu.js';
import { DeesContextmenu } from '../dees-contextmenu.js';
import * as domtools from '@design.estate/dees-domtools';
import { type TIconKey } from './dees-icon.js';
import { type TIconKey } from '../dees-icon.js';
declare global {
interface HTMLElementTagNameMap {
@@ -63,6 +63,21 @@ export interface ITableActionDataArg<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
@@ -134,6 +149,24 @@ export class DeesTable<T> extends DeesElement {
})
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,
})
@@ -180,6 +213,12 @@ export class DeesTable<T> extends DeesElement {
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();
}
@@ -544,6 +583,11 @@ export class DeesTable<T> extends DeesElement {
];
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 -->
@@ -609,32 +653,35 @@ export class DeesTable<T> extends DeesElement {
<!-- the actual table -->
<style></style>
${this.data.length > 0
? (() => {
// Only pick up the keys from the first transformed data object
// as all data objects are assumed to have the same structure
const firstTransformedItem = this.displayFunction(this.data[0]);
const headings: string[] = Object.keys(firstTransformedItem);
return html`
<table>
<thead>
<tr>
${headings.map(
(headingArg) => html`
<th>${headingArg}</th>
`
)}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
return html`
<th>Actions</th>
`;
}
})()}
</tr>
</thead>
<tbody>
${this.data.map((itemArg) => {
const transformedItem = this.displayFunction(itemArg);
? 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;
@@ -651,8 +698,6 @@ export class DeesTable<T> extends DeesElement {
eventArg.preventDefault();
eventArg.stopPropagation();
const realTarget = getTr(eventArg.target as HTMLElement);
console.log('dragenter');
console.log(realTarget);
setTimeout(() => {
realTarget.classList.add('hasAttachment');
}, 0);
@@ -702,29 +747,31 @@ export class DeesTable<T> extends DeesElement {
}}
class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
>
${headings.map(
(headingArg) => html`
<td
@dblclick=${(e: Event) => {
if (this.editableFields.includes(headingArg)) {
this.handleCellEditing(e, itemArg, headingArg);
} else {
const wantedAction = this.dataActions.find((actionArg) =>
${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 (wantedAction) {
wantedAction.actionFunc({
item: itemArg,
table: this,
});
if (this.editableFields.includes(editKey)) {
this.handleCellEditing(e, itemArg, editKey);
} else if (dblAction) {
dblAction.actionFunc({ item: itemArg, table: this });
}
}
}}
>
<div class="innerCellContainer">${transformedItem[headingArg]}</div>
</td>
`
)}
}}
>
<div class="innerCellContainer">${content}</div>
</td>
`;
})}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
return html`
@@ -732,36 +779,30 @@ export class DeesTable<T> extends DeesElement {
<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
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>
`;
</tr>`;
})}
</tbody>
</table>
`;
})()
</tbody>
</table>
`
: html` <div class="noDataSet">No data set!</div> `}
<div class="footer">
<div class="tableStatistics">
@@ -869,6 +910,87 @@ export class DeesTable<T> extends DeesElement {
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) {
@@ -884,7 +1006,7 @@ export class DeesTable<T> extends DeesElement {
const originalColor = target.style.color;
target.style.color = 'transparent';
const transformedItem = this.displayFunction(itemArg);
const initialValue = (transformedItem[key] as unknown as string) || '';
const initialValue = ((transformedItem as any)[key] ?? (itemArg as any)[key] ?? '') as string;
// Create an input element
const input = document.createElement('input');
input.type = 'text';

View File

@@ -57,7 +57,7 @@ export * from './dees-speechbubble.js';
export * from './dees-spinner.js';
export * from './dees-statsgrid.js';
export * from './dees-stepper.js';
export * from './dees-table.js';
export * from './dees-table/dees-table.js';
export * from './dees-terminal.js';
export * from './dees-toast.js';
export * from './dees-updater.js';