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:
@@ -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
|
||||
|
@@ -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>
|
||||
`;
|
||||
`;
|
@@ -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';
|
@@ -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';
|
||||
|
Reference in New Issue
Block a user