Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ae0541065 | |||
| 4b735b768a | |||
| 9422edbfa1 | |||
| 37c5e92d6d | |||
| c7503de11e | |||
| 408362f3be | |||
| b3f5ab3d31 | |||
| 8d954b17ad | |||
| ac9cc8cfed |
24
changelog.md
24
changelog.md
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-08 - 3.68.0 - feat(dees-simple-appdash)
|
||||
add nested sidebar subviews and preserve submit labels from slotted text
|
||||
|
||||
- support grouped navigation items with expandable subviews and parent-to-first-subview fallback in the app dashboard
|
||||
- allow dees-form-submit to derive its button text from light DOM content when no explicit text property is set
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "3.65.0",
|
||||
"version": "3.68.0",
|
||||
"private": false,
|
||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||
"main": "dist_ts_web/index.js",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog',
|
||||
version: '3.65.0',
|
||||
version: '3.68.0',
|
||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -75,12 +75,23 @@ export class DeesFormSubmit extends DeesElement {
|
||||
.text=${this.text}
|
||||
?disabled=${this.disabled}
|
||||
@clicked=${this.submit}
|
||||
>
|
||||
<slot></slot>
|
||||
</dees-button>
|
||||
></dees-button>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// Capture light DOM text content as the button label. dees-button wipes
|
||||
// its own light DOM during extractLightDom(), so we cannot simply forward
|
||||
// a <slot> into it — we have to hoist the text onto the .text property
|
||||
// ourselves before handing it to dees-button.
|
||||
if (!this.text) {
|
||||
const slotText = this.textContent?.trim();
|
||||
if (slotText) {
|
||||
this.text = slotText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async submit() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
|
||||
@@ -353,12 +353,35 @@ export const demoFunc = () => html`
|
||||
name: 'Analytics',
|
||||
iconName: 'lucide:lineChart',
|
||||
element: DemoViewAnalytics,
|
||||
subViews: [
|
||||
{
|
||||
name: 'Overview',
|
||||
iconName: 'lucide:activity',
|
||||
element: DemoViewAnalytics,
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
iconName: 'lucide:fileText',
|
||||
element: DemoViewDashboard,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
element: DemoViewSettings,
|
||||
}
|
||||
subViews: [
|
||||
{
|
||||
name: 'Profile',
|
||||
iconName: 'lucide:user',
|
||||
element: DemoViewSettings,
|
||||
},
|
||||
{
|
||||
name: 'Billing',
|
||||
iconName: 'lucide:creditCard',
|
||||
element: DemoViewSettings,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as IView[]}
|
||||
@logout=${() => {
|
||||
console.log('Logout event triggered');
|
||||
|
||||
@@ -26,7 +26,8 @@ declare global {
|
||||
export interface IView {
|
||||
name: string;
|
||||
iconName?: string;
|
||||
element: DeesElement['constructor']['prototype'];
|
||||
element?: DeesElement['constructor']['prototype'];
|
||||
subViews?: IView[];
|
||||
}
|
||||
|
||||
export type TGlobalMessageType = 'info' | 'success' | 'warning' | 'error';
|
||||
@@ -250,6 +251,55 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewTab .chevron {
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.2s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.viewTab.hasSubs:hover .chevron {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.viewTab.hasSubs.groupActive .chevron {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.subViews {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 2px 0 4px 12px;
|
||||
padding-left: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subViews::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 1px;
|
||||
background: var(--dees-color-border-default);
|
||||
}
|
||||
|
||||
.viewTab.sub {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.viewTab.sub dees-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.viewTab.sub.selected::before {
|
||||
left: -12px;
|
||||
}
|
||||
|
||||
.appActions {
|
||||
padding: 12px 8px;
|
||||
border-top: 1px solid var(--dees-color-border-default);
|
||||
@@ -563,10 +613,12 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
<div class="viewTabs-container">
|
||||
<div class="section-label">Navigation</div>
|
||||
<div class="viewTabs">
|
||||
${this.viewTabs.map(
|
||||
(view) => html`
|
||||
${this.viewTabs.map((view) => {
|
||||
const hasSubs = !!view.subViews?.length;
|
||||
const groupActive = hasSubs && this.isGroupActive(view);
|
||||
return html`
|
||||
<div
|
||||
class="viewTab ${this.selectedView === view ? 'selected' : ''}"
|
||||
class="viewTab ${this.selectedView === view ? 'selected' : ''} ${hasSubs ? 'hasSubs' : ''} ${groupActive ? 'groupActive' : ''}"
|
||||
@click=${() => this.loadView(view)}
|
||||
>
|
||||
${view.iconName ? html`
|
||||
@@ -575,9 +627,34 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
<dees-icon .icon="${'lucide:file'}"></dees-icon>
|
||||
`}
|
||||
<span>${view.name}</span>
|
||||
${hasSubs ? html`
|
||||
<dees-icon class="chevron" .icon="${'lucide:chevronDown'}"></dees-icon>
|
||||
` : ''}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
${hasSubs && groupActive ? html`
|
||||
<div class="subViews">
|
||||
${view.subViews!.map(
|
||||
(sub) => html`
|
||||
<div
|
||||
class="viewTab sub ${this.selectedView === sub ? 'selected' : ''}"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.loadView(sub);
|
||||
}}
|
||||
>
|
||||
${sub.iconName ? html`
|
||||
<dees-icon .icon="${sub.iconName.includes(':') ? sub.iconName : `lucide:${sub.iconName}`}"></dees-icon>
|
||||
` : html`
|
||||
<dees-icon .icon="${'lucide:dot'}"></dees-icon>
|
||||
`}
|
||||
<span>${sub.name}</span>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="appActions">
|
||||
@@ -771,8 +848,23 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
}
|
||||
|
||||
|
||||
private isGroupActive(view: IView): boolean {
|
||||
if (this.selectedView === view) return true;
|
||||
return view.subViews?.some((sv) => sv === this.selectedView) ?? false;
|
||||
}
|
||||
|
||||
private currentView!: DeesElement;
|
||||
public async loadView(viewArg: IView) {
|
||||
// Group-only parent: resolve to first sub view with an element
|
||||
if (!viewArg.element && viewArg.subViews?.length) {
|
||||
const firstNavigable = viewArg.subViews.find((sv) => sv.element);
|
||||
if (firstNavigable) {
|
||||
return this.loadView(firstNavigable);
|
||||
}
|
||||
return; // nothing navigable — ignore click
|
||||
}
|
||||
if (!viewArg.element) return; // safety: no element and no subs → no-op
|
||||
|
||||
const appcontent = this.shadowRoot!.querySelector('.appcontent')!;
|
||||
const view = new viewArg.element();
|
||||
if (this.currentView) {
|
||||
@@ -781,7 +873,7 @@ export class DeesSimpleAppDash extends DeesElement {
|
||||
appcontent.appendChild(view);
|
||||
this.currentView = view;
|
||||
this.selectedView = viewArg;
|
||||
|
||||
|
||||
// Emit view-select event
|
||||
this.dispatchEvent(new CustomEvent('view-select', {
|
||||
detail: { view: viewArg },
|
||||
|
||||
Reference in New Issue
Block a user