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