feat: add per-column filtering and sticky header support to DeesTable component
This commit is contained in:
		| @@ -41,19 +41,39 @@ export function getViewData<T>( | ||||
|   effectiveColumns: Column<T>[], | ||||
|   sortKey?: string, | ||||
|   sortDir?: 'asc' | 'desc' | null, | ||||
|   filterText?: string | ||||
|   filterText?: string, | ||||
|   columnFilters?: Record<string, string> | ||||
| ): T[] { | ||||
|   let arr = data.slice(); | ||||
|   const ft = (filterText || '').trim().toLowerCase(); | ||||
|   if (ft) { | ||||
|   const cf = columnFilters || {}; | ||||
|   const cfKeys = Object.keys(cf).filter((k) => (cf[k] ?? '').trim().length > 0); | ||||
|   if (ft || cfKeys.length > 0) { | ||||
|     arr = arr.filter((row) => { | ||||
|       for (const col of effectiveColumns) { | ||||
|         if (col.hidden) continue; | ||||
|       // column filters (AND across columns) | ||||
|       for (const k of cfKeys) { | ||||
|         const col = effectiveColumns.find((c) => String(c.key) === k); | ||||
|         if (!col || col.hidden || col.filterable === false) continue; | ||||
|         const val = getCellValue(row, col); | ||||
|         const s = String(val ?? '').toLowerCase(); | ||||
|         if (s.includes(ft)) return true; | ||||
|         const needle = String(cf[k]).toLowerCase(); | ||||
|         if (!s.includes(needle)) return false; | ||||
|       } | ||||
|       return false; | ||||
|       // global filter (OR across visible columns) | ||||
|       if (ft) { | ||||
|         let any = false; | ||||
|         for (const col of effectiveColumns) { | ||||
|           if (col.hidden) continue; | ||||
|           const val = getCellValue(row, col); | ||||
|           const s = String(val ?? '').toLowerCase(); | ||||
|           if (s.includes(ft)) { | ||||
|             any = true; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|         if (!any) return false; | ||||
|       } | ||||
|       return true; | ||||
|     }); | ||||
|   } | ||||
|   if (!sortKey || !sortDir) return arr; | ||||
| @@ -75,4 +95,3 @@ export function getViewData<T>( | ||||
|   }); | ||||
|   return arr; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -505,6 +505,38 @@ export const demoFunc = () => html` | ||||
|           dataName="items" | ||||
|         ></dees-table> | ||||
|       </div> | ||||
|  | ||||
|       <div class="demo-section"> | ||||
|         <h2 class="demo-title">Column Filters + Sticky Header (New)</h2> | ||||
|         <p class="demo-description">Per-column quick filters and sticky header with internal scroll. Try filtering the Name column. Uses --table-max-height var.</p> | ||||
|         <style> | ||||
|           dees-table[sticky-header] { --table-max-height: 220px; } | ||||
|         </style> | ||||
|         <dees-table | ||||
|           heading1="Employees" | ||||
|           heading2="Quick filter per column + sticky header" | ||||
|           .showColumnFilters=${true} | ||||
|           .stickyHeader=${true} | ||||
|           .columns=${[ | ||||
|             { key: 'name', header: 'Name', sortable: true }, | ||||
|             { key: 'email', header: 'Email', sortable: true }, | ||||
|             { key: 'department', header: 'Department', sortable: true }, | ||||
|           ]} | ||||
|           .data=${[ | ||||
|             { name: 'Alice Johnson', email: 'alice@corp.com', department: 'Engineering' }, | ||||
|             { name: 'Bob Smith', email: 'bob@corp.com', department: 'Sales' }, | ||||
|             { name: 'Charlie Davis', email: 'charlie@corp.com', department: 'HR' }, | ||||
|             { name: 'Diana Martinez', email: 'diana@corp.com', department: 'Engineering' }, | ||||
|             { name: 'Ethan Brown', email: 'ethan@corp.com', department: 'Finance' }, | ||||
|             { name: 'Fiona Clark', email: 'fiona@corp.com', department: 'Sales' }, | ||||
|             { name: 'Grace Lee', email: 'grace@corp.com', department: 'Engineering' }, | ||||
|             { name: 'Henry Wilson', email: 'henry@corp.com', department: 'Marketing' }, | ||||
|             { name: 'Irene Walker', email: 'irene@corp.com', department: 'Finance' }, | ||||
|             { name: 'Jack Turner', email: 'jack@corp.com', department: 'Support' }, | ||||
|           ]} | ||||
|           dataName="employees" | ||||
|         ></dees-table> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| `; | ||||
|   | ||||
| @@ -3,7 +3,6 @@ import { demoFunc } from './dees-table.demo.js'; | ||||
| import { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element'; | ||||
|  | ||||
| import { DeesContextmenu } from '../dees-contextmenu.js'; | ||||
| import * as plugins from '../00plugins.js'; | ||||
| import * as domtools from '@design.estate/dees-domtools'; | ||||
| import { type TIconKey } from '../dees-icon.js'; | ||||
| import { tableStyles } from './styles.js'; | ||||
| @@ -167,6 +166,13 @@ export class DeesTable<T> extends DeesElement { | ||||
|   // simple client-side filtering (Phase 1) | ||||
|   @property({ type: String }) | ||||
|   public filterText: string = ''; | ||||
|   // per-column quick filters | ||||
|   @property({ attribute: false }) | ||||
|   public columnFilters: Record<string, string> = {}; | ||||
|   @property({ type: Boolean, attribute: 'show-column-filters' }) | ||||
|   public showColumnFilters: boolean = false; | ||||
|   @property({ type: Boolean, reflect: true, attribute: 'sticky-header' }) | ||||
|   public stickyHeader: boolean = false; | ||||
|    | ||||
|   // selection (Phase 1) | ||||
|   @property({ type: String }) | ||||
| @@ -254,6 +260,7 @@ export class DeesTable<T> extends DeesElement { | ||||
|         <style></style> | ||||
|         ${this.data.length > 0 | ||||
|           ? html` | ||||
|               <div class="tableScroll"> | ||||
|               <table> | ||||
|                 <thead> | ||||
|                   <tr> | ||||
| @@ -261,12 +268,15 @@ export class DeesTable<T> extends DeesElement { | ||||
|                       ? html` | ||||
|                           <th style="width:42px; text-align:center;"> | ||||
|                             ${this.selectionMode === 'multi' | ||||
|                               ? html`<input type="checkbox" | ||||
|                                   .checked=${this.areAllSelected()} | ||||
|                                   @click=${(e: Event) => { | ||||
|                                     e.stopPropagation(); | ||||
|                                     this.toggleSelectAll(); | ||||
|                                   }} />` | ||||
|                               ? html` | ||||
|                                   <dees-input-checkbox | ||||
|                                     .value=${this.areAllSelected()} | ||||
|                                     @newValue=${(e: CustomEvent<boolean>) => { | ||||
|                                       e.stopPropagation(); | ||||
|                                       this.setSelectAll(e.detail === true); | ||||
|                                     }} | ||||
|                                   ></dees-input-checkbox> | ||||
|                                 ` | ||||
|                               : html``} | ||||
|                           </th> | ||||
|                         ` | ||||
| @@ -293,9 +303,31 @@ export class DeesTable<T> extends DeesElement { | ||||
|                       } | ||||
|                     })()} | ||||
|                   </tr> | ||||
|                   ${this.showColumnFilters | ||||
|                     ? html`<tr class="filtersRow"> | ||||
|                         ${this.selectionMode !== 'none' | ||||
|                           ? html`<th style="width:42px;"></th>` | ||||
|                           : html``} | ||||
|                         ${effectiveColumns | ||||
|                           .filter((c) => !c.hidden) | ||||
|                           .map((col) => { | ||||
|                             const key = String(col.key); | ||||
|                             if (col.filterable === false) return html`<th></th>`; | ||||
|                             return html`<th> | ||||
|                               <input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''} | ||||
|                                 @input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} /> | ||||
|                             </th>`; | ||||
|                           })} | ||||
|                         ${(() => { | ||||
|                           if (this.dataActions && this.dataActions.length > 0) { | ||||
|                             return html` <th></th> `; | ||||
|                           } | ||||
|                         })()} | ||||
|                       </tr>` | ||||
|                     : html``} | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                   ${getViewDataFn(this.data, effectiveColumns, this.sortKey, this.sortDir, this.filterText).map((itemArg, rowIndex) => { | ||||
|                   ${getViewDataFn(this.data, effectiveColumns, this.sortKey, this.sortDir, this.filterText, this.columnFilters).map((itemArg, rowIndex) => { | ||||
|                     const getTr = (elementArg: HTMLElement): HTMLElement => { | ||||
|                       if (elementArg.tagName === 'TR') { | ||||
|                         return elementArg; | ||||
| @@ -370,14 +402,13 @@ export class DeesTable<T> extends DeesElement { | ||||
|                       > | ||||
|                         ${this.selectionMode !== 'none' | ||||
|                           ? html`<td style="width:42px; text-align:center;"> | ||||
|                               <input | ||||
|                                 type="checkbox" | ||||
|                                 .checked=${this.isRowSelected(itemArg)} | ||||
|                                 @click=${(e: Event) => { | ||||
|                               <dees-input-checkbox | ||||
|                                 .value=${this.isRowSelected(itemArg)} | ||||
|                                 @newValue=${(e: CustomEvent<boolean>) => { | ||||
|                                   e.stopPropagation(); | ||||
|                                   this.toggleRowSelected(itemArg); | ||||
|                                   this.setRowSelected(itemArg, e.detail === true); | ||||
|                                 }} | ||||
|                               /> | ||||
|                               ></dees-input-checkbox> | ||||
|                             </td>` | ||||
|                           : html``} | ||||
|                         ${effectiveColumns | ||||
| @@ -435,6 +466,7 @@ export class DeesTable<T> extends DeesElement { | ||||
|                   })} | ||||
|                 </tbody> | ||||
|               </table> | ||||
|               </div> | ||||
|             ` | ||||
|           : html` <div class="noDataSet">No data set!</div> `} | ||||
|         <div class="footer"> | ||||
| @@ -583,7 +615,7 @@ export class DeesTable<T> extends DeesElement { | ||||
|     if (prev !== this.filterText) { | ||||
|       this.dispatchEvent( | ||||
|         new CustomEvent('filterChange', { | ||||
|           detail: { text: this.filterText }, | ||||
|           detail: { text: this.filterText, columns: { ...this.columnFilters } }, | ||||
|           bubbles: true, | ||||
|         }) | ||||
|       ); | ||||
| @@ -591,6 +623,17 @@ export class DeesTable<T> extends DeesElement { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   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) { | ||||
| @@ -621,6 +664,19 @@ export class DeesTable<T> extends DeesElement { | ||||
|     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 areAllSelected(): boolean { | ||||
|     return this.data.length > 0 && this.selectedIds.size === this.data.length; | ||||
|   } | ||||
| @@ -635,6 +691,16 @@ export class DeesTable<T> extends DeesElement { | ||||
|     this.requestUpdate(); | ||||
|   } | ||||
|  | ||||
|   private setSelectAll(checked: boolean) { | ||||
|     if (checked) { | ||||
|       this.selectedIds = new Set(this.data.map((r) => this.getRowId(r))); | ||||
|     } else { | ||||
|       this.selectedIds.clear(); | ||||
|     } | ||||
|     this.emitSelectionChange(); | ||||
|     this.requestUpdate(); | ||||
|   } | ||||
|  | ||||
|   private emitSelectionChange() { | ||||
|     const selectedIds = Array.from(this.selectedIds); | ||||
|     const selectedRows = this.data.filter((r) => this.selectedIds.has(this.getRowId(r))); | ||||
|   | ||||
| @@ -108,6 +108,14 @@ export const tableStyles: CSSResult[] = [ | ||||
|         border-bottom-width: 0px; | ||||
|       } | ||||
|  | ||||
|       .tableScroll { | ||||
|         /* no overflow by default to preserve current layout */ | ||||
|       } | ||||
|       :host([sticky-header]) .tableScroll { | ||||
|         max-height: var(--table-max-height, 360px); | ||||
|         overflow: auto; | ||||
|       } | ||||
|  | ||||
|       table { | ||||
|         width: 100%; | ||||
|         caption-side: bottom; | ||||
| @@ -126,6 +134,11 @@ export const tableStyles: CSSResult[] = [ | ||||
|         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%)')}; | ||||
|       } | ||||
|       :host([sticky-header]) thead th { | ||||
|         position: sticky; | ||||
|         top: 0; | ||||
|         z-index: 2; | ||||
|       } | ||||
|        | ||||
|       tbody tr { | ||||
|         transition: background-color 0.15s ease; | ||||
| @@ -282,6 +295,21 @@ export const tableStyles: CSSResult[] = [ | ||||
|         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)')}; | ||||
|       } | ||||
|  | ||||
|       /* filter row */ | ||||
|       thead tr.filtersRow th { | ||||
|         padding: 8px 12px 12px 12px; | ||||
|       } | ||||
|       thead tr.filtersRow th input[type='text'] { | ||||
|         width: 100%; | ||||
|         box-sizing: border-box; | ||||
|         padding: 6px 8px; | ||||
|         font-size: 13px; | ||||
|         border-radius: 6px; | ||||
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; | ||||
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; | ||||
|         color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; | ||||
|       } | ||||
|       .actionsContainer { | ||||
|         display: flex; | ||||
|         flex-direction: row; | ||||
| @@ -359,4 +387,3 @@ export const tableStyles: CSSResult[] = [ | ||||
|       } | ||||
|   `, | ||||
| ]; | ||||
|  | ||||
|   | ||||
| @@ -21,8 +21,9 @@ export interface Column<T = any> { | ||||
|   value?: (row: T) => any; | ||||
|   renderer?: (value: any, row: T, ctx: { rowIndex: number; colIndex: number; column: Column<T> }) => TemplateResult | string; | ||||
|   sortable?: boolean; | ||||
|   /** whether this column participates in per-column quick filtering (default: true) */ | ||||
|   filterable?: boolean; | ||||
|   hidden?: boolean; | ||||
| } | ||||
|  | ||||
| export type TDisplayFunction<T = any> = (itemArg: T) => Record<string, any>; | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user