feat(dees-table): add multi-column sorting with header menu controls and priority indicators
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user