feat(dees-table): add multi-column sorting with header menu controls and priority indicators

This commit is contained in:
2026-04-07 12:19:59 +00:00
parent 3601651e10
commit fa56c7cce8
7 changed files with 471 additions and 49 deletions

View File

@@ -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 { 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 { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.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 {
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
computeEffectiveColumns as computeEffectiveColumnsFn,
@@ -17,7 +18,14 @@ import { compileLucenePredicate } from './lucene.js';
import { themeDefaultStyles } from '../../00theme.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 {
interface HTMLElementTagNameMap {
@@ -161,11 +169,12 @@ export class DeesTable<T> extends DeesElement {
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 })
accessor sortKey: string | undefined = undefined;
@property({ attribute: false })
accessor sortDir: 'asc' | 'desc' | null = null;
accessor sortBy: ISortDescriptor[] = [];
// simple client-side filtering (Phase 1)
@property({ type: String })
@@ -213,8 +222,7 @@ export class DeesTable<T> extends DeesElement {
const viewData = getViewDataFn(
this.data,
effectiveColumns,
this.sortKey,
this.sortDir,
this.sortBy,
this.filterText,
this.columnFilters,
this.searchMode === 'data' ? 'data' : 'table',
@@ -318,7 +326,12 @@ export class DeesTable<T> extends DeesElement {
role="columnheader"
aria-sort=${ariaSort}
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)}
${this.renderSortIndicator(col)}
@@ -651,35 +664,348 @@ export class DeesTable<T> extends DeesElement {
// compute helpers moved to ./data.ts
private toggleSort(col: Column<T>) {
const key = String(col.key);
if (this.sortKey !== key) {
this.sortKey = key;
this.sortDir = 'asc';
} else {
if (this.sortDir === 'asc') this.sortDir = 'desc';
else if (this.sortDir === 'desc') {
this.sortDir = null;
this.sortKey = undefined;
} else this.sortDir = 'asc';
}
this.dispatchEvent(
new CustomEvent('sortChange', {
detail: { key: this.sortKey, dir: this.sortDir },
bubbles: true,
})
);
// ─── sort: public API ────────────────────────────────────────────────
/** Returns the descriptor for `key` if the column is currently in the cascade. */
public getSortDescriptor(key: string): ISortDescriptor | undefined {
return this.sortBy.find((d) => d.key === key);
}
/** Returns the 0-based priority of `key` in the cascade, or -1 if not present. */
public getSortPriority(key: string): number {
return this.sortBy.findIndex((d) => d.key === key);
}
/** Replaces the cascade with a single sort entry. */
public setSort(key: string, dir: 'asc' | 'desc'): void {
this.sortBy = [{ key, dir }];
this.emitSortChange();
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' {
if (String(col.key) !== this.sortKey || !this.sortDir) return 'none';
return this.sortDir === 'asc' ? 'ascending' : 'descending';
// ARIA sort reflects only the primary sort key (standard grid pattern).
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>) {
if (String(col.key) !== this.sortKey || !this.sortDir) return html``;
return html`<span style="margin-left:6px; opacity:0.7;">${this.sortDir === 'asc' ? '▲' : '▼'}</span>`;
const idx = this.getSortPriority(String(col.key));
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