Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e806c9bce6 | |||
| fa56c7cce8 | |||
| 3601651e10 | |||
| b5055b0696 |
13
changelog.md
13
changelog.md
@@ -1,5 +1,18 @@
|
||||
# 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
|
||||
|
||||
- Delays refocusing the add input in dees-input-list until after Lit re-renders complete when adding or removing entries.
|
||||
- Adds pointer-events: none to dees-icon so icons do not block click interactions on surrounding controls.
|
||||
|
||||
## 2026-04-05 - 3.61.1 - fix(dees-input-list)
|
||||
align list input with dees-tile styling and improve item affordances
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "3.61.1",
|
||||
"version": "3.62.0",
|
||||
"private": false,
|
||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||
"main": "dist_ts_web/index.js",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog',
|
||||
version: '3.61.1',
|
||||
version: '3.62.0',
|
||||
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>(
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>(
|
||||
data: T[],
|
||||
effectiveColumns: Column<T>[],
|
||||
sortKey?: string,
|
||||
sortDir?: 'asc' | 'desc' | null,
|
||||
sortBy: ISortDescriptor[],
|
||||
filterText?: string,
|
||||
columnFilters?: Record<string, string>,
|
||||
filterMode: 'table' | 'data' = 'table',
|
||||
@@ -94,21 +114,17 @@ export function getViewData<T>(
|
||||
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<T> } => !!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;
|
||||
|
||||
@@ -580,6 +580,44 @@ export const demoFunc = () => html`
|
||||
></dees-table>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,4 +26,13 @@ export interface Column<T = any> {
|
||||
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>;
|
||||
|
||||
@@ -665,11 +665,13 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
this.currentCandidateIndex = -1;
|
||||
this.matchingCandidates = [];
|
||||
this.validationText = '';
|
||||
|
||||
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
|
||||
if (input) { input.value = ''; input.focus(); }
|
||||
|
||||
this.emitChange();
|
||||
|
||||
// Re-focus input after Lit re-renders
|
||||
this.updateComplete.then(() => {
|
||||
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
|
||||
if (input) { input.value = ''; input.focus(); }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -708,15 +710,13 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
this.value = [...this.value, trimmedValue];
|
||||
this.inputValue = '';
|
||||
this.validationText = '';
|
||||
|
||||
// Clear the input
|
||||
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
|
||||
this.emitChange();
|
||||
|
||||
// Re-focus input after Lit re-renders
|
||||
this.updateComplete.then(() => {
|
||||
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
|
||||
if (input) { input.value = ''; input.focus(); }
|
||||
});
|
||||
}
|
||||
|
||||
private startEdit(index: number) {
|
||||
|
||||
@@ -338,6 +338,7 @@ export class DeesIcon extends DeesElement {
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Improve rendering performance */
|
||||
|
||||
Reference in New Issue
Block a user