Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e806c9bce6 | |||
| fa56c7cce8 | |||
| 3601651e10 | |||
| b5055b0696 | |||
| a5f7a7ecee | |||
| dede3216fb | |||
| 5ee89b31b1 | |||
| 2d354ace55 | |||
| 34f5239607 | |||
| d17bdcbaad |
34
changelog.md
34
changelog.md
@@ -1,5 +1,39 @@
|
||||
# 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
|
||||
|
||||
- wrap the list in dees-tile with a dynamic item count heading and move the add-item controls into the tile footer
|
||||
- replace custom container styling with shared tile and theme tokens for hover, focus, row, and disabled states
|
||||
- show a bullet icon for non-sortable or disabled items when no candidate state icon is present
|
||||
|
||||
## 2026-04-05 - 3.61.0 - feat(dees-input-list)
|
||||
allow freeform entries alongside candidate suggestions in dees-input-list
|
||||
|
||||
- adds an allowFreeform option so Enter can add values that do not exactly match the candidate list
|
||||
- shows check and question-mark indicators to distinguish known candidates from custom freeform items
|
||||
- updates the component demo with a freeform-plus-candidates example
|
||||
|
||||
## 2026-04-05 - 3.60.0 - feat(dees-input-list)
|
||||
add candidate autocomplete with tab completion and payload retrieval
|
||||
|
||||
- Adds terminal-style inline autocomplete with ghost text, Tab accept, Shift+Tab cycling, and Escape clearing for candidate-based input.
|
||||
- Introduces candidate payload support with APIs to retrieve selected candidate objects after items are added.
|
||||
- Updates the dees-input-list demo with candidate selection examples for team members and technology stacks.
|
||||
|
||||
## 2026-04-05 - 3.59.1 - fix(project)
|
||||
no changes to commit
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "3.59.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.59.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>;
|
||||
|
||||
@@ -262,7 +262,85 @@ export const demoFunc = () => html`
|
||||
></dees-input-list>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'9. Empty State'} .subtitle=${'How the component looks with no items'}>
|
||||
<dees-panel .title=${'9. Candidates with Tab Completion'} .subtitle=${'Terminal-style autocomplete — Tab accepts, Shift+Tab cycles'}>
|
||||
<div class="grid-layout">
|
||||
<dees-input-list
|
||||
id="candidate-list"
|
||||
.label=${'Assign Team Members'}
|
||||
.placeholder=${'Type a name... (Tab to complete)'}
|
||||
.candidates=${[
|
||||
{ viewKey: 'Alice Smith', payload: { id: 1, role: 'Engineer', department: 'Frontend' } },
|
||||
{ viewKey: 'Bob Johnson', payload: { id: 2, role: 'Designer', department: 'UX' } },
|
||||
{ viewKey: 'Carol Williams', payload: { id: 3, role: 'Product Manager', department: 'Product' } },
|
||||
{ viewKey: 'David Brown', payload: { id: 4, role: 'Engineer', department: 'Backend' } },
|
||||
{ viewKey: 'Eve Davis', payload: { id: 5, role: 'QA Engineer', department: 'Quality' } },
|
||||
{ viewKey: 'Frank Miller', payload: { id: 6, role: 'DevOps', department: 'Infrastructure' } },
|
||||
{ viewKey: 'Grace Wilson', payload: { id: 7, role: 'Designer', department: 'UX' } },
|
||||
{ viewKey: 'Henry Moore', payload: { id: 8, role: 'Engineer', department: 'Frontend' } },
|
||||
]}
|
||||
.value=${['Alice Smith', 'Carol Williams']}
|
||||
.maxItems=${5}
|
||||
.description=${'Type to see ghost completion. Tab to accept, Shift+Tab to cycle, Enter to add.'}
|
||||
@change=${(e: CustomEvent) => {
|
||||
const preview = document.querySelector('#candidate-json');
|
||||
if (preview) {
|
||||
const list = (e.target as any);
|
||||
const candidates = list.getAddedCandidates();
|
||||
preview.textContent = JSON.stringify(candidates, null, 2);
|
||||
}
|
||||
}}
|
||||
></dees-input-list>
|
||||
|
||||
<div>
|
||||
<div style="font-size: 13px; font-weight: 500; margin-bottom: 8px; color: inherit;">Selected Candidates (with payloads)</div>
|
||||
<div class="output-preview" id="candidate-json">[]</div>
|
||||
<div class="feature-note">
|
||||
Try typing "D" — ghost text shows "avid Brown". Press Shift+Tab to cycle to other D-matches. Tab accepts, Enter adds.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'10. Technology Stack'} .subtitle=${'Larger candidate pool with Shift+Tab cycling'}>
|
||||
<dees-input-list
|
||||
.label=${'Select Technologies'}
|
||||
.placeholder=${'Type to autocomplete...'}
|
||||
.candidates=${[
|
||||
{ viewKey: 'TypeScript', payload: { category: 'language' } },
|
||||
{ viewKey: 'React', payload: { category: 'framework' } },
|
||||
{ viewKey: 'Vue.js', payload: { category: 'framework' } },
|
||||
{ viewKey: 'Angular', payload: { category: 'framework' } },
|
||||
{ viewKey: 'Node.js', payload: { category: 'runtime' } },
|
||||
{ viewKey: 'Deno', payload: { category: 'runtime' } },
|
||||
{ viewKey: 'Docker', payload: { category: 'devops' } },
|
||||
{ viewKey: 'PostgreSQL', payload: { category: 'database' } },
|
||||
{ viewKey: 'MongoDB', payload: { category: 'database' } },
|
||||
{ viewKey: 'Redis', payload: { category: 'database' } },
|
||||
{ viewKey: 'Kubernetes', payload: { category: 'devops' } },
|
||||
]}
|
||||
.description=${'Try "D" — cycles through Deno/Docker. "R" — cycles through React/Redis.'}
|
||||
></dees-input-list>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'11. Freeform + Candidates'} .subtitle=${'Allow adding items not in the candidate list (shown with a question mark)'}>
|
||||
<dees-input-list
|
||||
.label=${'Tags'}
|
||||
.placeholder=${'Type a tag... (freeform allowed)'}
|
||||
.allowFreeform=${true}
|
||||
.candidates=${[
|
||||
{ viewKey: 'bug', payload: { color: 'red' } },
|
||||
{ viewKey: 'feature', payload: { color: 'blue' } },
|
||||
{ viewKey: 'docs', payload: { color: 'green' } },
|
||||
{ viewKey: 'refactor', payload: { color: 'purple' } },
|
||||
{ viewKey: 'performance', payload: { color: 'orange' } },
|
||||
{ viewKey: 'security', payload: { color: 'red' } },
|
||||
]}
|
||||
.value=${['bug', 'my-custom-tag', 'feature']}
|
||||
.description=${'Known tags get a checkmark, custom tags get a question mark. Tab to complete, Enter to add freeform.'}
|
||||
></dees-input-list>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'12. Empty State'} .subtitle=${'How the component looks with no items'}>
|
||||
<dees-input-list
|
||||
.label=${'Your Ideas'}
|
||||
.placeholder=${'Share your ideas...'}
|
||||
|
||||
@@ -9,9 +9,15 @@ import {
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import '../../00group-utility/dees-icon/dees-icon.js';
|
||||
import '../../00group-layout/dees-tile/dees-tile.js';
|
||||
import { demoFunc } from './dees-input-list.demo.js';
|
||||
import { themeDefaultStyles } from '../../00theme.js';
|
||||
|
||||
export interface IListCandidate {
|
||||
viewKey: string;
|
||||
payload?: any;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-list': DeesInputList;
|
||||
@@ -46,12 +52,27 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
@property({ type: Boolean })
|
||||
accessor confirmDelete: boolean = false;
|
||||
|
||||
@property({ type: Array })
|
||||
accessor candidates: IListCandidate[] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor allowFreeform: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
accessor validationText: string = '';
|
||||
|
||||
private addedCandidatesMap: Map<string, IListCandidate> = new Map();
|
||||
private matchingCandidates: IListCandidate[] = [];
|
||||
|
||||
@state()
|
||||
accessor inputValue: string = '';
|
||||
|
||||
@state()
|
||||
accessor ghostText: string = '';
|
||||
|
||||
@state()
|
||||
accessor currentCandidateIndex: number = -1;
|
||||
|
||||
@state()
|
||||
accessor editingIndex: number = -1;
|
||||
|
||||
@@ -99,26 +120,19 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
dees-tile:hover::part(outer) {
|
||||
border-color: var(--dees-color-border-strong);
|
||||
}
|
||||
|
||||
.list-container:hover:not(.disabled) {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.list-container:focus-within {
|
||||
dees-tile:focus-within::part(outer) {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||
}
|
||||
|
||||
.list-container.disabled {
|
||||
:host([disabled]) dees-tile {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.list-items {
|
||||
@@ -131,8 +145,8 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border-bottom: 1px solid var(--dees-color-border-subtle);
|
||||
background: transparent;
|
||||
transition: transform 0.2s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -143,7 +157,7 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
}
|
||||
|
||||
.list-items:not(.is-dragging) .list-item:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')};
|
||||
background: var(--dees-color-row-hover);
|
||||
}
|
||||
|
||||
/* Dragging item - follows cursor */
|
||||
@@ -167,6 +181,28 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
}
|
||||
|
||||
|
||||
.candidate-check {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.candidate-unknown {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(45 93% 47%)', 'hsl(45 93% 58%)')};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-bullet {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--dees-color-text-muted);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -269,12 +305,10 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.add-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
@@ -368,6 +402,38 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
.list-items.dropping .list-item {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* ── Terminal-style inline autocomplete ── */
|
||||
.autocomplete-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ghost-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ghost-typed {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ghost-completion {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
opacity: 0.5;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -376,7 +442,7 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
<div class="input-wrapper">
|
||||
${this.label ? html`<dees-label .label=${this.label} .required=${this.required}></dees-label>` : ''}
|
||||
|
||||
<div class="list-container ${this.disabled ? 'disabled' : ''}">
|
||||
<dees-tile .heading="${this.value.length} item${this.value.length !== 1 ? 's' : ''}">
|
||||
<div class="list-items">
|
||||
${this.value.length > 0 ? this.value.map((item, index) => html`
|
||||
<div
|
||||
@@ -392,7 +458,17 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
<dees-icon .icon=${'lucide:gripVertical'}></dees-icon>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
|
||||
${this.candidates.length > 0 ? html`
|
||||
${this.candidates.some(c => c.viewKey === item) ? html`
|
||||
<dees-icon class="candidate-check" .icon=${'lucide:check'}></dees-icon>
|
||||
` : html`
|
||||
<dees-icon class="candidate-unknown" .icon=${'lucide:helpCircle'}></dees-icon>
|
||||
`}
|
||||
` : !this.sortable || this.disabled ? html`
|
||||
<dees-icon class="item-bullet" .icon=${'lucide:dot'}></dees-icon>
|
||||
` : ''}
|
||||
|
||||
<div class="item-content">
|
||||
${this.editingIndex === index ? html`
|
||||
<input
|
||||
@@ -438,16 +514,23 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
</div>
|
||||
|
||||
${!this.disabled && (!this.maxItems || this.value.length < this.maxItems) ? html`
|
||||
<div class="add-item-container">
|
||||
<input
|
||||
type="text"
|
||||
class="add-input"
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.inputValue}
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleAddKeyDown}
|
||||
?disabled=${this.disabled}
|
||||
/>
|
||||
<div slot="footer" class="add-item-container">
|
||||
<div class="autocomplete-wrapper">
|
||||
${this.ghostText ? html`
|
||||
<span class="ghost-text">
|
||||
<span class="ghost-typed">${this.inputValue}</span><span class="ghost-completion">${this.ghostText}</span>
|
||||
</span>
|
||||
` : ''}
|
||||
<input
|
||||
type="text"
|
||||
class="add-input"
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.inputValue}
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleAddKeyDown}
|
||||
?disabled=${this.disabled}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="add-button"
|
||||
@click=${this.addItem}
|
||||
@@ -457,7 +540,7 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
${this.validationText ? html`
|
||||
<div class="validation-message">${this.validationText}</div>
|
||||
@@ -472,11 +555,82 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
|
||||
private handleInput(e: InputEvent) {
|
||||
this.inputValue = (e.target as HTMLInputElement).value;
|
||||
this.updateGhostText();
|
||||
}
|
||||
|
||||
private updateGhostText(): void {
|
||||
if (this.candidates.length === 0 || !this.inputValue) {
|
||||
this.ghostText = '';
|
||||
this.currentCandidateIndex = -1;
|
||||
this.matchingCandidates = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const search = this.inputValue.toLowerCase();
|
||||
this.matchingCandidates = this.candidates
|
||||
.filter(c => {
|
||||
if (this.value.includes(c.viewKey)) return false;
|
||||
return c.viewKey.toLowerCase().startsWith(search);
|
||||
})
|
||||
.sort((a, b) => a.viewKey.length - b.viewKey.length);
|
||||
|
||||
if (this.matchingCandidates.length > 0) {
|
||||
this.currentCandidateIndex = 0;
|
||||
this.ghostText = this.matchingCandidates[0].viewKey.slice(this.inputValue.length);
|
||||
} else {
|
||||
this.currentCandidateIndex = -1;
|
||||
this.ghostText = '';
|
||||
}
|
||||
}
|
||||
|
||||
private handleAddKeyDown(e: KeyboardEvent) {
|
||||
// Tab/Shift+Tab: autocomplete handling when candidates are active
|
||||
if (e.key === 'Tab' && this.candidates.length > 0 && this.inputValue) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey && this.matchingCandidates.length > 0) {
|
||||
// Shift+Tab: cycle to next candidate
|
||||
this.currentCandidateIndex = (this.currentCandidateIndex + 1) % this.matchingCandidates.length;
|
||||
const candidate = this.matchingCandidates[this.currentCandidateIndex];
|
||||
this.ghostText = candidate.viewKey.slice(this.inputValue.length);
|
||||
} else if (!e.shiftKey && this.ghostText && this.matchingCandidates.length > 0) {
|
||||
// Tab: accept the completion into the input
|
||||
const candidate = this.matchingCandidates[this.currentCandidateIndex];
|
||||
this.inputValue = candidate.viewKey;
|
||||
this.ghostText = '';
|
||||
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
|
||||
if (input) input.value = candidate.viewKey;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear ghost text
|
||||
if (e.key === 'Escape' && this.ghostText) {
|
||||
e.preventDefault();
|
||||
this.ghostText = '';
|
||||
this.currentCandidateIndex = -1;
|
||||
this.matchingCandidates = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: add item
|
||||
if (e.key === 'Enter' && this.inputValue.trim()) {
|
||||
e.preventDefault();
|
||||
if (this.candidates.length > 0) {
|
||||
// Try exact candidate match first
|
||||
const match = this.candidates.find(
|
||||
c => c.viewKey.toLowerCase() === this.inputValue.trim().toLowerCase()
|
||||
);
|
||||
if (match) {
|
||||
this.selectCandidate(match);
|
||||
} else if (this.allowFreeform) {
|
||||
// Allow freeform entry (won't have a candidate checkmark)
|
||||
this.ghostText = '';
|
||||
this.currentCandidateIndex = -1;
|
||||
this.matchingCandidates = [];
|
||||
this.addItem();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.addItem();
|
||||
}
|
||||
}
|
||||
@@ -491,6 +645,52 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
}
|
||||
}
|
||||
|
||||
private selectCandidate(candidate: IListCandidate): void {
|
||||
if (this.maxItems && this.value.length >= this.maxItems) {
|
||||
this.validationText = `Maximum ${this.maxItems} items allowed`;
|
||||
setTimeout(() => this.validationText = '', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.allowDuplicates && this.value.includes(candidate.viewKey)) {
|
||||
this.validationText = 'This item already exists in the list';
|
||||
setTimeout(() => this.validationText = '', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
this.addedCandidatesMap.set(candidate.viewKey, candidate);
|
||||
this.value = [...this.value, candidate.viewKey];
|
||||
this.inputValue = '';
|
||||
this.ghostText = '';
|
||||
this.currentCandidateIndex = -1;
|
||||
this.matchingCandidates = [];
|
||||
this.validationText = '';
|
||||
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(); }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full candidate object for an item by its viewKey.
|
||||
* Returns undefined if the item was added as a plain string.
|
||||
*/
|
||||
public getCandidateForItem(viewKey: string): IListCandidate | undefined {
|
||||
return this.addedCandidatesMap.get(viewKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all added candidates with their payloads.
|
||||
*/
|
||||
public getAddedCandidates(): IListCandidate[] {
|
||||
return this.value
|
||||
.map(v => this.addedCandidatesMap.get(v))
|
||||
.filter((c): c is IListCandidate => c !== undefined);
|
||||
}
|
||||
|
||||
private addItem() {
|
||||
const trimmedValue = this.inputValue.trim();
|
||||
if (!trimmedValue) return;
|
||||
@@ -510,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) {
|
||||
@@ -570,6 +768,8 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const removedKey = this.value[index];
|
||||
this.addedCandidatesMap.delete(removedKey);
|
||||
this.value = this.value.filter((_, i) => i !== index);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
@@ -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