diff --git a/changelog.md b/changelog.md index 5de4b02..c4588e3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # 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) preserve input focus after list updates and make icons ignore pointer events diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 04be2b2..6561f51 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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.' } diff --git a/ts_web/elements/00group-dataview/dees-table/data.ts b/ts_web/elements/00group-dataview/dees-table/data.ts index e813bd6..ae31052 100644 --- a/ts_web/elements/00group-dataview/dees-table/data.ts +++ b/ts_web/elements/00group-dataview/dees-table/data.ts @@ -1,4 +1,4 @@ -import type { Column, TDisplayFunction } from './types.js'; +import type { Column, ISortDescriptor, TDisplayFunction } from './types.js'; export function computeColumnsFromDisplayFunction( displayFunction: TDisplayFunction, @@ -36,11 +36,31 @@ export function getCellValue(row: T, col: Column, displayFunction?: TDispl 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( data: T[], effectiveColumns: Column[], - sortKey?: string, - sortDir?: 'asc' | 'desc' | null, + sortBy: ISortDescriptor[], filterText?: string, columnFilters?: Record, filterMode: 'table' | 'data' = 'table', @@ -94,21 +114,17 @@ export function getViewData( return true; }); } - if (!sortKey || !sortDir) return arr; - const col = effectiveColumns.find((c) => String(c.key) === sortKey); - if (!col) return arr; - const dir = sortDir === 'asc' ? 1 : -1; + if (!sortBy || sortBy.length === 0) return arr; + // Pre-resolve descriptors -> columns once for performance. + const resolved = sortBy + .map((desc) => ({ desc, col: effectiveColumns.find((c) => String(c.key) === desc.key) })) + .filter((entry): entry is { desc: ISortDescriptor; col: Column } => !!entry.col); + if (resolved.length === 0) return arr; arr.sort((a, b) => { - const va = getCellValue(a, col); - const vb = getCellValue(b, col); - if (va == null && vb == null) return 0; - 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; + for (const { desc, col } of resolved) { + const cmp = compareCellValues(getCellValue(a, col), getCellValue(b, col)); + if (cmp !== 0) return desc.dir === 'asc' ? cmp : -cmp; + } return 0; }); return arr; diff --git a/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts b/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts index 776f5cd..5012cc1 100644 --- a/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts +++ b/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts @@ -580,6 +580,44 @@ export const demoFunc = () => html` > +
+

Multi-Column Sort

+

+ 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. +

+ +
+

Wide Properties + Many Actions

A table with many columns and rich actions to stress test layout and sticky Actions.

diff --git a/ts_web/elements/00group-dataview/dees-table/dees-table.ts b/ts_web/elements/00group-dataview/dees-table/dees-table.ts index 6f250c8..55e9817 100644 --- a/ts_web/elements/00group-dataview/dees-table/dees-table.ts +++ b/ts_web/elements/00group-dataview/dees-table/dees-table.ts @@ -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 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 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 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 extends DeesElement { // compute helpers moved to ./data.ts - private toggleSort(col: Column) { - 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, + _effectiveColumns: Column[] + ) { + 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) { + 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) { + 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): Promise { + 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 | undefined; + const label = c?.header ?? d.key; + return html`
  • ${i + 1}. ${label} ${d.dir === 'asc' ? '▲' : '▼'}
  • `; + }); + DeesModal.createAndShow({ + heading: 'Replace multi-column sort?', + width: 'small', + showCloseButton: true, + content: html` +
    +

    + You currently have a ${this.sortBy.length}-column sort active: +

    +
      ${summary}
    +

    + Continuing will discard the cascade and replace it with a single sort on + ${targetCol.header ?? String(targetCol.key)}. +

    +
    + `, + 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 | 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, + effectiveColumns: Column[] + ) { + 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, effectiveColumns: Column[]) { + 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): '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) { - if (String(col.key) !== this.sortKey || !this.sortDir) return html``; - return html`${this.sortDir === 'asc' ? '▲' : '▼'}`; + 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`${arrow}`; + } + return html`${arrow}${idx + 1}`; } // filtering helpers diff --git a/ts_web/elements/00group-dataview/dees-table/styles.ts b/ts_web/elements/00group-dataview/dees-table/styles.ts index 1e52442..155a1ca 100644 --- a/ts_web/elements/00group-dataview/dees-table/styles.ts +++ b/ts_web/elements/00group-dataview/dees-table/styles.ts @@ -276,6 +276,32 @@ export const tableStyles: CSSResult[] = [ color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; 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 { border-right: 1px solid var(--dees-color-border-default); diff --git a/ts_web/elements/00group-dataview/dees-table/types.ts b/ts_web/elements/00group-dataview/dees-table/types.ts index a77a2fc..bd03625 100644 --- a/ts_web/elements/00group-dataview/dees-table/types.ts +++ b/ts_web/elements/00group-dataview/dees-table/types.ts @@ -26,4 +26,13 @@ export interface Column { 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 = (itemArg: T) => Record;