feat(dees-table): add multi-column sorting with header menu controls and priority indicators
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-07 - 3.62.0 - feat(dees-table)
|
||||||
|
add multi-column sorting with header menu controls and priority indicators
|
||||||
|
|
||||||
|
- replace single-column sort state with ordered sort descriptors for cascading client-side sorting
|
||||||
|
- add Shift+click header sorting, context menu actions, and confirmation before replacing an active sort cascade
|
||||||
|
- show multi-sort direction and priority badges in table headers and add a demo showcasing the new behavior
|
||||||
|
|
||||||
## 2026-04-06 - 3.61.2 - fix(dees-input-list,dees-icon)
|
## 2026-04-06 - 3.61.2 - fix(dees-input-list,dees-icon)
|
||||||
preserve input focus after list updates and make icons ignore pointer events
|
preserve input focus after list updates and make icons ignore pointer events
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-catalog',
|
name: '@design.estate/dees-catalog',
|
||||||
version: '3.61.2',
|
version: '3.62.0',
|
||||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Column, TDisplayFunction } from './types.js';
|
import type { Column, ISortDescriptor, TDisplayFunction } from './types.js';
|
||||||
|
|
||||||
export function computeColumnsFromDisplayFunction<T>(
|
export function computeColumnsFromDisplayFunction<T>(
|
||||||
displayFunction: TDisplayFunction<T>,
|
displayFunction: TDisplayFunction<T>,
|
||||||
@@ -36,11 +36,31 @@ export function getCellValue<T>(row: T, col: Column<T>, displayFunction?: TDispl
|
|||||||
return col.value ? col.value(row) : (row as any)[col.key as any];
|
return col.value ? col.value(row) : (row as any)[col.key as any];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two cell values in ascending order. Returns -1, 0, or 1.
|
||||||
|
* Null/undefined values sort before defined values. Numbers compare numerically;
|
||||||
|
* everything else compares as case-insensitive strings.
|
||||||
|
*/
|
||||||
|
export function compareCellValues(va: any, vb: any): number {
|
||||||
|
if (va == null && vb == null) return 0;
|
||||||
|
if (va == null) return -1;
|
||||||
|
if (vb == null) return 1;
|
||||||
|
if (typeof va === 'number' && typeof vb === 'number') {
|
||||||
|
if (va < vb) return -1;
|
||||||
|
if (va > vb) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const sa = String(va).toLowerCase();
|
||||||
|
const sb = String(vb).toLowerCase();
|
||||||
|
if (sa < sb) return -1;
|
||||||
|
if (sa > sb) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function getViewData<T>(
|
export function getViewData<T>(
|
||||||
data: T[],
|
data: T[],
|
||||||
effectiveColumns: Column<T>[],
|
effectiveColumns: Column<T>[],
|
||||||
sortKey?: string,
|
sortBy: ISortDescriptor[],
|
||||||
sortDir?: 'asc' | 'desc' | null,
|
|
||||||
filterText?: string,
|
filterText?: string,
|
||||||
columnFilters?: Record<string, string>,
|
columnFilters?: Record<string, string>,
|
||||||
filterMode: 'table' | 'data' = 'table',
|
filterMode: 'table' | 'data' = 'table',
|
||||||
@@ -94,21 +114,17 @@ export function getViewData<T>(
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!sortKey || !sortDir) return arr;
|
if (!sortBy || sortBy.length === 0) return arr;
|
||||||
const col = effectiveColumns.find((c) => String(c.key) === sortKey);
|
// Pre-resolve descriptors -> columns once for performance.
|
||||||
if (!col) return arr;
|
const resolved = sortBy
|
||||||
const dir = sortDir === 'asc' ? 1 : -1;
|
.map((desc) => ({ desc, col: effectiveColumns.find((c) => String(c.key) === desc.key) }))
|
||||||
|
.filter((entry): entry is { desc: ISortDescriptor; col: Column<T> } => !!entry.col);
|
||||||
|
if (resolved.length === 0) return arr;
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
const va = getCellValue(a, col);
|
for (const { desc, col } of resolved) {
|
||||||
const vb = getCellValue(b, col);
|
const cmp = compareCellValues(getCellValue(a, col), getCellValue(b, col));
|
||||||
if (va == null && vb == null) return 0;
|
if (cmp !== 0) return desc.dir === 'asc' ? cmp : -cmp;
|
||||||
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 0;
|
||||||
});
|
});
|
||||||
return arr;
|
return arr;
|
||||||
|
|||||||
@@ -580,6 +580,44 @@ export const demoFunc = () => html`
|
|||||||
></dees-table>
|
></dees-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h2 class="demo-title">Multi-Column Sort</h2>
|
||||||
|
<p class="demo-description">
|
||||||
|
Click any column header for a single-column sort. Hold Shift while clicking to add the
|
||||||
|
column to a multi-sort cascade (or cycle its direction). Right-click any sortable header
|
||||||
|
to open a menu where you can pin a column to a specific priority slot, remove it, or
|
||||||
|
clear the cascade.
|
||||||
|
</p>
|
||||||
|
<dees-table
|
||||||
|
heading1="People Directory"
|
||||||
|
heading2="Pre-seeded with department ▲ 1, name ▲ 2"
|
||||||
|
.sortBy=${[
|
||||||
|
{ key: 'department', dir: 'asc' },
|
||||||
|
{ key: 'name', dir: 'asc' },
|
||||||
|
]}
|
||||||
|
.columns=${[
|
||||||
|
{ key: 'department', header: 'Department', sortable: true },
|
||||||
|
{ key: 'name', header: 'Name', sortable: true },
|
||||||
|
{ key: 'role', header: 'Role', sortable: true },
|
||||||
|
{ key: 'createdAt', header: 'Created', sortable: true },
|
||||||
|
{ key: 'location', header: 'Location', sortable: true },
|
||||||
|
{ key: 'status', header: 'Status', sortable: true },
|
||||||
|
]}
|
||||||
|
.data=${[
|
||||||
|
{ department: 'R&D', name: 'Alice Johnson', role: 'Engineer', createdAt: '2023-01-12', location: 'Berlin', status: 'Active' },
|
||||||
|
{ department: 'R&D', name: 'Diana Martinez', role: 'Engineer', createdAt: '2020-06-30', location: 'Madrid', status: 'Active' },
|
||||||
|
{ department: 'R&D', name: 'Mark Lee', role: 'Engineer', createdAt: '2024-03-04', location: 'Berlin', status: 'Active' },
|
||||||
|
{ department: 'Design', name: 'Bob Smith', role: 'Designer', createdAt: '2022-11-05', location: 'Paris', status: 'Active' },
|
||||||
|
{ department: 'Design', name: 'Sara Kim', role: 'Designer', createdAt: '2021-08-19', location: 'Paris', status: 'On Leave' },
|
||||||
|
{ department: 'Ops', name: 'Charlie Davis', role: 'Manager', createdAt: '2021-04-21', location: 'London', status: 'On Leave' },
|
||||||
|
{ department: 'Ops', name: 'Helena Voss', role: 'SRE', createdAt: '2023-07-22', location: 'London', status: 'Active' },
|
||||||
|
{ department: 'QA', name: 'Fiona Clark', role: 'QA', createdAt: '2022-03-14', location: 'Vienna', status: 'Active' },
|
||||||
|
{ department: 'QA', name: 'Tomás Rivera', role: 'QA', createdAt: '2024-01-09', location: 'Madrid', status: 'Active' },
|
||||||
|
{ department: 'CS', name: 'Ethan Brown', role: 'Support', createdAt: '2019-09-18', location: 'Rome', status: 'Inactive' },
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="demo-section">
|
<div class="demo-section">
|
||||||
<h2 class="demo-title">Wide Properties + Many Actions</h2>
|
<h2 class="demo-title">Wide Properties + Many Actions</h2>
|
||||||
<p class="demo-description">A table with many columns and rich actions to stress test layout and sticky Actions.</p>
|
<p class="demo-description">A table with many columns and rich actions to stress test layout and sticky Actions.</p>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ 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 '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
|
import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
|
||||||
|
import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
import * as domtools from '@design.estate/dees-domtools';
|
||||||
import { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.js';
|
import { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.js';
|
||||||
import { tableStyles } from './styles.js';
|
import { tableStyles } from './styles.js';
|
||||||
import type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
import type { Column, ISortDescriptor, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
||||||
import {
|
import {
|
||||||
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
|
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
|
||||||
computeEffectiveColumns as computeEffectiveColumnsFn,
|
computeEffectiveColumns as computeEffectiveColumnsFn,
|
||||||
@@ -17,7 +18,14 @@ import { compileLucenePredicate } from './lucene.js';
|
|||||||
import { themeDefaultStyles } from '../../00theme.js';
|
import { themeDefaultStyles } from '../../00theme.js';
|
||||||
import '../../00group-layout/dees-tile/dees-tile.js';
|
import '../../00group-layout/dees-tile/dees-tile.js';
|
||||||
|
|
||||||
export type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
export type { Column, ISortDescriptor, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
||||||
|
|
||||||
|
/** Returns the English ordinal label for a 1-based position (e.g. 1 → "1st"). */
|
||||||
|
function ordinalLabel(n: number): string {
|
||||||
|
const s = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = n % 100;
|
||||||
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -161,11 +169,12 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
|
|
||||||
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
|
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
|
||||||
|
|
||||||
// simple client-side sorting (Phase 1)
|
/**
|
||||||
|
* Multi-column sort cascade. The first entry is the primary sort key,
|
||||||
|
* subsequent entries are tiebreakers in priority order.
|
||||||
|
*/
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor sortKey: string | undefined = undefined;
|
accessor sortBy: ISortDescriptor[] = [];
|
||||||
@property({ attribute: false })
|
|
||||||
accessor sortDir: 'asc' | 'desc' | null = null;
|
|
||||||
|
|
||||||
// simple client-side filtering (Phase 1)
|
// simple client-side filtering (Phase 1)
|
||||||
@property({ type: String })
|
@property({ type: String })
|
||||||
@@ -213,8 +222,7 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
const viewData = getViewDataFn(
|
const viewData = getViewDataFn(
|
||||||
this.data,
|
this.data,
|
||||||
effectiveColumns,
|
effectiveColumns,
|
||||||
this.sortKey,
|
this.sortBy,
|
||||||
this.sortDir,
|
|
||||||
this.filterText,
|
this.filterText,
|
||||||
this.columnFilters,
|
this.columnFilters,
|
||||||
this.searchMode === 'data' ? 'data' : 'table',
|
this.searchMode === 'data' ? 'data' : 'table',
|
||||||
@@ -318,7 +326,12 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
role="columnheader"
|
role="columnheader"
|
||||||
aria-sort=${ariaSort}
|
aria-sort=${ariaSort}
|
||||||
style="${isSortable ? 'cursor: pointer;' : ''}"
|
style="${isSortable ? 'cursor: pointer;' : ''}"
|
||||||
@click=${() => (isSortable ? this.toggleSort(col) : null)}
|
@click=${(eventArg: MouseEvent) =>
|
||||||
|
isSortable ? this.handleHeaderClick(eventArg, col, effectiveColumns) : null}
|
||||||
|
@contextmenu=${(eventArg: MouseEvent) =>
|
||||||
|
isSortable
|
||||||
|
? this.openHeaderContextMenu(eventArg, col, effectiveColumns)
|
||||||
|
: null}
|
||||||
>
|
>
|
||||||
${col.header ?? (col.key as any)}
|
${col.header ?? (col.key as any)}
|
||||||
${this.renderSortIndicator(col)}
|
${this.renderSortIndicator(col)}
|
||||||
@@ -651,35 +664,348 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
|
|
||||||
// compute helpers moved to ./data.ts
|
// compute helpers moved to ./data.ts
|
||||||
|
|
||||||
private toggleSort(col: Column<T>) {
|
// ─── sort: public API ────────────────────────────────────────────────
|
||||||
const key = String(col.key);
|
|
||||||
if (this.sortKey !== key) {
|
/** Returns the descriptor for `key` if the column is currently in the cascade. */
|
||||||
this.sortKey = key;
|
public getSortDescriptor(key: string): ISortDescriptor | undefined {
|
||||||
this.sortDir = 'asc';
|
return this.sortBy.find((d) => d.key === key);
|
||||||
} else {
|
}
|
||||||
if (this.sortDir === 'asc') this.sortDir = 'desc';
|
|
||||||
else if (this.sortDir === 'desc') {
|
/** Returns the 0-based priority of `key` in the cascade, or -1 if not present. */
|
||||||
this.sortDir = null;
|
public getSortPriority(key: string): number {
|
||||||
this.sortKey = undefined;
|
return this.sortBy.findIndex((d) => d.key === key);
|
||||||
} else this.sortDir = 'asc';
|
}
|
||||||
}
|
|
||||||
this.dispatchEvent(
|
/** Replaces the cascade with a single sort entry. */
|
||||||
new CustomEvent('sortChange', {
|
public setSort(key: string, dir: 'asc' | 'desc'): void {
|
||||||
detail: { key: this.sortKey, dir: this.sortDir },
|
this.sortBy = [{ key, dir }];
|
||||||
bubbles: true,
|
this.emitSortChange();
|
||||||
})
|
|
||||||
);
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts (or moves) `key` to a 0-based position in the cascade. If the key is
|
||||||
|
* already present elsewhere, its previous entry is removed before insertion so
|
||||||
|
* a column appears at most once.
|
||||||
|
*/
|
||||||
|
public addSortAt(key: string, position: number, dir: 'asc' | 'desc'): void {
|
||||||
|
const next = this.sortBy.filter((d) => d.key !== key);
|
||||||
|
const clamped = Math.max(0, Math.min(position, next.length));
|
||||||
|
next.splice(clamped, 0, { key, dir });
|
||||||
|
this.sortBy = next;
|
||||||
|
this.emitSortChange();
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends `key` to the end of the cascade (or moves it there if already present). */
|
||||||
|
public appendSort(key: string, dir: 'asc' | 'desc'): void {
|
||||||
|
const next = this.sortBy.filter((d) => d.key !== key);
|
||||||
|
next.push({ key, dir });
|
||||||
|
this.sortBy = next;
|
||||||
|
this.emitSortChange();
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes `key` from the cascade. No-op if not present. */
|
||||||
|
public removeSort(key: string): void {
|
||||||
|
if (!this.sortBy.some((d) => d.key === key)) return;
|
||||||
|
this.sortBy = this.sortBy.filter((d) => d.key !== key);
|
||||||
|
this.emitSortChange();
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empties the cascade. */
|
||||||
|
public clearSorts(): void {
|
||||||
|
if (this.sortBy.length === 0) return;
|
||||||
|
this.sortBy = [];
|
||||||
|
this.emitSortChange();
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitSortChange() {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('sortChange', {
|
||||||
|
detail: { sortBy: this.sortBy.map((d) => ({ ...d })) },
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── sort: header interaction handlers ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain left-click on a sortable header. Cycles `none → asc → desc → none`
|
||||||
|
* collapsing the cascade to a single column. If a multi-column cascade is
|
||||||
|
* active, asks the user to confirm the destructive replacement first. A
|
||||||
|
* Shift+click bypasses the modal and routes through the multi-sort cycle.
|
||||||
|
*/
|
||||||
|
private async handleHeaderClick(
|
||||||
|
eventArg: MouseEvent,
|
||||||
|
col: Column<T>,
|
||||||
|
_effectiveColumns: Column<T>[]
|
||||||
|
) {
|
||||||
|
if (eventArg.shiftKey) {
|
||||||
|
this.handleHeaderShiftClick(col);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const proceed = await this.confirmReplaceCascade(col);
|
||||||
|
if (!proceed) return;
|
||||||
|
this.cycleSingleSort(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycles a single column through `none → asc → desc → none`, collapsing the
|
||||||
|
* cascade. Used by both plain click and the menu's "Sort Ascending/Descending"
|
||||||
|
* shortcuts (after confirmation).
|
||||||
|
*/
|
||||||
|
private cycleSingleSort(col: Column<T>) {
|
||||||
|
const key = String(col.key);
|
||||||
|
const current = this.sortBy.length === 1 && this.sortBy[0].key === key ? this.sortBy[0].dir : null;
|
||||||
|
if (current === 'asc') this.setSort(key, 'desc');
|
||||||
|
else if (current === 'desc') this.clearSorts();
|
||||||
|
else this.setSort(key, 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift+click cycle on a sortable header. Edits the cascade in place without
|
||||||
|
* destroying other sort keys: append → flip dir → remove.
|
||||||
|
*/
|
||||||
|
private handleHeaderShiftClick(col: Column<T>) {
|
||||||
|
const key = String(col.key);
|
||||||
|
const existing = this.getSortDescriptor(key);
|
||||||
|
if (!existing) {
|
||||||
|
this.appendSort(key, 'asc');
|
||||||
|
} else if (existing.dir === 'asc') {
|
||||||
|
this.sortBy = this.sortBy.map((d) => (d.key === key ? { key, dir: 'desc' } : d));
|
||||||
|
this.emitSortChange();
|
||||||
|
this.requestUpdate();
|
||||||
|
} else {
|
||||||
|
this.removeSort(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a confirmation modal when the cascade has more than one entry and the
|
||||||
|
* user attempts a destructive single-sort replacement. Resolves to `true` if
|
||||||
|
* the user accepts, `false` if they cancel. If the cascade has 0 or 1 entries
|
||||||
|
* the modal is skipped and we resolve to `true` immediately.
|
||||||
|
*/
|
||||||
|
private confirmReplaceCascade(targetCol: Column<T>): Promise<boolean> {
|
||||||
|
if (this.sortBy.length <= 1) return Promise.resolve(true);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const settle = (result: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const summary = this.sortBy
|
||||||
|
.map((d, i) => {
|
||||||
|
const c = (this as any)._lookupColumnByKey?.(d.key) as Column<T> | undefined;
|
||||||
|
const label = c?.header ?? d.key;
|
||||||
|
return html`<li>${i + 1}. ${label} ${d.dir === 'asc' ? '▲' : '▼'}</li>`;
|
||||||
|
});
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Replace multi-column sort?',
|
||||||
|
width: 'small',
|
||||||
|
showCloseButton: true,
|
||||||
|
content: html`
|
||||||
|
<div style="font-size:13px; line-height:1.55;">
|
||||||
|
<p style="margin:0 0 8px;">
|
||||||
|
You currently have a ${this.sortBy.length}-column sort active:
|
||||||
|
</p>
|
||||||
|
<ul style="margin:0 0 12px; padding-left:18px;">${summary}</ul>
|
||||||
|
<p style="margin:0;">
|
||||||
|
Continuing will discard the cascade and replace it with a single sort on
|
||||||
|
<strong>${targetCol.header ?? String(targetCol.key)}</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modal) => {
|
||||||
|
settle(false);
|
||||||
|
await modal!.destroy();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Replace',
|
||||||
|
iconName: 'lucide:check',
|
||||||
|
action: async (modal) => {
|
||||||
|
settle(true);
|
||||||
|
await modal!.destroy();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a column by its string key in the currently effective column set.
|
||||||
|
* Used by the modal helper to render human-friendly labels.
|
||||||
|
*/
|
||||||
|
private _lookupColumnByKey(key: string): Column<T> | undefined {
|
||||||
|
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
|
||||||
|
const effective = usingColumns
|
||||||
|
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
|
||||||
|
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
|
||||||
|
return effective.find((c) => String(c.key) === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the header context menu for explicit multi-sort priority control.
|
||||||
|
*/
|
||||||
|
private openHeaderContextMenu(
|
||||||
|
eventArg: MouseEvent,
|
||||||
|
col: Column<T>,
|
||||||
|
effectiveColumns: Column<T>[]
|
||||||
|
) {
|
||||||
|
const items = this.buildHeaderMenuItems(col, effectiveColumns);
|
||||||
|
DeesContextmenu.openContextMenuWithOptions(eventArg, items as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the dynamic context-menu structure for a single column header.
|
||||||
|
*/
|
||||||
|
private buildHeaderMenuItems(col: Column<T>, effectiveColumns: Column<T>[]) {
|
||||||
|
const key = String(col.key);
|
||||||
|
const existing = this.getSortDescriptor(key);
|
||||||
|
const cascadeLen = this.sortBy.length;
|
||||||
|
// Maximum exposed slot: one beyond the current cascade, capped at the
|
||||||
|
// number of sortable columns. If the column is already in the cascade we
|
||||||
|
// never need to grow the slot count.
|
||||||
|
const sortableColumnCount = effectiveColumns.filter((c) => !!c.sortable).length;
|
||||||
|
const maxSlot = Math.min(
|
||||||
|
Math.max(cascadeLen + (existing ? 0 : 1), 1),
|
||||||
|
Math.max(sortableColumnCount, 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const items: any[] = [];
|
||||||
|
|
||||||
|
// Single-sort shortcuts. These are destructive when a cascade is active, so
|
||||||
|
// they go through confirmReplaceCascade just like a plain click.
|
||||||
|
items.push({
|
||||||
|
name: 'Sort Ascending',
|
||||||
|
iconName: cascadeLen === 1 && existing?.dir === 'asc' ? 'lucide:check' : 'lucide:arrowUp',
|
||||||
|
action: async () => {
|
||||||
|
if (await this.confirmReplaceCascade(col)) this.setSort(key, 'asc');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
name: 'Sort Descending',
|
||||||
|
iconName: cascadeLen === 1 && existing?.dir === 'desc' ? 'lucide:check' : 'lucide:arrowDown',
|
||||||
|
action: async () => {
|
||||||
|
if (await this.confirmReplaceCascade(col)) this.setSort(key, 'desc');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({ divider: true });
|
||||||
|
|
||||||
|
// Priority slot entries (1..maxSlot). Each slot has an asc/desc submenu.
|
||||||
|
for (let slot = 1; slot <= maxSlot; slot++) {
|
||||||
|
const ordinal = ordinalLabel(slot);
|
||||||
|
const isCurrentSlot = existing && this.getSortPriority(key) === slot - 1;
|
||||||
|
items.push({
|
||||||
|
name: `Set as ${ordinal} sort`,
|
||||||
|
iconName: isCurrentSlot ? 'lucide:check' : 'lucide:listOrdered',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
name: 'Ascending',
|
||||||
|
iconName: 'lucide:arrowUp',
|
||||||
|
action: async () => {
|
||||||
|
this.addSortAt(key, slot - 1, 'asc');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Descending',
|
||||||
|
iconName: 'lucide:arrowDown',
|
||||||
|
action: async () => {
|
||||||
|
this.addSortAt(key, slot - 1, 'desc');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ divider: true });
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
name: 'Append to sort',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
name: 'Ascending',
|
||||||
|
iconName: 'lucide:arrowUp',
|
||||||
|
action: async () => {
|
||||||
|
this.appendSort(key, 'asc');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Descending',
|
||||||
|
iconName: 'lucide:arrowDown',
|
||||||
|
action: async () => {
|
||||||
|
this.appendSort(key, 'desc');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
items.push({ divider: true });
|
||||||
|
items.push({
|
||||||
|
name: 'Remove from sort',
|
||||||
|
iconName: 'lucide:minus',
|
||||||
|
action: async () => {
|
||||||
|
this.removeSort(key);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cascadeLen > 0) {
|
||||||
|
if (!existing) items.push({ divider: true });
|
||||||
|
items.push({
|
||||||
|
name: 'Clear all sorts',
|
||||||
|
iconName: 'lucide:trash',
|
||||||
|
action: async () => {
|
||||||
|
this.clearSorts();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── sort: indicator + ARIA ──────────────────────────────────────────
|
||||||
|
|
||||||
private getAriaSort(col: Column<T>): 'none' | 'ascending' | 'descending' {
|
private getAriaSort(col: Column<T>): 'none' | 'ascending' | 'descending' {
|
||||||
if (String(col.key) !== this.sortKey || !this.sortDir) return 'none';
|
// ARIA sort reflects only the primary sort key (standard grid pattern).
|
||||||
return this.sortDir === 'asc' ? 'ascending' : 'descending';
|
const primary = this.sortBy[0];
|
||||||
|
if (!primary || primary.key !== String(col.key)) return 'none';
|
||||||
|
return primary.dir === 'asc' ? 'ascending' : 'descending';
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderSortIndicator(col: Column<T>) {
|
private renderSortIndicator(col: Column<T>) {
|
||||||
if (String(col.key) !== this.sortKey || !this.sortDir) return html``;
|
const idx = this.getSortPriority(String(col.key));
|
||||||
return html`<span style="margin-left:6px; opacity:0.7;">${this.sortDir === 'asc' ? '▲' : '▼'}</span>`;
|
if (idx < 0) return html``;
|
||||||
|
const desc = this.sortBy[idx];
|
||||||
|
const arrow = desc.dir === 'asc' ? '▲' : '▼';
|
||||||
|
if (this.sortBy.length === 1) {
|
||||||
|
return html`<span class="sortArrow">${arrow}</span>`;
|
||||||
|
}
|
||||||
|
return html`<span class="sortArrow">${arrow}</span><span class="sortBadge">${idx + 1}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// filtering helpers
|
// filtering helpers
|
||||||
|
|||||||
@@ -277,6 +277,32 @@ export const tableStyles: CSSResult[] = [
|
|||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
th[role='columnheader']:hover {
|
||||||
|
color: var(--dees-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
th .sortArrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
th .sortBadge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: ${cssManager.bdTheme('hsl(222.2 47.4% 30%)', 'hsl(217.2 91.2% 75%)')};
|
||||||
|
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.12)', 'hsl(217.2 91.2% 59.8% / 0.18)')};
|
||||||
|
border-radius: 999px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
:host([show-vertical-lines]) th {
|
:host([show-vertical-lines]) th {
|
||||||
border-right: 1px solid var(--dees-color-border-default);
|
border-right: 1px solid var(--dees-color-border-default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,13 @@ export interface Column<T = any> {
|
|||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry in a multi-column sort cascade. Order in the array reflects priority:
|
||||||
|
* index 0 is the primary sort key, index 1 the secondary tiebreaker, and so on.
|
||||||
|
*/
|
||||||
|
export interface ISortDescriptor {
|
||||||
|
key: string;
|
||||||
|
dir: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
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