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>[],
|
effectiveColumns: Column<T>[],
|
||||||
sortKey?: string,
|
sortKey?: string,
|
||||||
sortDir?: 'asc' | 'desc' | null,
|
sortDir?: 'asc' | 'desc' | null,
|
||||||
filterText?: string
|
filterText?: string,
|
||||||
|
columnFilters?: Record<string, string>
|
||||||
): T[] {
|
): T[] {
|
||||||
let arr = data.slice();
|
let arr = data.slice();
|
||||||
const ft = (filterText || '').trim().toLowerCase();
|
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) => {
|
arr = arr.filter((row) => {
|
||||||
for (const col of effectiveColumns) {
|
// column filters (AND across columns)
|
||||||
if (col.hidden) continue;
|
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 val = getCellValue(row, col);
|
||||||
const s = String(val ?? '').toLowerCase();
|
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;
|
if (!sortKey || !sortDir) return arr;
|
||||||
@@ -75,4 +95,3 @@ export function getViewData<T>(
|
|||||||
});
|
});
|
||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -505,6 +505,38 @@ export const demoFunc = () => html`
|
|||||||
dataName="items"
|
dataName="items"
|
||||||
></dees-table>
|
></dees-table>
|
||||||
</div>
|
</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>
|
||||||
</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 { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element';
|
||||||
|
|
||||||
import { DeesContextmenu } from '../dees-contextmenu.js';
|
import { DeesContextmenu } from '../dees-contextmenu.js';
|
||||||
import * as plugins from '../00plugins.js';
|
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
import * as domtools from '@design.estate/dees-domtools';
|
||||||
import { type TIconKey } from '../dees-icon.js';
|
import { type TIconKey } from '../dees-icon.js';
|
||||||
import { tableStyles } from './styles.js';
|
import { tableStyles } from './styles.js';
|
||||||
@@ -167,6 +166,13 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
// simple client-side filtering (Phase 1)
|
// simple client-side filtering (Phase 1)
|
||||||
@property({ type: String })
|
@property({ type: String })
|
||||||
public filterText: 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)
|
// selection (Phase 1)
|
||||||
@property({ type: String })
|
@property({ type: String })
|
||||||
@@ -254,6 +260,7 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
<style></style>
|
<style></style>
|
||||||
${this.data.length > 0
|
${this.data.length > 0
|
||||||
? html`
|
? html`
|
||||||
|
<div class="tableScroll">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -261,12 +268,15 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
? html`
|
? html`
|
||||||
<th style="width:42px; text-align:center;">
|
<th style="width:42px; text-align:center;">
|
||||||
${this.selectionMode === 'multi'
|
${this.selectionMode === 'multi'
|
||||||
? html`<input type="checkbox"
|
? html`
|
||||||
.checked=${this.areAllSelected()}
|
<dees-input-checkbox
|
||||||
@click=${(e: Event) => {
|
.value=${this.areAllSelected()}
|
||||||
e.stopPropagation();
|
@newValue=${(e: CustomEvent<boolean>) => {
|
||||||
this.toggleSelectAll();
|
e.stopPropagation();
|
||||||
}} />`
|
this.setSelectAll(e.detail === true);
|
||||||
|
}}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
`
|
||||||
: html``}
|
: html``}
|
||||||
</th>
|
</th>
|
||||||
`
|
`
|
||||||
@@ -293,9 +303,31 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
</tr>
|
</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>
|
</thead>
|
||||||
<tbody>
|
<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 => {
|
const getTr = (elementArg: HTMLElement): HTMLElement => {
|
||||||
if (elementArg.tagName === 'TR') {
|
if (elementArg.tagName === 'TR') {
|
||||||
return elementArg;
|
return elementArg;
|
||||||
@@ -370,14 +402,13 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
>
|
>
|
||||||
${this.selectionMode !== 'none'
|
${this.selectionMode !== 'none'
|
||||||
? html`<td style="width:42px; text-align:center;">
|
? html`<td style="width:42px; text-align:center;">
|
||||||
<input
|
<dees-input-checkbox
|
||||||
type="checkbox"
|
.value=${this.isRowSelected(itemArg)}
|
||||||
.checked=${this.isRowSelected(itemArg)}
|
@newValue=${(e: CustomEvent<boolean>) => {
|
||||||
@click=${(e: Event) => {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.toggleRowSelected(itemArg);
|
this.setRowSelected(itemArg, e.detail === true);
|
||||||
}}
|
}}
|
||||||
/>
|
></dees-input-checkbox>
|
||||||
</td>`
|
</td>`
|
||||||
: html``}
|
: html``}
|
||||||
${effectiveColumns
|
${effectiveColumns
|
||||||
@@ -435,6 +466,7 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
: html` <div class="noDataSet">No data set!</div> `}
|
: html` <div class="noDataSet">No data set!</div> `}
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
@@ -583,7 +615,7 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
if (prev !== this.filterText) {
|
if (prev !== this.filterText) {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('filterChange', {
|
new CustomEvent('filterChange', {
|
||||||
detail: { text: this.filterText },
|
detail: { text: this.filterText, columns: { ...this.columnFilters } },
|
||||||
bubbles: true,
|
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
|
// selection helpers
|
||||||
private getRowId(row: T): string {
|
private getRowId(row: T): string {
|
||||||
if (this.rowKey) {
|
if (this.rowKey) {
|
||||||
@@ -621,6 +664,19 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
this.requestUpdate();
|
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 {
|
private areAllSelected(): boolean {
|
||||||
return this.data.length > 0 && this.selectedIds.size === this.data.length;
|
return this.data.length > 0 && this.selectedIds.size === this.data.length;
|
||||||
}
|
}
|
||||||
@@ -635,6 +691,16 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
this.requestUpdate();
|
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() {
|
private emitSelectionChange() {
|
||||||
const selectedIds = Array.from(this.selectedIds);
|
const selectedIds = Array.from(this.selectedIds);
|
||||||
const selectedRows = this.data.filter((r) => this.selectedIds.has(this.getRowId(r)));
|
const selectedRows = this.data.filter((r) => this.selectedIds.has(this.getRowId(r)));
|
||||||
|
@@ -108,6 +108,14 @@ export const tableStyles: CSSResult[] = [
|
|||||||
border-bottom-width: 0px;
|
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 {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
caption-side: bottom;
|
caption-side: bottom;
|
||||||
@@ -126,6 +134,11 @@ export const tableStyles: CSSResult[] = [
|
|||||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
|
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%)')};
|
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 {
|
tbody tr {
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
@@ -282,6 +295,21 @@ export const tableStyles: CSSResult[] = [
|
|||||||
outline-offset: 2px;
|
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)')};
|
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 {
|
.actionsContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -359,4 +387,3 @@ export const tableStyles: CSSResult[] = [
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@@ -21,8 +21,9 @@ export interface Column<T = any> {
|
|||||||
value?: (row: T) => any;
|
value?: (row: T) => any;
|
||||||
renderer?: (value: any, row: T, ctx: { rowIndex: number; colIndex: number; column: Column<T> }) => TemplateResult | string;
|
renderer?: (value: any, row: T, ctx: { rowIndex: number; colIndex: number; column: Column<T> }) => TemplateResult | string;
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
|
/** whether this column participates in per-column quick filtering (default: true) */
|
||||||
|
filterable?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TDisplayFunction<T = any> = (itemArg: T) => Record<string, any>;
|
export type TDisplayFunction<T = any> = (itemArg: T) => Record<string, any>;
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user