feat: add per-column filtering and sticky header support to DeesTable component

This commit is contained in:
2025-09-16 15:17:33 +00:00
parent cf92a423cf
commit 6427510c98
5 changed files with 169 additions and 24 deletions

View File

@@ -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) => {
// 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();
const needle = String(cf[k]).toLowerCase();
if (!s.includes(needle)) 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)) return true;
if (s.includes(ft)) {
any = true;
break;
}
return false;
}
if (!any) return false;
}
return true;
});
}
if (!sortKey || !sortDir) return arr;
@@ -75,4 +95,3 @@ export function getViewData<T>(
});
return arr;
}

View File

@@ -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>
`;

View File

@@ -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) => {
? html`
<dees-input-checkbox
.value=${this.areAllSelected()}
@newValue=${(e: CustomEvent<boolean>) => {
e.stopPropagation();
this.toggleSelectAll();
}} />`
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)));

View File

@@ -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[] = [
}
`,
];

View File

@@ -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>;