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

1849 lines
66 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;
/**
* 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;
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
public render(): TemplateResult {
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
const effectiveColumns: Column<T>[] = usingColumns
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
const lucenePred = compileLucenePredicate<T>(
this.filterText,
this.searchMode === 'data' ? 'data' : 'table',
effectiveColumns
);
const viewData = getViewDataFn(
this.data,
effectiveColumns,
this.sortBy,
this.filterText,
this.columnFilters,
this.searchMode === 'data' ? 'data' : 'table',
lucenePred || undefined
);
(this as any)._lastViewData = viewData;
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>
${viewData.map((itemArg, rowIndex) => {
2023-09-04 19:28:50 +02:00
const getTr = (elementArg: HTMLElement): HTMLElement => {
if (elementArg.tagName === 'TR') {
return elementArg;
} else {
return getTr(elementArg.parentElement!);
2023-09-04 19:28:50 +02:00
}
};
return html`
2021-10-07 18:47:36 +02:00
<tr
@click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
@mousedown=${(e: MouseEvent) => {
// Prevent the browser's native shift-click text
// selection so range-select doesn't highlight text.
if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
2021-10-07 18:47:36 +02:00
}}
2022-12-07 02:28:31 +01:00
@dragenter=${async (eventArg: DragEvent) => {
2022-12-06 13:11:06 +01:00
eventArg.preventDefault();
eventArg.stopPropagation();
2023-09-04 19:28:50 +02:00
const realTarget = getTr(eventArg.target as HTMLElement);
setTimeout(() => {
2023-09-15 20:11:51 +02:00
realTarget.classList.add('hasAttachment');
2023-09-04 19:28:50 +02:00
}, 0);
2022-12-06 13:11:06 +01:00
}}
2022-12-07 02:28:31 +01:00
@dragleave=${async (eventArg: DragEvent) => {
2022-12-06 13:11:06 +01:00
eventArg.preventDefault();
eventArg.stopPropagation();
2023-09-04 19:28:50 +02:00
const realTarget = getTr(eventArg.target as HTMLElement);
2023-09-15 20:11:51 +02:00
realTarget.classList.remove('hasAttachment');
2022-12-06 13:11:06 +01:00
}}
2022-12-07 02:28:31 +01:00
@dragover=${async (eventArg: DragEvent) => {
2022-12-06 13:11:06 +01:00
eventArg.preventDefault();
2022-12-07 02:28:31 +01:00
}}
@drop=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
const newFiles: File[] = [];
for (const file of Array.from(eventArg.dataTransfer!.files)) {
2022-12-07 02:28:31 +01:00
this.files.push(file);
newFiles.push(file);
this.requestUpdate();
}
const result: File[] = this.fileWeakMap.get(itemArg as object);
if (!result) {
2023-09-04 19:28:50 +02:00
this.fileWeakMap.set(itemArg as object, newFiles);
2022-12-07 02:28:31 +01:00
} else {
result.push(...newFiles);
}
2022-12-06 13:11:06 +01:00
}}
2023-09-04 19:28:50 +02:00
@contextmenu=${async (eventArg: MouseEvent) => {
// If the right-clicked row isn't part of the
// current selection, treat it like a plain click
// first so the context menu acts on a sensible
// selection (matches file-manager behavior).
if (!this.isRowSelected(itemArg)) {
this.selectedDataRow = itemArg;
this.selectedIds.clear();
this.selectedIds.add(this.getRowId(itemArg));
this.__selectionAnchorId = this.getRowId(itemArg);
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: itemArg,
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(itemArg);
return null;
},
},
];
DeesContextmenu.openContextMenuWithOptions(eventArg, [
...userItems,
...defaultItems,
]);
2023-09-04 19:28:50 +02:00
}}
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 rowId = this.getRowId(itemArg);
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}
@click=${(e: MouseEvent) => {
if (isEditing) {
e.stopPropagation();
return;
}
if (isEditable) {
this.__focusedCell = { rowId, colKey: editKey };
}
}}
@dblclick=${(e: Event) => {
const dblAction = this.dataActions.find((actionArg) =>
actionArg.type?.includes('doubleClick')
2023-09-15 19:03:18 +02:00
);
if (isEditable) {
e.stopPropagation();
this.startEditing(itemArg, col);
} else if (dblAction) {
dblAction.actionFunc({ item: itemArg, table: this });
2023-09-15 19:03:18 +02:00
}
}}
>
<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
})}
</tbody>
</table>
</div>
<div class="floatingHeader" aria-hidden="true">
<table>
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
</table>
</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) => {
const isSortable = !!col.sortable;
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;
private __floatingActive = false;
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();
if (this.fixedHeight) 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 so horizontal
// scrolling inside the table re-syncs the floating header.
const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null;
if (tableScrollEl) {
this.__scrollAncestors.unshift({ target: tableScrollEl, scrollsY: false, scrollsX: true });
}
// Track resize of the real table so we can mirror its width and column widths.
this.__floatingResizeObserver = new ResizeObserver(() => {
this.__syncFloatingHeader();
});
this.__floatingResizeObserver.observe(realTable);
this.__floatingScrollHandler = () => this.__syncFloatingHeader();
for (const a of this.__scrollAncestors) {
a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true });
}
window.addEventListener('resize', this.__floatingScrollHandler, { passive: true });
this.__syncFloatingHeader();
}
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');
}
/**
* Single function that drives both activation and geometry of the floating
* header. Called on scroll, resize, table-resize, and after each render.
*/
private __syncFloatingHeader() {
const fh = this.__floatingHeaderEl;
const realTable = this.__realTableEl;
const floatTable = this.__floatingTableEl;
if (!fh || !realTable || !floatTable) return;
const tableRect = realTable.getBoundingClientRect();
const stick = this.__getStickContext();
// Mirror table layout + per-cell widths so columns line up.
floatTable.style.tableLayout = realTable.style.tableLayout || 'auto';
const realHeadRows = realTable.tHead?.rows;
const floatHeadRows = floatTable.tHead?.rows;
let headerHeight = 0;
if (realHeadRows && floatHeadRows) {
for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) {
headerHeight += realHeadRows[r].getBoundingClientRect().height;
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`;
}
}
}
// Active when the table top is above the stick line and the table bottom
// hasn't yet scrolled past it.
const shouldBeActive =
tableRect.top < stick.top && tableRect.bottom > stick.top + Math.min(headerHeight, 1);
if (shouldBeActive !== this.__floatingActive) {
this.__floatingActive = shouldBeActive;
fh.classList.toggle('active', shouldBeActive);
}
if (!shouldBeActive) return;
// 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`;
// 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`;
}
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);
2023-10-20 10:47:53 +02:00
this.determineColumnWidths();
// (Re)wire the floating header whenever the relevant props change or
// the table markup may have appeared/disappeared.
if (
changedProperties.has('fixedHeight') ||
changedProperties.has('data') ||
changedProperties.has('columns') ||
!this.__floatingScrollHandler
) {
if (!this.fixedHeight && this.data.length > 0) {
this.setupFloatingHeader();
} else {
this.teardownFloatingHeader();
}
}
// Keep the floating header in sync after any re-render
// (column widths may have changed).
if (!this.fixedHeight && this.data.length > 0) {
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.
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' {
// 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();
}
/**
* 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?.();
});
}
/** 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) => (o?.option ?? o?.key ?? o) === value) ?? null;
return html`<dees-input-dropdown
.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
.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
}