Compare commits

...

7 Commits

Author SHA1 Message Date
9422edbfa1 v3.67.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-07 21:35:21 +00:00
37c5e92d6d fix(repo): no changes to commit 2026-04-07 21:35:21 +00:00
c7503de11e update 2026-04-07 21:31:43 +00:00
408362f3be v3.67.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-07 21:04:52 +00:00
b3f5ab3d31 feat(dees-table): improve inline cell editors with integrated input styling and auto-open dropdowns 2026-04-07 21:04:52 +00:00
8d954b17ad v3.66.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-07 15:56:55 +00:00
ac9cc8cfed feat(dees-table): add virtualized row rendering for large tables and optimize table rendering performance 2026-04-07 15:56:55 +00:00
9 changed files with 663 additions and 167 deletions

View File

@@ -1,5 +1,23 @@
# Changelog
## 2026-04-07 - 3.67.1 - fix(repo)
no changes to commit
## 2026-04-07 - 3.67.0 - feat(dees-table)
improve inline cell editors with integrated input styling and auto-open dropdowns
- add a visually integrated mode to dees-input-text and dees-input-dropdown for table cell editing
- auto-open dropdown editors when a table cell enters edit mode
- refine table editing cell outline and dropdown value matching for inline editors
## 2026-04-07 - 3.66.0 - feat(dees-table)
add virtualized row rendering for large tables and optimize table rendering performance
- add a virtualized mode with configurable overscan to render only visible rows while preserving scroll height
- improve table render performance with memoized column and view-data computation plus deferred floating header rendering
- update the dees-table demo to showcase virtualized scrolling in the fixed-height example
## 2026-04-07 - 3.65.0 - feat(dees-table)
add schema-based in-cell editing with keyboard navigation and cell edit events

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-catalog",
"version": "3.65.0",
"version": "3.67.1",
"private": false,
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"main": "dist_ts_web/index.js",

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-catalog',
version: '3.65.0',
version: '3.67.1',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
}

View File

@@ -700,7 +700,8 @@ export const demoFunc = () => html`
<dees-table
id="scrollSmallHeight"
.fixedHeight=${true}
heading1="People Directory (Scrollable)"
.virtualized=${true}
heading1="People Directory (Scrollable, Virtualized)"
heading2="Forced scrolling with many items"
.columns=${[
{ key: 'id', header: 'ID', sortable: true },

View File

@@ -199,6 +199,21 @@ export class DeesTable<T> extends DeesElement {
@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
@@ -245,6 +260,46 @@ export class DeesTable<T> extends DeesElement {
@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 };
constructor() {
super();
// Make the host focusable so it can receive Ctrl/Cmd+C for copy.
@@ -368,28 +423,106 @@ export class DeesTable<T> extends DeesElement {
public static styles = tableStyles;
public render(): TemplateResult {
/**
* 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 effectiveColumns: Column<T>[] = usingColumns
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
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;
}
const lucenePred = compileLucenePredicate<T>(
this.filterText,
this.searchMode === 'data' ? 'data' : 'table',
effectiveColumns
);
const viewData = getViewDataFn(
/**
* 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,
this.searchMode === 'data' ? 'data' : 'table',
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;
}
return html`
<dees-tile>
<div slot="header" class="header">
@@ -460,98 +593,25 @@ export class DeesTable<T> extends DeesElement {
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
<tbody>
${viewData.map((itemArg, rowIndex) => {
const getTr = (elementArg: HTMLElement): HTMLElement => {
if (elementArg.tagName === 'TR') {
return elementArg;
} else {
return getTr(elementArg.parentElement!);
}
};
<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);
return html`
<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();
}}
@dragenter=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
const realTarget = getTr(eventArg.target as HTMLElement);
setTimeout(() => {
realTarget.classList.add('hasAttachment');
}, 0);
}}
@dragleave=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
const realTarget = getTr(eventArg.target as HTMLElement);
realTarget.classList.remove('hasAttachment');
}}
@dragover=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
}}
@drop=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
const newFiles: File[] = [];
for (const file of Array.from(eventArg.dataTransfer!.files)) {
this.files.push(file);
newFiles.push(file);
this.requestUpdate();
}
const result: File[] = this.fileWeakMap.get(itemArg as object);
if (!result) {
this.fileWeakMap.set(itemArg as object, newFiles);
} else {
result.push(...newFiles);
}
}}
@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,
]);
}}
data-row-idx=${rowIndex}
class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
>
${this.showSelectionCheckbox
@@ -574,7 +634,6 @@ export class DeesTable<T> extends DeesElement {
: 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;
@@ -591,26 +650,7 @@ export class DeesTable<T> extends DeesElement {
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')
);
if (isEditable) {
e.stopPropagation();
this.startEditing(itemArg, col);
} else if (dblAction) {
dblAction.actionFunc({ item: itemArg, table: this });
}
}}
data-col-key=${editKey}
>
<div class="innerCellContainer">
${isEditing ? this.renderCellEditor(itemArg, col) : content}
@@ -646,15 +686,20 @@ export class DeesTable<T> extends DeesElement {
})()}
</tr>`;
})}
${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">
<table>
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
</table>
${this.__floatingActive
? html`<table>
<thead>
${this.renderHeaderRows(effectiveColumns)}
</thead>
</table>`
: html``}
</div>
`
: html` <div class="noDataSet">No data set!</div> `}
@@ -723,7 +768,7 @@ export class DeesTable<T> extends DeesElement {
${effectiveColumns
.filter((c) => !c.hidden)
.map((col) => {
const isSortable = !!col.sortable;
const isSortable = col.sortable !== false;
const ariaSort = this.getAriaSort(col);
return html`
<th
@@ -771,7 +816,8 @@ export class DeesTable<T> extends DeesElement {
// ─── Floating header (page-sticky) lifecycle ─────────────────────────
private __floatingResizeObserver?: ResizeObserver;
private __floatingScrollHandler?: () => void;
private __floatingActive = false;
// __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 {
@@ -854,32 +900,45 @@ export class DeesTable<T> extends DeesElement {
private setupFloatingHeader() {
this.teardownFloatingHeader();
if (this.fixedHeight) return;
// 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 so horizontal
// scrolling inside the table re-syncs the floating header.
// 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: false, scrollsX: true });
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(() => {
this.__syncFloatingHeader();
if (!this.fixedHeight) this.__syncFloatingHeader();
if (this.virtualized) this.__computeVirtualRange();
});
this.__floatingResizeObserver.observe(realTable);
this.__floatingScrollHandler = () => this.__syncFloatingHeader();
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 });
this.__syncFloatingHeader();
if (!this.fixedHeight) this.__syncFloatingHeader();
if (this.virtualized) this.__computeVirtualRange();
}
private teardownFloatingHeader() {
@@ -898,27 +957,136 @@ export class DeesTable<T> extends DeesElement {
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 each render.
* 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;
const floatTable = this.__floatingTableEl;
if (!fh || !realTable || !floatTable) return;
if (!fh || !realTable) 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) {
for (let r = 0; r < realHeadRows.length; r++) {
headerHeight += realHeadRows[r].getBoundingClientRect().height;
}
}
// 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);
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++) {
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++) {
@@ -930,17 +1098,6 @@ export class DeesTable<T> extends DeesElement {
}
}
// 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.
@@ -952,10 +1109,19 @@ export class DeesTable<T> extends DeesElement {
fh.style.left = `${clipLeft}px`;
fh.style.width = `${clipWidth}px`;
// 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`;
floatTable.style.transform = exitOffset > 0 ? `translateY(-${exitOffset}px)` : '';
}
public async disconnectedCallback() {
@@ -970,24 +1136,55 @@ export class DeesTable<T> extends DeesElement {
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
super.updated(changedProperties);
this.determineColumnWidths();
// (Re)wire the floating header whenever the relevant props change or
// the table markup may have appeared/disappeared.
// 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
) {
if (!this.fixedHeight && this.data.length > 0) {
const needsScrollWatchers = (!this.fixedHeight || this.virtualized) && this.data.length > 0;
if (needsScrollWatchers) {
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) {
// 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();
}
if (this.searchable) {
@@ -1317,7 +1514,7 @@ export class DeesTable<T> extends DeesElement {
// 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 sortableColumnCount = effectiveColumns.filter((c) => c.sortable !== false).length;
const maxSlot = Math.min(
Math.max(cascadeLen + (existing ? 0 : 1), 1),
Math.max(sortableColumnCount, 1)
@@ -1423,6 +1620,17 @@ export class DeesTable<T> extends DeesElement {
});
}
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;
}
@@ -1502,6 +1710,187 @@ export class DeesTable<T> extends DeesElement {
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
@@ -1663,6 +2052,10 @@ export class DeesTable<T> extends DeesElement {
'.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?.());
}
});
}
@@ -1730,8 +2123,13 @@ export class DeesTable<T> extends DeesElement {
case 'dropdown': {
const options = (col.editorOptions?.options as any[]) ?? [];
const selected =
options.find((o: any) => (o?.option ?? o?.key ?? o) === value) ?? null;
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>) => {
@@ -1761,6 +2159,7 @@ export class DeesTable<T> extends DeesElement {
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)}

View File

@@ -386,6 +386,11 @@ export const tableStyles: CSSResult[] = [
}
td.editingCell {
padding: 0;
outline: 2px solid ${cssManager.bdTheme(
'hsl(222.2 47.4% 51.2% / 0.6)',
'hsl(217.2 91.2% 59.8% / 0.6)'
)};
outline-offset: -2px;
}
td.editingCell .innerCellContainer {
padding: 0;

View File

@@ -48,6 +48,7 @@ export interface Column<T = any> {
header?: string | TemplateResult;
value?: (row: T) => any;
renderer?: (value: any, row: T, ctx: { rowIndex: number; colIndex: number; column: Column<T> }) => TemplateResult | string;
/** Whether this column can be sorted by clicking its header. Defaults to `true`; set to `false` to disable. */
sortable?: boolean;
/** whether this column participates in per-column quick filtering (default: true) */
filterable?: boolean;

View File

@@ -46,6 +46,12 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
})
accessor enableSearch: boolean = true;
@property({
type: Boolean,
reflect: true,
})
accessor vintegrated: boolean = false;
@state()
accessor isOpened = false;
@@ -126,6 +132,36 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
.selectedBox.open::after {
transform: translateY(-50%) rotate(180deg);
}
/* Visually integrated mode: shed chrome to blend into a host component
(e.g. a dees-table cell in edit mode). */
:host([vintegrated]) dees-label {
display: none;
}
:host([vintegrated]) .maincontainer {
height: 40px;
}
:host([vintegrated]) .selectedBox {
height: 40px;
line-height: 40px;
padding: 0 32px 0 16px;
font-size: 13px;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
transition: none;
}
:host([vintegrated]) .selectedBox:hover:not(.disabled),
:host([vintegrated]) .selectedBox:focus-visible {
border: none;
box-shadow: none;
background: transparent;
}
:host([vintegrated]) .selectedBox::after {
right: 12px;
opacity: 0.6;
}
`,
];

View File

@@ -57,6 +57,12 @@ export class DeesInputText extends DeesInputBase {
@property({})
accessor validationFunction!: (value: string) => boolean;
@property({
type: Boolean,
reflect: true,
})
accessor vintegrated: boolean = false;
public static styles = [
themeDefaultStyles,
...DeesInputBase.baseStyles,
@@ -194,6 +200,36 @@ export class DeesInputText extends DeesInputBase {
border-color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.05)', 'hsl(142.1 70.6% 45.3% / 0.05)')};
}
/* Visually integrated mode: shed chrome to blend into a host component
(e.g. a dees-table cell in edit mode). */
:host([vintegrated]) dees-label,
:host([vintegrated]) .validationContainer {
display: none;
}
:host([vintegrated]) .maincontainer {
height: 40px;
}
:host([vintegrated]) input {
height: 40px;
line-height: 24px;
padding: 0 16px;
font-size: 13px;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
transition: none;
}
:host([vintegrated]) input:hover:not(:disabled):not(:focus),
:host([vintegrated]) input:focus {
border: none;
box-shadow: none;
background: transparent;
}
:host([vintegrated]) .showPassword {
display: none;
}
`,
];