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) => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
@@ -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) => {
|
||||
? 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)));
|
||||
|
@@ -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