Files
dees-catalog/ts_web/elements/00group-dataview/dees-table/dees-table.ts

2248 lines
81 KiB
TypeScript
Raw Normal View History

import * as plugins from '../../00plugins.js';
2023-09-12 13:42:55 +02:00
import { demoFunc } from './dees-table.demo.js';
import { customElement, html, DeesElement, property, state, type TemplateResult, directives } from '@design.estate/dees-element';
2021-10-07 18:01:05 +02:00
import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
2023-08-07 19:13:29 +02:00
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,
ISortDescriptor,
ITableAction,
ITableActionDataArg,
TCellEditorType,
TDisplayFunction,
} from './types.js';
import '../../00group-input/dees-input-text/index.js';
import '../../00group-input/dees-input-checkbox/index.js';
import '../../00group-input/dees-input-dropdown/index.js';
import '../../00group-input/dees-input-datepicker/index.js';
import '../../00group-input/dees-input-tags/index.js';
import {
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
computeEffectiveColumns as computeEffectiveColumnsFn,
getCellValue as getCellValueFn,
getViewData as getViewDataFn,
} from './data.js';
import { compileLucenePredicate } from './lucene.js';
import { themeDefaultStyles } from '../../00theme.js';
import '../../00group-layout/dees-tile/dees-tile.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]);
}
2021-10-07 18:01:05 +02:00
declare global {
interface HTMLElementTagNameMap {
'dees-table': DeesTable<any>;
}
}
// interfaces moved to ./types.ts and re-exported above
2023-09-04 19:28:50 +02:00
// the table implementation
2021-10-07 18:01:05 +02:00
@customElement('dees-table')
export class DeesTable<T> extends DeesElement {
2023-09-12 13:42:55 +02:00
public static demo = demoFunc;
public static demoGroups = ['Data View'];
2021-10-07 18:01:05 +02:00
2022-12-07 02:28:31 +01:00
// INSTANCE
2021-11-26 20:06:09 +01:00
@property({
type: String,
})
accessor heading1: string = 'heading 1';
2021-10-07 18:01:05 +02:00
2021-11-26 20:06:09 +01:00
@property({
type: String,
})
accessor heading2: string = 'heading 2';
2021-10-07 18:01:05 +02:00
@property({
2021-10-07 18:47:36 +02:00
type: Array,
2021-10-07 18:01:05 +02:00
})
accessor data: T[] = [];
2021-10-07 18:01:05 +02:00
2023-10-17 20:07:45 +02:00
// dees-form compatibility -----------------------------------------
@property({
type: String,
})
accessor key!: string;
2023-10-17 20:07:45 +02:00
@property({
type: String,
})
accessor label!: string;
2023-10-17 20:07:45 +02:00
@property({
type: Boolean,
})
accessor disabled: boolean = false;
2023-10-17 20:07:45 +02:00
@property({
type: Boolean,
})
accessor required: boolean = false;
2023-10-17 20:07:45 +02:00
get value() {
return this.data;
}
2025-06-27 17:50:54 +00:00
set value(_valueArg) {}
2023-10-23 17:26:03 +02:00
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesTable<T>>();
2023-10-17 20:07:45 +02:00
// end dees-form compatibility -----------------------------------------
2024-01-21 22:37:39 +01:00
/**
* What does a row of data represent?
*/
2022-12-06 13:11:06 +01:00
@property({
2023-09-04 19:28:50 +02:00
type: String,
reflect: true,
2022-12-06 13:11:06 +01:00
})
accessor dataName!: string;
2022-12-06 13:11:06 +01:00
2024-01-21 22:37:39 +01:00
@property({
type: Boolean,
})
accessor searchable: boolean = true;
2024-01-21 22:37:39 +01:00
2021-11-26 20:06:09 +01:00
@property({
2023-09-04 19:28:50 +02:00
type: Array,
2021-11-26 20:06:09 +01:00
})
accessor dataActions: ITableAction<T>[] = [];
2021-10-07 18:01:05 +02:00
// schema-first columns API
@property({ attribute: false })
accessor columns: Column<T>[] = [];
/**
* Stable row identity for selection and updates. If provided as a function,
* it is only usable as a property (not via attribute).
*/
@property({ attribute: false })
accessor rowKey: keyof T | ((row: T) => string) | undefined = undefined;
/**
* When true and columns are provided, merge any missing columns discovered
* via displayFunction into the effective schema.
*/
@property({ type: Boolean })
accessor augmentFromDisplayFunction: boolean = false;
2021-10-07 18:01:05 +02:00
@property({
2023-09-04 19:28:50 +02:00
attribute: false,
2021-10-07 18:01:05 +02:00
})
accessor displayFunction: TDisplayFunction = (itemArg: T) => itemArg as any;
2021-10-07 18:01:05 +02:00
2023-09-17 21:38:02 +02:00
@property({
attribute: false,
})
accessor reverseDisplayFunction: (itemArg: any) => T = (itemArg: any) => itemArg as T;
2023-09-17 21:38:02 +02:00
2021-10-07 18:01:05 +02:00
@property({
2023-09-04 19:28:50 +02:00
type: Object,
2021-10-07 18:01:05 +02:00
})
accessor selectedDataRow!: T;
2021-10-07 18:01:05 +02:00
2025-06-27 17:50:54 +00:00
@property({
type: Boolean,
reflect: true,
attribute: 'show-vertical-lines'
})
accessor showVerticalLines: boolean = false;
2025-06-27 17:50:54 +00:00
@property({
type: Boolean,
reflect: true,
attribute: 'show-horizontal-lines'
})
accessor showHorizontalLines: boolean = false;
2025-06-27 17:50:54 +00:00
@property({
type: Boolean,
reflect: true,
attribute: 'show-grid'
})
accessor showGrid: boolean = true;
2025-06-27 17:50:54 +00:00
2022-12-07 02:28:31 +01:00
public files: File[] = [];
public fileWeakMap = new WeakMap();
2023-09-22 20:02:48 +02:00
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
2023-09-17 21:38:02 +02:00
/**
* Multi-column sort cascade. The first entry is the primary sort key,
* subsequent entries are tiebreakers in priority order.
*/
@property({ attribute: false })
accessor sortBy: ISortDescriptor[] = [];
// simple client-side filtering (Phase 1)
@property({ type: String })
accessor filterText: string = '';
// per-column quick filters
@property({ attribute: false })
accessor columnFilters: Record<string, string> = {};
@property({ type: Boolean, attribute: 'show-column-filters' })
accessor showColumnFilters: boolean = false;
/**
* When true, the table renders a leftmost checkbox column for click-driven
* (de)selection. Row selection by mouse (plain/shift/ctrl click) is always
* available regardless of this flag.
*/
@property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
accessor showSelectionCheckbox: boolean = false;
/**
* Enables row virtualization. Only rows visible in the nearest scroll
* ancestor (or the viewport) plus a small overscan are rendered. Top and
* bottom spacer rows preserve the scrollbar geometry.
*
* Assumes uniform row height (measured once from the first rendered row).
* Recommended for tables with > a few hundred rows.
*/
@property({ type: Boolean, reflect: true, attribute: 'virtualized' })
accessor virtualized: boolean = false;
/** Number of extra rows rendered above and below the visible window. */
@property({ type: Number, attribute: 'virtual-overscan' })
accessor virtualOverscan: number = 8;
/**
* When set, the table renders inside a fixed-height scroll container
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
* within that box via plain CSS sticky.
*
* When unset (the default), the table flows naturally and a JS-managed
* floating header keeps the column headers visible while the table is
* scrolled past in any ancestor scroll container (page or otherwise).
*/
@property({ type: Boolean, reflect: true, attribute: 'fixed-height' })
accessor fixedHeight: boolean = false;
// search row state
@property({ type: String })
accessor searchMode: 'table' | 'data' | 'server' = 'table';
private __searchTextSub?: { unsubscribe?: () => void };
private __searchModeSub?: { unsubscribe?: () => void };
// selection (Phase 1)
@property({ type: String })
accessor selectionMode: 'none' | 'single' | 'multi' = 'none';
@property({ attribute: false })
accessor selectedIds: Set<string> = new Set();
private _rowIdMap = new WeakMap<object, string>();
private _rowIdCounter = 0;
/**
* Anchor row id for shift+click range selection. Set whenever the user
* makes a non-range click (plain or cmd/ctrl) so the next shift+click
* can compute a contiguous range from this anchor.
*/
private __selectionAnchorId?: string;
/**
* Cell currently focused for keyboard navigation. When set, the cell shows
* a focus ring and Enter/F2 enters edit mode. Independent from row selection.
*/
@state()
private accessor __focusedCell: { rowId: string; colKey: string } | undefined = undefined;
/**
* Cell currently being edited. When set, that cell renders an editor
* (dees-input-*) instead of its display content.
*/
@state()
private accessor __editingCell: { rowId: string; colKey: string } | undefined = undefined;
/**
* True while the page-sticky floating header overlay is visible. Lifted
* to @state so the floating-header clone subtree is rendered only when
* needed (saves a full thead worth of cells per render when inactive).
*/
@state()
private accessor __floatingActive: boolean = false;
// ─── Render memoization ──────────────────────────────────────────────
// These caches let render() short-circuit when the relevant inputs
// (by reference) haven't changed. They are NOT @state — mutating them
// must never trigger a re-render.
private __memoEffectiveCols?: {
columns: any;
augment: boolean;
displayFunction: any;
data: any;
out: Column<T>[];
};
private __memoViewData?: {
data: any;
sortBy: any;
filterText: string;
columnFilters: any;
searchMode: string;
effectiveColumns: Column<T>[];
out: T[];
};
/** Tracks the (data, columns) pair that `determineColumnWidths()` last sized for. */
private __columnsSizedFor?: { data: any; columns: any };
// ─── Virtualization state ────────────────────────────────────────────
/** Estimated row height (px). Measured once from the first rendered row. */
private __rowHeight: number = 36;
/** True once we've measured `__rowHeight` from a real DOM row. */
private __rowHeightMeasured: boolean = false;
/** Currently rendered range [start, end). Triggers re-render when changed. */
@state()
private accessor __virtualRange: { start: number; end: number } = { start: 0, end: 0 };
2021-10-07 18:01:05 +02:00
constructor() {
super();
// Make the host focusable so it can receive Ctrl/Cmd+C for copy.
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
this.addEventListener('keydown', this.__handleHostKeydown);
}
/**
* Ctrl/Cmd+C copies the currently selected rows as a JSON array. Falls
* back to copying the focused-row (`selectedDataRow`) if no multi
* selection exists. No-op if a focused input/textarea would normally
* receive the copy.
*/
private __handleHostKeydown = (eventArg: KeyboardEvent) => {
// Detect whether the keydown originated inside an editor (input/textarea
// or contenteditable). Used to skip both copy hijacking and grid nav.
const path = (eventArg.composedPath?.() || []) as EventTarget[];
let inEditor = false;
for (const t of path) {
const tag = (t as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (t as HTMLElement)?.isContentEditable) {
inEditor = true;
break;
}
}
// Ctrl/Cmd+C → copy selected rows as JSON (unless typing in an input).
const isCopy =
(eventArg.metaKey || eventArg.ctrlKey) && (eventArg.key === 'c' || eventArg.key === 'C');
if (isCopy) {
if (inEditor) return;
const rows: T[] = [];
if (this.selectedIds.size > 0) {
for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
} else if (this.selectedDataRow) {
rows.push(this.selectedDataRow);
}
if (rows.length === 0) return;
eventArg.preventDefault();
this.__writeRowsAsJson(rows);
return;
}
// Cell navigation only when no editor is open.
if (inEditor || this.__editingCell) return;
switch (eventArg.key) {
case 'ArrowLeft':
eventArg.preventDefault();
this.moveFocusedCell(-1, 0, false);
return;
case 'ArrowRight':
eventArg.preventDefault();
this.moveFocusedCell(+1, 0, false);
return;
case 'ArrowUp':
eventArg.preventDefault();
this.moveFocusedCell(0, -1, false);
return;
case 'ArrowDown':
eventArg.preventDefault();
this.moveFocusedCell(0, +1, false);
return;
case 'Enter':
case 'F2': {
if (!this.__focusedCell) return;
const view: T[] = (this as any)._lastViewData ?? [];
const item = view.find((r) => this.getRowId(r) === this.__focusedCell!.rowId);
if (!item) return;
const allCols: Column<T>[] =
Array.isArray(this.columns) && this.columns.length > 0
? computeEffectiveColumnsFn(
this.columns,
this.augmentFromDisplayFunction,
this.displayFunction,
this.data
)
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
const col = allCols.find((c) => String(c.key) === this.__focusedCell!.colKey);
if (!col || !this.__isColumnEditable(col)) return;
eventArg.preventDefault();
this.startEditing(item, col);
return;
}
case 'Escape':
if (this.__focusedCell) {
this.__focusedCell = undefined;
this.requestUpdate();
}
return;
default:
return;
}
};
/**
* Copies the current selection as a JSON array. If `fallbackRow` is given
* and there is no multi-selection, that row is copied instead. Used both
* by the Ctrl/Cmd+C handler and by the default context-menu action.
*/
public copySelectionAsJson(fallbackRow?: T) {
const rows: T[] = [];
if (this.selectedIds.size > 0) {
for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
} else if (fallbackRow) {
rows.push(fallbackRow);
} else if (this.selectedDataRow) {
rows.push(this.selectedDataRow);
}
if (rows.length === 0) return;
this.__writeRowsAsJson(rows);
}
private __writeRowsAsJson(rows: T[]) {
try {
const json = JSON.stringify(rows, null, 2);
navigator.clipboard?.writeText(json);
} catch {
/* ignore — clipboard may be unavailable */
}
2021-10-07 18:01:05 +02:00
}
public static styles = tableStyles;
2021-10-07 18:01:05 +02:00
/**
* Returns the effective column schema, memoized by reference of the inputs
* that affect it. Avoids re-running `computeEffectiveColumnsFn` /
* `computeColumnsFromDisplayFunctionFn` on every Lit update.
*/
private __getEffectiveColumns(): Column<T>[] {
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
const cache = this.__memoEffectiveCols;
if (
cache &&
cache.columns === this.columns &&
cache.augment === this.augmentFromDisplayFunction &&
cache.displayFunction === this.displayFunction &&
cache.data === this.data
) {
return cache.out;
}
const out = usingColumns
? computeEffectiveColumnsFn(
this.columns,
this.augmentFromDisplayFunction,
this.displayFunction,
this.data
)
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
this.__memoEffectiveCols = {
columns: this.columns,
augment: this.augmentFromDisplayFunction,
displayFunction: this.displayFunction,
data: this.data,
out,
};
return out;
}
/**
* Returns the sorted/filtered view of the data, memoized by reference of
* everything that affects it. Avoids re-running the lucene compiler and
* the sort/filter pipeline on every render.
*/
private __getViewData(effectiveColumns: Column<T>[]): T[] {
const searchMode = this.searchMode === 'data' ? 'data' : 'table';
const cache = this.__memoViewData;
if (
cache &&
cache.data === this.data &&
cache.sortBy === this.sortBy &&
cache.filterText === this.filterText &&
cache.columnFilters === this.columnFilters &&
cache.searchMode === searchMode &&
cache.effectiveColumns === effectiveColumns
) {
return cache.out;
}
const lucenePred = compileLucenePredicate<T>(this.filterText, searchMode, effectiveColumns);
const out = getViewDataFn(
this.data,
effectiveColumns,
this.sortBy,
this.filterText,
this.columnFilters,
searchMode,
lucenePred || undefined
);
this.__memoViewData = {
data: this.data,
sortBy: this.sortBy,
filterText: this.filterText,
columnFilters: this.columnFilters,
searchMode,
effectiveColumns,
out,
};
return out;
}
public render(): TemplateResult {
const effectiveColumns = this.__getEffectiveColumns();
const viewData = this.__getViewData(effectiveColumns);
(this as any)._lastViewData = viewData;
// Virtualization slice — only the rows in `__virtualRange` actually
// render. Top/bottom spacer rows preserve scroll geometry.
const useVirtual = this.virtualized && viewData.length > 0;
let renderRows: T[] = viewData;
let renderStart = 0;
let topSpacerHeight = 0;
let bottomSpacerHeight = 0;
if (useVirtual) {
const range = this.__virtualRange;
const start = Math.max(0, range.start);
const end = Math.min(viewData.length, range.end || 0);
// On the very first render the range is {0,0} — render a small first
// window so we can measure row height and compute the real range.
const initialEnd = end > 0 ? end : Math.min(viewData.length, this.virtualOverscan * 2 + 16);
renderStart = start;
renderRows = viewData.slice(start, initialEnd);
topSpacerHeight = start * this.__rowHeight;
bottomSpacerHeight = Math.max(0, viewData.length - initialEnd) * this.__rowHeight;
}
2021-10-07 18:01:05 +02:00
return html`
<dees-tile>
<div slot="header" class="header">
2023-09-04 19:28:50 +02:00
<div class="headingContainer">
2023-10-17 20:07:45 +02:00
<div class="heading heading1">${this.label || this.heading1}</div>
2023-09-04 19:28:50 +02:00
<div class="heading heading2">${this.heading2}</div>
</div>
<div class="headerActions">
${directives.resolveExec(async () => {
2023-09-04 19:28:50 +02:00
const resultArray: TemplateResult[] = [];
for (const action of this.dataActions) {
if (!action.type?.includes('header')) continue;
2023-09-04 19:28:50 +02:00
resultArray.push(
html`<div
class="headerAction"
@click=${() => {
2023-09-22 19:04:02 +02:00
action.actionFunc({
item: this.selectedDataRow,
2023-09-22 20:02:48 +02:00
table: this,
2023-09-22 19:04:02 +02:00
});
2023-09-04 19:28:50 +02:00
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
2023-09-04 19:28:50 +02:00
${action.name}`
: action.name}
</div>`
);
}
return resultArray;
})}
</div>
</div>
2021-10-07 18:01:05 +02:00
<div class="headingSeparation"></div>
2024-01-21 22:37:39 +01:00
<div class="searchGrid hidden">
2024-01-21 01:42:06 +01:00
<dees-input-text
.label=${'lucene syntax search'}
.description=${`
You can use the lucene syntax to search for data, e.g.:
\`\`\`
name: "john" AND age: 18
\`\`\`
2024-01-21 14:14:57 +01:00
`}
></dees-input-text>
2024-01-21 01:12:57 +01:00
<dees-input-multitoggle
.label=${'search mode'}
.options=${['table', 'data', 'server']}
2024-01-21 13:36:47 +01:00
.selectedOption=${'table'}
2024-01-21 01:42:06 +01:00
.description=${`
There are three basic modes:
* table: only searches data already in the table
* data: searches original data, ignoring table transforms
* server: searches data on the server
`}
2024-01-21 01:12:57 +01:00
></dees-input-multitoggle>
</div>
2023-10-24 14:18:03 +02:00
2021-10-07 18:01:05 +02:00
<!-- the actual table -->
<style></style>
${this.data.length > 0
? html`
<div class="tableScroll">
<table>
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
<tbody
@click=${this.__onTbodyClick}
@dblclick=${this.__onTbodyDblclick}
@mousedown=${this.__onTbodyMousedown}
@contextmenu=${this.__onTbodyContextmenu}
@dragenter=${this.__onTbodyDragenter}
@dragleave=${this.__onTbodyDragleave}
@dragover=${this.__onTbodyDragover}
@drop=${this.__onTbodyDrop}
>
${useVirtual && topSpacerHeight > 0
? html`<tr aria-hidden="true" style="height:${topSpacerHeight}px"><td></td></tr>`
: html``}
${renderRows.map((itemArg, sliceIdx) => {
const rowIndex = renderStart + sliceIdx;
const rowId = this.getRowId(itemArg);
2023-09-04 19:28:50 +02:00
return html`
2021-10-07 18:47:36 +02:00
<tr
data-row-idx=${rowIndex}
class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
2021-10-07 18:47:36 +02:00
>
${this.showSelectionCheckbox
? html`<td style="width:42px; text-align:center;">
<dees-input-checkbox
.value=${this.isRowSelected(itemArg)}
@newValue=${(e: CustomEvent<boolean>) => {
e.stopPropagation();
this.setRowSelected(itemArg, e.detail === true);
}}
></dees-input-checkbox>
</td>`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col, colIndex) => {
const value = getCellValueFn(itemArg, col, this.displayFunction);
const content = col.renderer
? col.renderer(value, itemArg, { rowIndex, colIndex, column: col })
: value;
const editKey = String(col.key);
const isEditable = !!(col.editable || col.editor);
const isFocused =
this.__focusedCell?.rowId === rowId &&
this.__focusedCell?.colKey === editKey;
const isEditing =
this.__editingCell?.rowId === rowId &&
this.__editingCell?.colKey === editKey;
const cellClasses = [
isEditable ? 'editable' : '',
isFocused && !isEditing ? 'focused' : '',
isEditing ? 'editingCell' : '',
]
.filter(Boolean)
.join(' ');
return html`
<td
class=${cellClasses}
data-col-key=${editKey}
>
<div class="innerCellContainer">
${isEditing ? this.renderCellEditor(itemArg, col) : content}
</div>
</td>
`;
})}
2022-12-06 13:11:06 +01:00
${(() => {
2022-12-11 17:24:12 +01:00
if (this.dataActions && this.dataActions.length > 0) {
2022-12-06 13:11:06 +01:00
return html`
<td class="actionsCol">
2025-06-27 17:50:54 +00:00
<div class="actionsContainer">
${this.getActionsForType('inRow').map(
(actionArg) => html`
<div
class="action"
@click=${() =>
actionArg.actionFunc({
item: itemArg,
table: this,
})}
>
${actionArg.iconName
? html` <dees-icon .icon=${actionArg.iconName}></dees-icon> `
: actionArg.name}
</div>
`
)}
2022-12-06 13:11:06 +01:00
</div>
</td>
`;
}
})()}
</tr>`;
2023-09-04 19:28:50 +02:00
})}
${useVirtual && bottomSpacerHeight > 0
? html`<tr aria-hidden="true" style="height:${bottomSpacerHeight}px"><td></td></tr>`
: html``}
</tbody>
</table>
</div>
<div class="floatingHeader" aria-hidden="true">
${this.__floatingActive
? html`<table>
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
</table>`
: html``}
</div>
`
2021-10-07 18:01:05 +02:00
: html` <div class="noDataSet">No data set!</div> `}
<div slot="footer" class="footer">
2023-09-04 19:28:50 +02:00
<div class="tableStatistics">
${this.data.length} ${this.dataName || 'data rows'} (total) |
${this.selectedDataRow ? '# ' + `${this.data.indexOf(this.selectedDataRow) + 1}` : `No`}
selected
</div>
<div class="footerActions">
${directives.resolveExec(async () => {
2023-09-04 19:28:50 +02:00
const resultArray: TemplateResult[] = [];
for (const action of this.dataActions) {
if (!action.type?.includes('footer')) continue;
2023-09-04 19:28:50 +02:00
resultArray.push(
html`<div
class="footerAction"
@click=${() => {
2023-09-22 19:04:02 +02:00
action.actionFunc({
item: this.selectedDataRow,
2023-09-22 20:02:48 +02:00
table: this,
2023-09-22 19:04:02 +02:00
});
2023-09-04 19:28:50 +02:00
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
2023-09-04 19:28:50 +02:00
${action.name}`
: action.name}
</div>`
);
}
return resultArray;
})}
</div>
2021-10-07 18:01:05 +02:00
</div>
</dees-tile>
2021-10-07 18:01:05 +02:00
`;
}
/**
* Renders the header rows. Used twice per render: once inside the real
* `<thead>` and once inside the floating-header clone, so sort indicators
* and filter inputs stay in sync automatically.
*/
private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult {
return html`
<tr>
${this.showSelectionCheckbox
? html`
<th style="width:42px; text-align:center;">
${this.selectionMode === 'multi'
? html`
<dees-input-checkbox
.value=${this.areAllVisibleSelected()}
.indeterminate=${this.isVisibleSelectionIndeterminate()}
@newValue=${(e: CustomEvent<boolean>) => {
e.stopPropagation();
this.setSelectVisible(e.detail === true);
}}
></dees-input-checkbox>
`
: html``}
</th>
`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col) => {
2026-04-07 21:31:43 +00:00
const isSortable = col.sortable !== false;
const ariaSort = this.getAriaSort(col);
return html`
<th
role="columnheader"
aria-sort=${ariaSort}
style="${isSortable ? 'cursor: pointer;' : ''}"
@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)}
</th>`;
})}
${this.dataActions && this.dataActions.length > 0
? html`<th class="actionsCol">Actions</th>`
: html``}
</tr>
${this.showColumnFilters
? html`<tr class="filtersRow">
${this.showSelectionCheckbox
? html`<th style="width:42px;"></th>`
: html``}
${effectiveColumns
.filter((c) => !c.hidden)
.map((col) => {
const key = String(col.key);
if (col.filterable === false) return html`<th></th>`;
return html`<th>
<input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
@input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
</th>`;
})}
${this.dataActions && this.dataActions.length > 0
? html`<th></th>`
: html``}
</tr>`
: html``}
`;
}
// ─── Floating header (page-sticky) lifecycle ─────────────────────────
private __floatingResizeObserver?: ResizeObserver;
private __floatingScrollHandler?: () => void;
// __floatingActive is declared as a @state field above so its toggle
// triggers re-rendering of the floating-header clone subtree.
private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
private get __floatingHeaderEl(): HTMLDivElement | null {
return this.shadowRoot?.querySelector('.floatingHeader') ?? null;
}
private get __realTableEl(): HTMLTableElement | null {
return this.shadowRoot?.querySelector('.tableScroll > table') ?? null;
}
private get __floatingTableEl(): HTMLTableElement | null {
return this.shadowRoot?.querySelector('.floatingHeader > table') ?? null;
}
/**
* Walks up the DOM (and through shadow roots) collecting every ancestor
* element whose computed `overflow-y` makes it a scroll container, plus
* `window` at the end. We listen for scroll on all of them so the floating
* header reacts whether the user scrolls the page or any nested container.
*/
private __collectScrollAncestors(): Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> {
const result: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = [];
let node: Node | null = this as unknown as Node;
const scrollish = (v: string) => v === 'auto' || v === 'scroll' || v === 'overlay';
while (node) {
if (node instanceof Element) {
const style = getComputedStyle(node);
const sy = scrollish(style.overflowY);
const sx = scrollish(style.overflowX);
if (sy || sx) {
result.push({ target: node, scrollsY: sy, scrollsX: sx });
}
}
const parent = (node as any).assignedSlot
? (node as any).assignedSlot
: node.parentNode;
if (parent) {
node = parent;
} else if ((node as ShadowRoot).host) {
node = (node as ShadowRoot).host;
} else {
node = null;
}
}
result.push({ target: window, scrollsY: true, scrollsX: true });
return result;
}
/**
* Returns the "stick line" the y-coordinate (in viewport space) at which
* the floating header should appear. Defaults to 0 (page top), but if the
* table is inside a scroll container we use that container's content-box
* top so the header sits inside the container's border/padding instead of
* floating over it.
*/
private __getStickContext(): { top: number; left: number; right: number } {
let top = 0;
let left = 0;
let right = window.innerWidth;
for (const a of this.__scrollAncestors) {
if (a.target === window) continue;
const el = a.target as Element;
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
// Only constrain top from ancestors that actually scroll vertically —
// a horizontal-only scroll container (like .tableScroll) must not push
// the stick line down to its own top.
if (a.scrollsY) {
const bt = parseFloat(cs.borderTopWidth) || 0;
top = Math.max(top, r.top + bt);
}
// Same for horizontal clipping.
if (a.scrollsX) {
const bl = parseFloat(cs.borderLeftWidth) || 0;
const br = parseFloat(cs.borderRightWidth) || 0;
left = Math.max(left, r.left + bl);
right = Math.min(right, r.right - br);
}
}
return { top, left, right };
}
private setupFloatingHeader() {
this.teardownFloatingHeader();
// Skip entirely only when neither feature needs scroll watchers.
if (this.fixedHeight && !this.virtualized) return;
const realTable = this.__realTableEl;
if (!realTable) return;
this.__scrollAncestors = this.__collectScrollAncestors();
// .tableScroll is a descendant (inside our shadow root), not an ancestor,
// so the upward walk above misses it. Add it explicitly. In Mode A
// (`fixedHeight`) it is the only vertical scroll source — mark it as
// scrollsY in that case so virtualization picks it up.
const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
if (tableScrollEl) {
this.__scrollAncestors.unshift({
target: tableScrollEl,
scrollsY: this.fixedHeight,
scrollsX: true,
});
}
// Track resize of the real table so we can mirror its width and column widths.
this.__floatingResizeObserver = new ResizeObserver(() => {
if (!this.fixedHeight) this.__syncFloatingHeader();
if (this.virtualized) this.__computeVirtualRange();
});
this.__floatingResizeObserver.observe(realTable);
this.__floatingScrollHandler = () => {
if (!this.fixedHeight) this.__syncFloatingHeader();
// Recompute virtual range on every scroll — cheap (one rect read +
// some math) and necessary so rows materialize before they're seen.
if (this.virtualized) this.__computeVirtualRange();
};
for (const a of this.__scrollAncestors) {
a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
}
window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
if (!this.fixedHeight) this.__syncFloatingHeader();
if (this.virtualized) this.__computeVirtualRange();
}
private teardownFloatingHeader() {
this.__floatingResizeObserver?.disconnect();
this.__floatingResizeObserver = undefined;
if (this.__floatingScrollHandler) {
for (const a of this.__scrollAncestors) {
a.target.removeEventListener('scroll', this.__floatingScrollHandler);
}
window.removeEventListener('resize', this.__floatingScrollHandler);
this.__floatingScrollHandler = undefined;
}
this.__scrollAncestors = [];
this.__floatingActive = false;
const fh = this.__floatingHeaderEl;
if (fh) fh.classList.remove('active');
}
// ─── Virtualization ─────────────────────────────────────────────────
/**
* Computes the visible row range based on the table's position in its
* nearest vertical scroll ancestor (or the viewport). Updates
* `__virtualRange` if it changed; that triggers a Lit re-render.
*/
private __computeVirtualRange() {
if (!this.virtualized) return;
const view: T[] = (this as any)._lastViewData ?? [];
const total = view.length;
if (total === 0) {
if (this.__virtualRange.start !== 0 || this.__virtualRange.end !== 0) {
this.__virtualRange = { start: 0, end: 0 };
}
return;
}
const realTable = this.__realTableEl;
if (!realTable) return;
const tableRect = realTable.getBoundingClientRect();
// Find the innermost vertical scroll ancestor (rect + content height).
let viewportTop = 0;
let viewportBottom = window.innerHeight;
for (const a of this.__scrollAncestors) {
if (a.target === window || !a.scrollsY) continue;
const r = (a.target as Element).getBoundingClientRect();
const cs = getComputedStyle(a.target as Element);
const bt = parseFloat(cs.borderTopWidth) || 0;
const bb = parseFloat(cs.borderBottomWidth) || 0;
viewportTop = Math.max(viewportTop, r.top + bt);
viewportBottom = Math.min(viewportBottom, r.bottom - bb);
}
const rowH = Math.max(1, this.__rowHeight);
// Distance from the table top to the visible window top, in px of body
// content (so any header offset above the rows is excluded).
const headerHeight = realTable.tHead?.getBoundingClientRect().height ?? 0;
const bodyTop = tableRect.top + headerHeight;
const offsetIntoBody = Math.max(0, viewportTop - bodyTop);
const visiblePx = Math.max(0, viewportBottom - Math.max(viewportTop, bodyTop));
const startRaw = Math.floor(offsetIntoBody / rowH);
const visibleCount = Math.ceil(visiblePx / rowH) + 1;
const start = Math.max(0, startRaw - this.virtualOverscan);
const end = Math.min(total, startRaw + visibleCount + this.virtualOverscan);
if (start !== this.__virtualRange.start || end !== this.__virtualRange.end) {
this.__virtualRange = { start, end };
}
}
/**
* Measures the height of the first rendered body row and stores it for
* subsequent virtualization math. Idempotent only measures once per
* `data`/`columns` pair (cleared in `updated()` when those change).
*/
private __measureRowHeight() {
if (!this.virtualized || this.__rowHeightMeasured) return;
const tbody = this.shadowRoot?.querySelector('tbody') as HTMLTableSectionElement | null;
if (!tbody) return;
const firstRow = Array.from(tbody.rows).find((r) => r.hasAttribute('data-row-idx'));
if (!firstRow) return;
const h = firstRow.getBoundingClientRect().height;
if (h > 0) {
this.__rowHeight = h;
this.__rowHeightMeasured = true;
}
}
/**
* Single function that drives both activation and geometry of the floating
* header. Called on scroll, resize, table-resize, and after relevant
* renders.
*
* Activation is decided from the *real* header geometry, so this function
* works even when the clone subtree hasn't been rendered yet (it's only
* rendered when `__floatingActive` is true). The first activation flips
* `__floatingActive`; the next render materializes the clone; the next
* call here mirrors widths and positions.
*/
private __syncFloatingHeader() {
const fh = this.__floatingHeaderEl;
const realTable = this.__realTableEl;
if (!fh || !realTable) return;
const tableRect = realTable.getBoundingClientRect();
const stick = this.__getStickContext();
const realHeadRows = realTable.tHead?.rows;
let headerHeight = 0;
if (realHeadRows) {
for (let r = 0; r < realHeadRows.length; r++) {
headerHeight += realHeadRows[r].getBoundingClientRect().height;
}
}
2026-04-07 21:31:43 +00:00
// Active when the table top is above the stick line and any pixel of the
// table still sits below it. As the table's bottom edge approaches the
// stick line we shrink the floating container and slide the cloned header
// up inside it, so the header appears to scroll off with the table
// instead of snapping away in one frame.
const distance = tableRect.bottom - stick.top;
const shouldBeActive = tableRect.top < stick.top && distance > 0;
if (shouldBeActive !== this.__floatingActive) {
this.__floatingActive = shouldBeActive;
fh.classList.toggle('active', shouldBeActive);
2026-04-07 21:31:43 +00:00
if (!shouldBeActive) {
// Reset inline geometry so the next activation starts clean.
fh.style.height = '';
const ft = this.__floatingTableEl;
if (ft) ft.style.transform = '';
}
if (shouldBeActive) {
// Clone subtree doesn't exist yet — wait for the next render to
// materialize it, then complete geometry sync.
this.updateComplete.then(() => this.__syncFloatingHeader());
return;
}
}
if (!shouldBeActive) return;
// Mirror table layout + per-cell widths so columns line up. The clone
// exists at this point because __floatingActive === true.
const floatTable = this.__floatingTableEl;
if (!floatTable) return;
floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
const floatHeadRows = floatTable.tHead?.rows;
if (realHeadRows && floatHeadRows) {
for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
const realCells = realHeadRows[r].cells;
const floatCells = floatHeadRows[r].cells;
for (let c = 0; c < realCells.length && c < floatCells.length; c++) {
const w = realCells[c].getBoundingClientRect().width;
(floatCells[c] as HTMLElement).style.width = `${w}px`;
(floatCells[c] as HTMLElement).style.minWidth = `${w}px`;
(floatCells[c] as HTMLElement).style.maxWidth = `${w}px`;
}
}
}
// Position the floating header. Clip horizontally to the scroll context
// so a horizontally-scrolled inner container's header doesn't bleed
// outside the container's border.
const clipLeft = Math.max(tableRect.left, stick.left);
const clipRight = Math.min(tableRect.right, stick.right);
const clipWidth = Math.max(0, clipRight - clipLeft);
fh.style.top = `${stick.top}px`;
fh.style.left = `${clipLeft}px`;
fh.style.width = `${clipWidth}px`;
2026-04-07 21:31:43 +00:00
// Exit animation: when the table's bottom edge is within `headerHeight`
// pixels of the stick line, shrink the container and translate the
// inner table up by the same amount. overflow:hidden on .floatingHeader
// clips the overflow, producing a scroll-off effect.
const visibleHeight = Math.min(headerHeight, distance);
const exitOffset = headerHeight - visibleHeight;
fh.style.height = `${visibleHeight}px`;
// The inner table is positioned so the visible region matches the real
// table's left edge — shift it left when we clipped to the container.
floatTable.style.width = `${tableRect.width}px`;
floatTable.style.marginLeft = `${tableRect.left - clipLeft}px`;
2026-04-07 21:31:43 +00:00
floatTable.style.transform = exitOffset > 0 ? `translateY(-${exitOffset}px)` : '';
}
public async disconnectedCallback() {
super.disconnectedCallback();
this.teardownFloatingHeader();
}
2024-01-21 22:37:39 +01:00
public async firstUpdated() {
// Floating-header observers are wired up in `updated()` once the
// table markup actually exists (it only renders when data.length > 0).
2024-01-21 22:37:39 +01:00
}
2023-09-12 13:42:55 +02:00
2023-09-16 14:31:03 +02:00
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
super.updated(changedProperties);
// Only re-measure column widths when the data or schema actually changed
// (or on first paint). `determineColumnWidths` is the single biggest
// first-paint cost — it forces multiple layout flushes per row.
const dataOrColsChanged =
!this.__columnsSizedFor ||
this.__columnsSizedFor.data !== this.data ||
this.__columnsSizedFor.columns !== this.columns;
if (dataOrColsChanged) {
this.__columnsSizedFor = { data: this.data, columns: this.columns };
this.determineColumnWidths();
// Force re-measure of row height; structure may have changed.
this.__rowHeightMeasured = false;
}
// Virtualization: measure row height after the first paint with rows,
// then compute the visible range. Both ops only run when `virtualized`
// is true, so the cost is zero for normal tables.
if (this.virtualized) {
this.__measureRowHeight();
this.__computeVirtualRange();
}
// (Re)wire the scroll watchers (used by both the floating header in
// Mode B and by virtualization). Skip entirely only when neither
// feature needs them.
if (
changedProperties.has('fixedHeight') ||
changedProperties.has('virtualized') ||
changedProperties.has('data') ||
changedProperties.has('columns') ||
!this.__floatingScrollHandler
) {
const needsScrollWatchers = (!this.fixedHeight || this.virtualized) && this.data.length > 0;
if (needsScrollWatchers) {
this.setupFloatingHeader();
} else {
this.teardownFloatingHeader();
}
}
// Only sync the floating header geometry when it's actually showing or
// the table layout-affecting state changed. Avoids per-render layout
// reads (getBoundingClientRect on every header cell) for typical updates
// like sort changes or selection toggles.
if (
!this.fixedHeight &&
this.data.length > 0 &&
(this.__floatingActive || dataOrColsChanged)
) {
this.__syncFloatingHeader();
}
2024-01-21 22:37:39 +01:00
if (this.searchable) {
const existing = this.dataActions.find((actionArg) => actionArg.type?.includes('header') && actionArg.name === 'Search');
2024-01-21 22:37:39 +01:00
if (!existing) {
this.dataActions.unshift({
name: 'Search',
iconName: 'lucide:Search',
2024-01-21 22:37:39 +01:00
type: ['header'],
actionFunc: async () => {
console.log('open search');
const searchGrid = this.shadowRoot!.querySelector('.searchGrid');
searchGrid!.classList.toggle('hidden');
2024-01-21 22:37:39 +01:00
}
});
console.log(this.dataActions);
this.requestUpdate();
};
// wire search inputs
this.wireSearchInputs();
}
}
private __debounceTimer?: any;
private debounceRun(fn: () => void, ms = 200) {
if (this.__debounceTimer) clearTimeout(this.__debounceTimer);
this.__debounceTimer = setTimeout(fn, ms);
}
private wireSearchInputs() {
const searchTextEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-text');
const searchModeEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-multitoggle');
if (searchTextEl && !this.__searchTextSub) {
this.__searchTextSub = searchTextEl.changeSubject.subscribe((el: any) => {
const val: string = el?.value ?? '';
this.debounceRun(() => {
if (this.searchMode === 'server') {
this.dispatchEvent(
new CustomEvent('searchRequest', {
detail: { query: val, mode: 'server' },
bubbles: true,
})
);
} else {
this.setFilterText(val);
}
});
});
}
if (searchModeEl && !this.__searchModeSub) {
this.__searchModeSub = searchModeEl.changeSubject.subscribe((el: any) => {
const mode: string = el?.selectedOption || el?.value || 'table';
if (mode === 'table' || mode === 'data' || mode === 'server') {
this.searchMode = mode as any;
// When switching modes, re-apply current text input
const val: string = searchTextEl?.value ?? '';
this.debounceRun(() => {
if (this.searchMode === 'server') {
this.dispatchEvent(new CustomEvent('searchRequest', { detail: { query: val, mode: 'server' }, bubbles: true }));
} else {
this.setFilterText(val);
}
});
}
});
2024-01-21 22:37:39 +01:00
}
2023-09-16 14:31:03 +02:00
}
2023-10-20 10:47:53 +02:00
public async determineColumnWidths() {
const domtools = await this.domtoolsPromise;
await domtools.convenience.smartdelay.delayFor(0);
2023-09-16 14:31:03 +02:00
// Get the table element
const table = this.shadowRoot!.querySelector('table');
2023-09-16 14:31:03 +02:00
if (!table) return;
2023-09-17 21:38:02 +02:00
2023-09-16 14:31:03 +02:00
// Get the first row's cells to measure the widths
const cells = table.rows[0].cells;
2023-09-17 21:38:02 +02:00
2023-10-20 10:47:53 +02:00
const handleColumnByIndex = async (i: number, waitForRenderArg: boolean = false) => {
const done = plugins.smartpromise.defer();
2023-09-16 14:31:03 +02:00
const cell = cells[i];
2023-09-17 21:38:02 +02:00
2023-09-16 14:31:03 +02:00
// Get computed width
const width = window.getComputedStyle(cell).width;
2023-10-20 10:47:53 +02:00
if (cell.textContent.includes('Actions')) {
2023-10-24 14:18:03 +02:00
const neededWidth =
this.dataActions.filter((actionArg) => actionArg.type?.includes('inRow')).length * 36;
2023-10-20 11:17:42 +02:00
cell.style.width = `${Math.max(neededWidth, 68)}px`;
2023-10-20 10:47:53 +02:00
} else {
cell.style.width = width;
}
if (waitForRenderArg) {
requestAnimationFrame(() => {
done.resolve();
});
await done.promise;
2023-09-16 14:31:03 +02:00
}
2023-10-24 14:18:03 +02:00
};
2023-10-20 10:47:53 +02:00
if (cells[cells.length - 1].textContent.includes('Actions')) {
await handleColumnByIndex(cells.length - 1, true);
}
2023-09-17 21:38:02 +02:00
2023-10-20 10:47:53 +02:00
for (let i = 0; i < cells.length; i++) {
if (cells[i].textContent.includes('Actions')) {
continue;
}
await handleColumnByIndex(i);
2023-09-16 14:31:03 +02:00
}
2023-10-20 10:47:53 +02:00
table.style.tableLayout = 'fixed';
2023-09-16 14:31:03 +02:00
}
// compute helpers moved to ./data.ts
// ─── 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.
2026-04-07 21:31:43 +00:00
const sortableColumnCount = effectiveColumns.filter((c) => c.sortable !== false).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;
},
});
}
2026-04-07 21:31:43 +00:00
items.push({ divider: true });
items.push({
name: this.showColumnFilters ? 'Hide column filters' : 'Show column filters',
iconName: this.showColumnFilters ? 'lucide:filterX' : 'lucide:filter',
action: async () => {
this.showColumnFilters = !this.showColumnFilters;
this.requestUpdate();
return null;
},
});
return items;
}
// ─── sort: indicator + ARIA ──────────────────────────────────────────
private getAriaSort(col: Column<T>): 'none' | '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>) {
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
public setFilterText(value: string) {
const prev = this.filterText;
this.filterText = value ?? '';
if (prev !== this.filterText) {
this.dispatchEvent(
new CustomEvent('filterChange', {
detail: { text: this.filterText, columns: { ...this.columnFilters } },
bubbles: true,
})
);
this.requestUpdate();
}
}
public setColumnFilter(key: string, value: string) {
this.columnFilters = { ...this.columnFilters, [key]: value };
this.dispatchEvent(
new CustomEvent('filterChange', {
detail: { text: this.filterText, columns: { ...this.columnFilters } },
bubbles: true,
})
);
this.requestUpdate();
}
// selection helpers
private getRowId(row: T): string {
if (this.rowKey) {
if (typeof this.rowKey === 'function') return this.rowKey(row);
return String((row as any)[this.rowKey]);
}
const key = row as any as object;
if (!this._rowIdMap.has(key)) {
this._rowIdMap.set(key, String(++this._rowIdCounter));
}
return this._rowIdMap.get(key)!;
}
private isRowSelected(row: T): boolean {
return this.selectedIds.has(this.getRowId(row));
}
private toggleRowSelected(row: T) {
const id = this.getRowId(row);
if (this.selectionMode === 'single') {
this.selectedIds.clear();
this.selectedIds.add(id);
} else if (this.selectionMode === 'multi') {
if (this.selectedIds.has(id)) this.selectedIds.delete(id);
else this.selectedIds.add(id);
}
this.emitSelectionChange();
this.requestUpdate();
}
// ─── Delegated tbody event handlers ─────────────────────────────────
// Hoisted from per-<tr> closures to a single set of handlers on <tbody>.
// Cuts ~7 closure allocations per row per render. Each handler resolves
// the source row via `data-row-idx` (and `data-col-key` for cell-level
// events) using the latest `_lastViewData`.
private __resolveRow(eventArg: Event): { item: T; rowIdx: number } | null {
const path = (eventArg.composedPath?.() || []) as EventTarget[];
let tr: HTMLTableRowElement | null = null;
for (const t of path) {
const el = t as HTMLElement;
if (el?.tagName === 'TR' && el.hasAttribute('data-row-idx')) {
tr = el as HTMLTableRowElement;
break;
}
}
if (!tr) return null;
const rowIdx = Number(tr.getAttribute('data-row-idx'));
const view: T[] = (this as any)._lastViewData ?? [];
const item = view[rowIdx];
if (!item) return null;
return { item, rowIdx };
}
private __resolveCell(eventArg: Event): { item: T; rowIdx: number; col: Column<T> } | null {
const row = this.__resolveRow(eventArg);
if (!row) return null;
const path = (eventArg.composedPath?.() || []) as EventTarget[];
let td: HTMLTableCellElement | null = null;
for (const t of path) {
const el = t as HTMLElement;
if (el?.tagName === 'TD' && el.hasAttribute('data-col-key')) {
td = el as HTMLTableCellElement;
break;
}
}
if (!td) return null;
const colKey = td.getAttribute('data-col-key')!;
const cols = this.__getEffectiveColumns();
const col = cols.find((c) => String(c.key) === colKey);
if (!col) return null;
return { item: row.item, rowIdx: row.rowIdx, col };
}
private __isInActionsCol(eventArg: Event): boolean {
const path = (eventArg.composedPath?.() || []) as EventTarget[];
for (const t of path) {
const el = t as HTMLElement;
if (el?.classList?.contains('actionsCol')) return true;
}
return false;
}
private __isInEditor(eventArg: Event): boolean {
const path = (eventArg.composedPath?.() || []) as EventTarget[];
for (const t of path) {
const el = t as HTMLElement;
const tag = el?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || el?.isContentEditable) return true;
if (tag && tag.startsWith('DEES-INPUT-')) return true;
}
return false;
}
private __onTbodyClick = (eventArg: MouseEvent) => {
if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return;
const cell = this.__resolveCell(eventArg);
if (!cell) return;
const view: T[] = (this as any)._lastViewData ?? [];
// Cell focus (when editable)
if (cell.col.editable || cell.col.editor) {
this.__focusedCell = {
rowId: this.getRowId(cell.item),
colKey: String(cell.col.key),
};
}
// Row selection (file-manager style)
this.handleRowClick(eventArg, cell.item, cell.rowIdx, view);
};
private __onTbodyDblclick = (eventArg: MouseEvent) => {
if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return;
const cell = this.__resolveCell(eventArg);
if (!cell) return;
const isEditable = !!(cell.col.editable || cell.col.editor);
if (isEditable) {
eventArg.stopPropagation();
this.startEditing(cell.item, cell.col);
return;
}
const dblAction = this.dataActions.find((a) => a.type?.includes('doubleClick'));
if (dblAction) dblAction.actionFunc({ item: cell.item, table: this });
};
private __onTbodyMousedown = (eventArg: MouseEvent) => {
// Suppress browser's native shift-click text selection so range-select
// doesn't highlight text mid-table.
if (eventArg.shiftKey && this.selectionMode !== 'single') eventArg.preventDefault();
};
private __onTbodyContextmenu = (eventArg: MouseEvent) => {
if (this.__isInActionsCol(eventArg)) return;
const row = this.__resolveRow(eventArg);
if (!row) return;
const item = row.item;
// Match file-manager behavior: right-clicking a non-selected row makes
// it the selection first.
if (!this.isRowSelected(item)) {
this.selectedDataRow = item;
this.selectedIds.clear();
this.selectedIds.add(this.getRowId(item));
this.__selectionAnchorId = this.getRowId(item);
this.emitSelectionChange();
this.requestUpdate();
}
const userItems: plugins.tsclass.website.IMenuItem[] = this.getActionsForType('contextmenu').map(
(action) => ({
name: action.name,
iconName: action.iconName as any,
action: async () => {
await action.actionFunc({ item, table: this });
return null;
},
})
);
const defaultItems: plugins.tsclass.website.IMenuItem[] = [
{
name:
this.selectedIds.size > 1
? `Copy ${this.selectedIds.size} rows as JSON`
: 'Copy row as JSON',
iconName: 'lucide:Copy' as any,
action: async () => {
this.copySelectionAsJson(item);
return null;
},
},
];
DeesContextmenu.openContextMenuWithOptions(eventArg, [...userItems, ...defaultItems]);
};
private __onTbodyDragenter = (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
const row = this.__resolveRow(eventArg);
if (!row) return;
const tr = (eventArg.composedPath?.() || []).find(
(t) => (t as HTMLElement)?.tagName === 'TR'
) as HTMLElement | undefined;
if (tr) setTimeout(() => tr.classList.add('hasAttachment'), 0);
};
private __onTbodyDragleave = (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
const tr = (eventArg.composedPath?.() || []).find(
(t) => (t as HTMLElement)?.tagName === 'TR'
) as HTMLElement | undefined;
if (tr) tr.classList.remove('hasAttachment');
};
private __onTbodyDragover = (eventArg: DragEvent) => {
eventArg.preventDefault();
};
private __onTbodyDrop = async (eventArg: DragEvent) => {
eventArg.preventDefault();
const row = this.__resolveRow(eventArg);
if (!row) return;
const item = row.item;
const newFiles: File[] = [];
for (const file of Array.from(eventArg.dataTransfer!.files)) {
this.files.push(file);
newFiles.push(file);
this.requestUpdate();
}
const existing: File[] | undefined = this.fileWeakMap.get(item as object);
if (!existing) this.fileWeakMap.set(item as object, newFiles);
else existing.push(...newFiles);
};
/**
* Handles row clicks with file-manager style selection semantics:
* - plain click: select only this row, set anchor
* - cmd/ctrl+click: toggle this row in/out, set anchor
* - shift+click: select the contiguous range from the anchor to this row
*
* Multi-row click selection is always available (`selectionMode === 'none'`
* and `'multi'` both behave this way) so consumers can always copy a set
* of rows. Only `selectionMode === 'single'` restricts to one row.
*/
private handleRowClick(eventArg: MouseEvent, item: T, rowIndex: number, view: T[]) {
const id = this.getRowId(item);
if (this.selectionMode === 'single') {
this.selectedDataRow = item;
this.selectedIds.clear();
this.selectedIds.add(id);
this.__selectionAnchorId = id;
this.emitSelectionChange();
this.requestUpdate();
return;
}
// multi
const isToggle = eventArg.metaKey || eventArg.ctrlKey;
const isRange = eventArg.shiftKey;
if (isRange && this.__selectionAnchorId !== undefined) {
// Clear any text selection the browser may have created.
window.getSelection?.()?.removeAllRanges();
const anchorIdx = view.findIndex((r) => this.getRowId(r) === this.__selectionAnchorId);
if (anchorIdx >= 0) {
const [a, b] = anchorIdx <= rowIndex ? [anchorIdx, rowIndex] : [rowIndex, anchorIdx];
this.selectedIds.clear();
for (let i = a; i <= b; i++) this.selectedIds.add(this.getRowId(view[i]));
} else {
// Anchor no longer in view (filter changed, etc.) — fall back to single select.
this.selectedIds.clear();
this.selectedIds.add(id);
this.__selectionAnchorId = id;
}
this.selectedDataRow = item;
} else if (isToggle) {
const wasSelected = this.selectedIds.has(id);
if (wasSelected) {
this.selectedIds.delete(id);
// If we just deselected the focused row, move focus to another
// selected row (or clear it) so the highlight goes away.
if (this.selectedDataRow === item) {
const remaining = view.find((r) => this.selectedIds.has(this.getRowId(r)));
this.selectedDataRow = remaining as T;
}
} else {
this.selectedIds.add(id);
this.selectedDataRow = item;
}
this.__selectionAnchorId = id;
} else {
this.selectedDataRow = item;
this.selectedIds.clear();
this.selectedIds.add(id);
this.__selectionAnchorId = id;
}
this.emitSelectionChange();
this.requestUpdate();
}
private setRowSelected(row: T, checked: boolean) {
const id = this.getRowId(row);
if (this.selectionMode === 'single') {
this.selectedIds.clear();
if (checked) this.selectedIds.add(id);
} else if (this.selectionMode === 'multi') {
if (checked) this.selectedIds.add(id);
else this.selectedIds.delete(id);
}
this.emitSelectionChange();
this.requestUpdate();
}
private areAllVisibleSelected(): boolean {
const view: T[] = (this as any)._lastViewData || [];
if (view.length === 0) return false;
for (const r of view) {
if (!this.selectedIds.has(this.getRowId(r))) return false;
}
return true;
}
private isVisibleSelectionIndeterminate(): boolean {
const view: T[] = (this as any)._lastViewData || [];
if (view.length === 0) return false;
let count = 0;
for (const r of view) {
if (this.selectedIds.has(this.getRowId(r))) count++;
}
return count > 0 && count < view.length;
}
private setSelectVisible(checked: boolean) {
const view: T[] = (this as any)._lastViewData || [];
if (checked) {
for (const r of view) this.selectedIds.add(this.getRowId(r));
} else {
for (const r of view) this.selectedIds.delete(this.getRowId(r));
}
this.emitSelectionChange();
this.requestUpdate();
}
private emitSelectionChange() {
const selectedIds = Array.from(this.selectedIds);
const selectedRows = this.data.filter((r) => this.selectedIds.has(this.getRowId(r)));
this.dispatchEvent(
new CustomEvent('selectionChange', {
detail: { selectedIds, selectedRows },
bubbles: true,
})
);
}
2023-09-12 13:42:55 +02:00
getActionsForType(typeArg: ITableAction['type'][0]) {
const actions: ITableAction[] = [];
for (const action of this.dataActions) {
if (!action.type?.includes(typeArg)) continue;
2023-09-12 13:42:55 +02:00
actions.push(action);
}
return actions;
}
2023-09-15 19:03:18 +02:00
// ─── Cell editing ─────────────────────────────────────────────────────
/** True if the column has any in-cell editor configured. */
private __isColumnEditable(col: Column<T>): boolean {
return !!(col.editable || col.editor);
}
/** Effective columns filtered to those that can be edited (visible only). */
private __editableColumns(effectiveColumns: Column<T>[]): Column<T>[] {
return effectiveColumns.filter((c) => !c.hidden && this.__isColumnEditable(c));
}
/**
* Opens the editor on the given cell. Sets focus + editing state and
* focuses the freshly rendered editor on the next frame.
*/
public startEditing(item: T, col: Column<T>) {
if (!this.__isColumnEditable(col)) return;
const rowId = this.getRowId(item);
const colKey = String(col.key);
this.__focusedCell = { rowId, colKey };
this.__editingCell = { rowId, colKey };
this.requestUpdate();
this.updateComplete.then(() => {
const el = this.shadowRoot?.querySelector(
'.editingCell dees-input-text, .editingCell dees-input-checkbox, ' +
'.editingCell dees-input-dropdown, .editingCell dees-input-datepicker, ' +
'.editingCell dees-input-tags'
) as any;
el?.focus?.();
// Dropdown editors should auto-open so the user can pick immediately.
if (el?.tagName === 'DEES-INPUT-DROPDOWN') {
el.updateComplete?.then(() => el.toggleSelectionBox?.());
}
});
}
/** Closes the editor without committing. */
public cancelCellEdit() {
this.__editingCell = undefined;
this.requestUpdate();
}
/**
* Commits an editor value to the row. Runs `parse` then `validate`. On
* validation failure, fires `cellEditError` and leaves the editor open.
* On success, mutates `data` in place, fires `cellEdit`, and closes the
* editor.
*/
public commitCellEdit(item: T, col: Column<T>, editorValue: any) {
const key = String(col.key);
const oldValue = (item as any)[col.key];
const parsed = col.parse ? col.parse(editorValue, item) : editorValue;
if (col.validate) {
const result = col.validate(parsed, item);
if (typeof result === 'string') {
this.dispatchEvent(
new CustomEvent('cellEditError', {
detail: { row: item, key, value: parsed, message: result },
bubbles: true,
composed: true,
})
);
return;
2023-09-17 21:38:02 +02:00
}
}
if (parsed !== oldValue) {
(item as any)[col.key] = parsed;
this.dispatchEvent(
new CustomEvent('cellEdit', {
detail: { row: item, key, oldValue, newValue: parsed },
bubbles: true,
composed: true,
})
);
this.changeSubject.next(this);
}
this.__editingCell = undefined;
this.requestUpdate();
}
/** Renders the appropriate dees-input-* component for this column. */
private renderCellEditor(item: T, col: Column<T>): TemplateResult {
const raw = (item as any)[col.key];
const value = col.format ? col.format(raw, item) : raw;
const editorType: TCellEditorType = col.editor ?? 'text';
const onTextCommit = (target: any) => this.commitCellEdit(item, col, target.value);
switch (editorType) {
case 'checkbox':
return html`<dees-input-checkbox
.value=${!!value}
@newValue=${(e: CustomEvent<boolean>) => {
e.stopPropagation();
this.commitCellEdit(item, col, e.detail);
}}
></dees-input-checkbox>`;
case 'dropdown': {
const options = (col.editorOptions?.options as any[]) ?? [];
const selected =
options.find((o: any) => {
if (o == null) return false;
if (typeof o === 'string') return o === raw;
return o.key === raw || o.option === raw;
}) ?? null;
return html`<dees-input-dropdown
.vintegrated=${true}
.options=${options}
.selectedOption=${selected}
@selectedOption=${(e: CustomEvent<any>) => {
e.stopPropagation();
const detail = e.detail;
const newRaw = detail?.option ?? detail?.key ?? detail;
this.commitCellEdit(item, col, newRaw);
}}
></dees-input-dropdown>`;
2023-09-17 21:38:02 +02:00
}
2023-09-15 19:03:18 +02:00
case 'date':
return html`<dees-input-datepicker
.value=${value}
@focusout=${(e: any) => onTextCommit(e.target)}
@keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
></dees-input-datepicker>`;
case 'tags':
return html`<dees-input-tags
.value=${(value as any) ?? []}
@focusout=${(e: any) => onTextCommit(e.target)}
@keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
></dees-input-tags>`;
case 'number':
case 'text':
default:
return html`<dees-input-text
.vintegrated=${true}
.value=${value == null ? '' : String(value)}
@focusout=${(e: any) => onTextCommit(e.target)}
@keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
></dees-input-text>`;
}
}
/**
* Centralized keydown handler for text-style editors. Handles Esc (cancel),
* Enter (commit + move down) and Tab/Shift+Tab (commit + move horizontally).
*/
private __handleEditorKey(eventArg: KeyboardEvent, item: T, col: Column<T>) {
if (eventArg.key === 'Escape') {
eventArg.preventDefault();
eventArg.stopPropagation();
this.cancelCellEdit();
// Restore focus to the host so arrow-key navigation can resume.
this.focus();
} else if (eventArg.key === 'Enter') {
eventArg.preventDefault();
eventArg.stopPropagation();
const target = eventArg.target as any;
this.commitCellEdit(item, col, target.value);
this.moveFocusedCell(0, +1, true);
} else if (eventArg.key === 'Tab') {
eventArg.preventDefault();
eventArg.stopPropagation();
const target = eventArg.target as any;
this.commitCellEdit(item, col, target.value);
this.moveFocusedCell(eventArg.shiftKey ? -1 : +1, 0, true);
}
}
/**
* Moves the focused cell by `dx` columns and `dy` rows along the editable
* grid. Wraps row-end next row when moving horizontally. If
* `andStartEditing` is true, opens the editor on the new cell.
*/
public moveFocusedCell(dx: number, dy: number, andStartEditing: boolean) {
const view: T[] = (this as any)._lastViewData ?? [];
if (view.length === 0) return;
// Recompute editable columns from the latest effective set.
const allCols: Column<T>[] = Array.isArray(this.columns) && this.columns.length > 0
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
const editableCols = this.__editableColumns(allCols);
if (editableCols.length === 0) return;
let rowIdx = 0;
let colIdx = 0;
if (this.__focusedCell) {
rowIdx = view.findIndex((r) => this.getRowId(r) === this.__focusedCell!.rowId);
colIdx = editableCols.findIndex((c) => String(c.key) === this.__focusedCell!.colKey);
if (rowIdx < 0) rowIdx = 0;
if (colIdx < 0) colIdx = 0;
}
if (dx !== 0) {
colIdx += dx;
while (colIdx >= editableCols.length) {
colIdx -= editableCols.length;
rowIdx += 1;
2023-09-15 19:03:18 +02:00
}
while (colIdx < 0) {
colIdx += editableCols.length;
rowIdx -= 1;
}
}
if (dy !== 0) rowIdx += dy;
2023-09-15 19:03:18 +02:00
// Clamp to grid bounds.
if (rowIdx < 0 || rowIdx >= view.length) {
this.cancelCellEdit();
return;
}
const item = view[rowIdx];
const col = editableCols[colIdx];
this.__focusedCell = { rowId: this.getRowId(item), colKey: String(col.key) };
if (andStartEditing) {
this.startEditing(item, col);
} else {
this.requestUpdate();
}
2023-09-15 19:03:18 +02:00
}
2021-10-07 18:01:05 +02:00
}