import * as plugins from '../../00plugins.js'; import { demoFunc } from './dees-table.demo.js'; import { customElement, html, DeesElement, property, state, type TemplateResult, directives } from '@design.estate/dees-element'; import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js'; import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js'; import * as domtools from '@design.estate/dees-domtools'; import { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.js'; import { tableStyles } from './styles.js'; import type { Column, 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]); } declare global { interface HTMLElementTagNameMap { 'dees-table': DeesTable; } } // interfaces moved to ./types.ts and re-exported above // the table implementation @customElement('dees-table') export class DeesTable extends DeesElement { public static demo = demoFunc; public static demoGroups = ['Data View']; // INSTANCE @property({ type: String, }) accessor heading1: string = 'heading 1'; @property({ type: String, }) accessor heading2: string = 'heading 2'; @property({ type: Array, }) accessor data: T[] = []; // dees-form compatibility ----------------------------------------- @property({ type: String, }) accessor key!: string; @property({ type: String, }) accessor label!: string; @property({ type: Boolean, }) accessor disabled: boolean = false; @property({ type: Boolean, }) accessor required: boolean = false; get value() { return this.data; } set value(_valueArg) {} public changeSubject = new domtools.plugins.smartrx.rxjs.Subject>(); // end dees-form compatibility ----------------------------------------- /** * What does a row of data represent? */ @property({ type: String, reflect: true, }) accessor dataName!: string; @property({ type: Boolean, }) accessor searchable: boolean = true; @property({ type: Array, }) accessor dataActions: ITableAction[] = []; // schema-first columns API @property({ attribute: false }) accessor columns: Column[] = []; /** * 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; @property({ attribute: false, }) accessor displayFunction: TDisplayFunction = (itemArg: T) => itemArg as any; @property({ attribute: false, }) accessor reverseDisplayFunction: (itemArg: any) => T = (itemArg: any) => itemArg as T; @property({ type: Object, }) accessor selectedDataRow!: T; @property({ type: Boolean, reflect: true, attribute: 'show-vertical-lines' }) accessor showVerticalLines: boolean = false; @property({ type: Boolean, reflect: true, attribute: 'show-horizontal-lines' }) accessor showHorizontalLines: boolean = false; @property({ type: Boolean, reflect: true, attribute: 'show-grid' }) accessor showGrid: boolean = true; public files: File[] = []; public fileWeakMap = new WeakMap(); public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject(); /** * 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 = {}; @property({ type: Boolean, attribute: 'show-column-filters' }) accessor showColumnFilters: boolean = false; /** * When true, the table renders a leftmost checkbox column for click-driven * (de)selection. Row selection by mouse (plain/shift/ctrl click) is always * available regardless of this flag. */ @property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' }) accessor showSelectionCheckbox: boolean = false; /** * Enables row virtualization. Only rows visible in the nearest scroll * ancestor (or the viewport) plus a small overscan are rendered. Top and * bottom spacer rows preserve the scrollbar geometry. * * Assumes uniform row height (measured once from the first rendered row). * Recommended for tables with > a few hundred rows. */ @property({ type: Boolean, reflect: true, attribute: 'virtualized' }) accessor virtualized: boolean = false; /** Number of extra rows rendered above and below the visible window. */ @property({ type: Number, attribute: 'virtual-overscan' }) accessor virtualOverscan: number = 8; /** * Opt-in visual indication of cell-value changes across data updates. * * - `'none'` (default): no diffing, zero overhead. * - `'flash'`: when `data` is reassigned to a new array reference, diff the * new rows against the previous snapshot and briefly flash any cells * whose resolved value changed. Equality is strict `===`; object-valued * cells are compared by reference. The currently-edited cell is never * flashed. User-initiated cell edits do not flash. * * Requires `rowKey` to be set — without it, the feature silently no-ops * and renders a visible dev warning banner. Honors `prefers-reduced-motion` * (fades are replaced with a static background hint of the same duration). */ @property({ type: String, attribute: 'highlight-updates' }) accessor highlightUpdates: 'none' | 'flash' = 'none'; /** * Duration of the flash animation in milliseconds. Fed into the * `--dees-table-flash-duration` CSS variable on the host. */ @property({ type: Number, attribute: 'highlight-duration' }) accessor highlightDuration: number = 900; /** * 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 = new Set(); private _rowIdMap = new WeakMap(); private _rowIdCounter = 0; /** * Anchor row id for shift+click range selection. Set whenever the user * makes a non-range click (plain or cmd/ctrl) so the next shift+click * can compute a contiguous range from this anchor. */ private __selectionAnchorId?: string; /** * Cell currently focused for keyboard navigation. When set, the cell shows * a focus ring and Enter/F2 enters edit mode. Independent from row selection. */ @state() private accessor __focusedCell: { rowId: string; colKey: string } | undefined = undefined; /** * Cell currently being edited. When set, that cell renders an editor * (dees-input-*) instead of its display content. */ @state() private accessor __editingCell: { rowId: string; colKey: string } | undefined = undefined; /** * True while the page-sticky floating header overlay is visible. Lifted * to @state so the floating-header clone subtree is rendered only when * needed (saves a full thead worth of cells per render when inactive). */ @state() private accessor __floatingActive: boolean = false; // ─── Flash-on-update state (only populated when highlightUpdates === 'flash') ── /** rowId → set of colKey strings currently flashing. */ @state() private accessor __flashingCells: Map> = new Map(); /** rowId → (colKey → last-seen resolved cell value). Populated per diff pass. */ private __prevSnapshot?: Map>; /** Single shared timer that clears __flashingCells after highlightDuration ms. */ private __flashClearTimer?: ReturnType; /** Monotonic counter bumped each flash batch so directives.keyed recreates the cell node and restarts the animation. */ private __flashTick: number = 0; /** One-shot console.warn gate for missing rowKey in flash mode. */ private __flashWarnedNoRowKey: 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[]; }; private __memoViewData?: { data: any; sortBy: any; filterText: string; columnFilters: any; searchMode: string; effectiveColumns: Column[]; 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. 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[] = 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 */ } } public static styles = tableStyles; /** * 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[] { const usingColumns = Array.isArray(this.columns) && this.columns.length > 0; const cache = this.__memoEffectiveCols; if ( cache && cache.columns === this.columns && cache.augment === this.augmentFromDisplayFunction && cache.displayFunction === this.displayFunction && cache.data === this.data ) { return cache.out; } const out = usingColumns ? computeEffectiveColumnsFn( this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data ) : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data); this.__memoEffectiveCols = { columns: this.columns, augment: this.augmentFromDisplayFunction, displayFunction: this.displayFunction, data: this.data, out, }; return out; } /** * Returns the sorted/filtered view of the data, memoized by reference of * everything that affects it. Avoids re-running the lucene compiler and * the sort/filter pipeline on every render. */ private __getViewData(effectiveColumns: Column[]): T[] { 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(this.filterText, searchMode, effectiveColumns); const out = getViewDataFn( this.data, effectiveColumns, this.sortBy, this.filterText, this.columnFilters, searchMode, lucenePred || undefined ); this.__memoViewData = { data: this.data, sortBy: this.sortBy, filterText: this.filterText, columnFilters: this.columnFilters, searchMode, effectiveColumns, out, }; return out; } public render(): TemplateResult { const effectiveColumns = this.__getEffectiveColumns(); const viewData = this.__getViewData(effectiveColumns); (this as any)._lastViewData = viewData; // Virtualization slice — only the rows in `__virtualRange` actually // render. Top/bottom spacer rows preserve scroll geometry. const useVirtual = this.virtualized && viewData.length > 0; let renderRows: T[] = viewData; let renderStart = 0; let topSpacerHeight = 0; let bottomSpacerHeight = 0; if (useVirtual) { const range = this.__virtualRange; const start = Math.max(0, range.start); const end = Math.min(viewData.length, range.end || 0); // On the very first render the range is {0,0} — render a small first // window so we can measure row height and compute the real range. const initialEnd = end > 0 ? end : Math.min(viewData.length, this.virtualOverscan * 2 + 16); renderStart = start; renderRows = viewData.slice(start, initialEnd); topSpacerHeight = start * this.__rowHeight; bottomSpacerHeight = Math.max(0, viewData.length - initialEnd) * this.__rowHeight; } return html`
${this.label || this.heading1}
${this.heading2}
${directives.resolveExec(async () => { const resultArray: TemplateResult[] = []; for (const action of this.dataActions) { if (!action.type?.includes('header')) continue; resultArray.push( html`
{ action.actionFunc({ item: this.selectedDataRow, table: this, }); }} > ${action.iconName ? html` ${action.name}` : action.name}
` ); } return resultArray; })}
${this.highlightUpdates === 'flash' && !this.rowKey ? html`` : html``} ${this.data.length > 0 ? html`
${this.renderHeaderRows(effectiveColumns)} ${useVirtual && topSpacerHeight > 0 ? html`` : html``} ${directives.repeat( renderRows, (itemArg, sliceIdx) => `${this.getRowId(itemArg)}::${renderStart + sliceIdx}`, (itemArg, sliceIdx) => { const rowIndex = renderStart + sliceIdx; const rowId = this.getRowId(itemArg); const flashSet = this.__flashingCells.get(rowId); return html` ${this.showSelectionCheckbox ? html`` : html``} ${effectiveColumns .filter((c) => !c.hidden) .map((col, colIndex) => { const value = getCellValueFn(itemArg, col, this.displayFunction); const content = col.renderer ? col.renderer(value, itemArg, { rowIndex, colIndex, column: col }) : value; const editKey = String(col.key); const isEditable = !!(col.editable || col.editor); const isFocused = this.__focusedCell?.rowId === rowId && this.__focusedCell?.colKey === editKey; const isEditing = this.__editingCell?.rowId === rowId && this.__editingCell?.colKey === editKey; const isFlashing = !!flashSet?.has(editKey); const cellClasses = [ isEditable ? 'editable' : '', isFocused && !isEditing ? 'focused' : '', isEditing ? 'editingCell' : '', ] .filter(Boolean) .join(' '); const innerHtml = html`
${isEditing ? this.renderCellEditor(itemArg, col) : content}
`; return html` `; })} ${(() => { if (this.dataActions && this.dataActions.length > 0) { return html` `; } })()} `; } )} ${useVirtual && bottomSpacerHeight > 0 ? html`` : html``}
) => { e.stopPropagation(); this.setRowSelected(itemArg, e.detail === true); }} > ${isFlashing ? directives.keyed( `${rowId}:${editKey}:${this.__flashTick}`, innerHtml ) : innerHtml}
${this.getActionsForType('inRow').map( (actionArg) => html`
actionArg.actionFunc({ item: itemArg, table: this, })} > ${actionArg.iconName ? html` ` : actionArg.name}
` )}
` : html`
No data set!
`}
`; } /** * Renders the header rows. Used twice per render: once inside the real * `` and once inside the floating-header clone, so sort indicators * and filter inputs stay in sync automatically. */ private renderHeaderRows(effectiveColumns: Column[]): TemplateResult { return html` ${this.showSelectionCheckbox ? html` ${this.selectionMode === 'multi' ? html` ) => { e.stopPropagation(); this.setSelectVisible(e.detail === true); }} > ` : html``} ` : html``} ${effectiveColumns .filter((c) => !c.hidden) .map((col) => { const isSortable = col.sortable !== false; const ariaSort = this.getAriaSort(col); return html` 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)} `; })} ${this.dataActions && this.dataActions.length > 0 ? html`Actions` : html``} ${this.showColumnFilters ? html` ${this.showSelectionCheckbox ? html`` : html``} ${effectiveColumns .filter((c) => !c.hidden) .map((col) => { const key = String(col.key); if (col.filterable === false) return html``; return html` this.setColumnFilter(key, (e.target as HTMLInputElement).value)} /> `; })} ${this.dataActions && this.dataActions.length > 0 ? html`` : html``} ` : html``} `; } // ─── Floating header (page-sticky) lifecycle ───────────────────────── private __floatingResizeObserver?: ResizeObserver; private __floatingScrollHandler?: () => void; // __floatingActive is declared as a @state field above so its toggle // triggers re-rendering of the floating-header clone subtree. private __scrollAncestors: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = []; private get __floatingHeaderEl(): HTMLDivElement | null { return this.shadowRoot?.querySelector('.floatingHeader') ?? null; } private get __realTableEl(): HTMLTableElement | null { return this.shadowRoot?.querySelector('.tableScroll > table') ?? null; } private get __floatingTableEl(): HTMLTableElement | null { return this.shadowRoot?.querySelector('.floatingHeader > table') ?? null; } /** * Walks up the DOM (and through shadow roots) collecting every ancestor * element whose computed `overflow-y` makes it a scroll container, plus * `window` at the end. We listen for scroll on all of them so the floating * header reacts whether the user scrolls the page or any nested container. */ private __collectScrollAncestors(): Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> { const result: Array<{ target: Element | Window; scrollsY: boolean; scrollsX: boolean }> = []; let node: Node | null = this as unknown as Node; const scrollish = (v: string) => v === 'auto' || v === 'scroll' || v === 'overlay'; while (node) { if (node instanceof Element) { const style = getComputedStyle(node); const sy = scrollish(style.overflowY); const sx = scrollish(style.overflowX); if (sy || sx) { result.push({ target: node, scrollsY: sy, scrollsX: sx }); } } const parent = (node as any).assignedSlot ? (node as any).assignedSlot : node.parentNode; if (parent) { node = parent; } else if ((node as ShadowRoot).host) { node = (node as ShadowRoot).host; } else { node = null; } } result.push({ target: window, scrollsY: true, scrollsX: true }); return result; } /** * Returns the "stick line" — the y-coordinate (in viewport space) at which * the floating header should appear. Defaults to 0 (page top), but if the * table is inside a scroll container we use that container's content-box * top so the header sits inside the container's border/padding instead of * floating over it. */ private __getStickContext(): { top: number; left: number; right: number } { let top = 0; let left = 0; let right = window.innerWidth; for (const a of this.__scrollAncestors) { if (a.target === window) continue; const el = a.target as Element; const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); // Only constrain top from ancestors that actually scroll vertically — // a horizontal-only scroll container (like .tableScroll) must not push // the stick line down to its own top. if (a.scrollsY) { const bt = parseFloat(cs.borderTopWidth) || 0; top = Math.max(top, r.top + bt); } // Same for horizontal clipping. if (a.scrollsX) { const bl = parseFloat(cs.borderLeftWidth) || 0; const br = parseFloat(cs.borderRightWidth) || 0; left = Math.max(left, r.left + bl); right = Math.min(right, r.right - br); } } return { top, left, right }; } private setupFloatingHeader() { this.teardownFloatingHeader(); // Skip entirely only when neither feature needs scroll watchers. if (this.fixedHeight && !this.virtualized) return; const realTable = this.__realTableEl; if (!realTable) return; this.__scrollAncestors = this.__collectScrollAncestors(); // .tableScroll is a descendant (inside our shadow root), not an ancestor, // so the upward walk above misses it. Add it explicitly. In Mode A // (`fixedHeight`) it is the only vertical scroll source — mark it as // scrollsY in that case so virtualization picks it up. const tableScrollEl = this.shadowRoot?.querySelector('.tableScroll') as HTMLElement | null; if (tableScrollEl) { this.__scrollAncestors.unshift({ target: tableScrollEl, scrollsY: this.fixedHeight, scrollsX: true, }); } // Track resize of the real table so we can mirror its width and column widths. this.__floatingResizeObserver = new ResizeObserver(() => { if (!this.fixedHeight) this.__syncFloatingHeader(); if (this.virtualized) this.__computeVirtualRange(); }); this.__floatingResizeObserver.observe(realTable); this.__floatingScrollHandler = () => { if (!this.fixedHeight) this.__syncFloatingHeader(); // Recompute virtual range on every scroll — cheap (one rect read + // some math) and necessary so rows materialize before they're seen. if (this.virtualized) this.__computeVirtualRange(); }; for (const a of this.__scrollAncestors) { a.target.addEventListener('scroll', this.__floatingScrollHandler, { passive: true }); } window.addEventListener('resize', this.__floatingScrollHandler, { passive: true }); if (!this.fixedHeight) this.__syncFloatingHeader(); if (this.virtualized) this.__computeVirtualRange(); } private teardownFloatingHeader() { this.__floatingResizeObserver?.disconnect(); this.__floatingResizeObserver = undefined; if (this.__floatingScrollHandler) { for (const a of this.__scrollAncestors) { a.target.removeEventListener('scroll', this.__floatingScrollHandler); } window.removeEventListener('resize', this.__floatingScrollHandler); this.__floatingScrollHandler = undefined; } this.__scrollAncestors = []; this.__floatingActive = false; const fh = this.__floatingHeaderEl; if (fh) fh.classList.remove('active'); } /** * If a filter `` inside the floating-header clone currently has * focus, copy its value, caret, and selection range onto the matching * input in the real header, then focus that real input. This lets the * user keep typing uninterrupted when filter input causes the table to * shrink below the viewport stick line and the floating header has to * unmount. * * Safe to call at any time — it is a no-op unless an input inside the * floating header is focused and has a `data-col-key` attribute that * matches a real-header input. */ private __transferFocusToRealHeader(): void { const fh = this.__floatingHeaderEl; if (!fh) return; const active = this.shadowRoot?.activeElement as HTMLElement | null; if (!active || !fh.contains(active)) return; const colKey = active.getAttribute('data-col-key'); if (!colKey) return; const fromInput = active as HTMLInputElement; const real = this.shadowRoot?.querySelector( `.tableScroll > table > thead input[data-col-key="${CSS.escape(colKey)}"]` ) as HTMLInputElement | null; if (!real || real === fromInput) return; const selStart = fromInput.selectionStart; const selEnd = fromInput.selectionEnd; const selDir = fromInput.selectionDirection as any; real.focus({ preventScroll: true }); try { if (selStart != null && selEnd != null) { real.setSelectionRange(selStart, selEnd, selDir || undefined); } } catch { /* setSelectionRange throws on unsupported input types — ignore */ } } /** * Symmetric counterpart to `__transferFocusToRealHeader`. When the * floating header has just activated and a real-header filter input * was focused (and is now scrolled off-screen behind the floating * clone), move focus to the clone's matching input so the user keeps * typing in the visible one. * * Called from `__syncFloatingHeader` inside the post-activation * `updateComplete` callback — by then the clone subtree exists in the * DOM and can receive focus. */ private __transferFocusToFloatingHeader(): void { const fh = this.__floatingHeaderEl; if (!fh || !this.__floatingActive) return; const active = this.shadowRoot?.activeElement as HTMLElement | null; if (!active) return; // Only handle focus that lives in the real header (not already in the clone). const realThead = this.shadowRoot?.querySelector( '.tableScroll > table > thead' ) as HTMLElement | null; if (!realThead || !realThead.contains(active)) return; const colKey = active.getAttribute('data-col-key'); if (!colKey) return; const fromInput = active as HTMLInputElement; const clone = fh.querySelector( `input[data-col-key="${CSS.escape(colKey)}"]` ) as HTMLInputElement | null; if (!clone || clone === fromInput) return; const selStart = fromInput.selectionStart; const selEnd = fromInput.selectionEnd; const selDir = fromInput.selectionDirection as any; clone.focus({ preventScroll: true }); try { if (selStart != null && selEnd != null) { clone.setSelectionRange(selStart, selEnd, selDir || undefined); } } catch { /* ignore */ } } // ─── Virtualization ───────────────────────────────────────────────── /** * Computes the visible row range based on the table's position in its * nearest vertical scroll ancestor (or the viewport). Updates * `__virtualRange` if it changed; that triggers a Lit re-render. */ private __computeVirtualRange() { if (!this.virtualized) return; const view: T[] = (this as any)._lastViewData ?? []; const total = view.length; if (total === 0) { if (this.__virtualRange.start !== 0 || this.__virtualRange.end !== 0) { this.__virtualRange = { start: 0, end: 0 }; } return; } const realTable = this.__realTableEl; if (!realTable) return; const tableRect = realTable.getBoundingClientRect(); // Find the innermost vertical scroll ancestor (rect + content height). let viewportTop = 0; let viewportBottom = window.innerHeight; for (const a of this.__scrollAncestors) { if (a.target === window || !a.scrollsY) continue; const r = (a.target as Element).getBoundingClientRect(); const cs = getComputedStyle(a.target as Element); const bt = parseFloat(cs.borderTopWidth) || 0; const bb = parseFloat(cs.borderBottomWidth) || 0; viewportTop = Math.max(viewportTop, r.top + bt); viewportBottom = Math.min(viewportBottom, r.bottom - bb); } const rowH = Math.max(1, this.__rowHeight); // Distance from the table top to the visible window top, in px of body // content (so any header offset above the rows is excluded). const headerHeight = realTable.tHead?.getBoundingClientRect().height ?? 0; const bodyTop = tableRect.top + headerHeight; const offsetIntoBody = Math.max(0, viewportTop - bodyTop); const visiblePx = Math.max(0, viewportBottom - Math.max(viewportTop, bodyTop)); const startRaw = Math.floor(offsetIntoBody / rowH); const visibleCount = Math.ceil(visiblePx / rowH) + 1; const start = Math.max(0, startRaw - this.virtualOverscan); const end = Math.min(total, startRaw + visibleCount + this.virtualOverscan); if (start !== this.__virtualRange.start || end !== this.__virtualRange.end) { this.__virtualRange = { start, end }; } } /** * Measures the height of the first rendered body row and stores it for * subsequent virtualization math. Idempotent — only measures once per * `data`/`columns` pair (cleared in `updated()` when those change). */ private __measureRowHeight() { if (!this.virtualized || this.__rowHeightMeasured) return; const tbody = this.shadowRoot?.querySelector('tbody') as HTMLTableSectionElement | null; if (!tbody) return; const firstRow = Array.from(tbody.rows).find((r) => r.hasAttribute('data-row-idx')); if (!firstRow) return; const h = firstRow.getBoundingClientRect().height; if (h > 0) { this.__rowHeight = h; this.__rowHeightMeasured = true; } } /** * Single function that drives both activation and geometry of the floating * header. Called on scroll, resize, table-resize, and after relevant * renders. * * Activation is decided from the *real* header geometry, so this function * works even when the clone subtree hasn't been rendered yet (it's only * rendered when `__floatingActive` is true). The first activation flips * `__floatingActive`; the next render materializes the clone; the next * call here mirrors widths and positions. */ private __syncFloatingHeader() { const fh = this.__floatingHeaderEl; const realTable = this.__realTableEl; if (!fh || !realTable) return; const tableRect = realTable.getBoundingClientRect(); const stick = this.__getStickContext(); const realHeadRows = realTable.tHead?.rows; let headerHeight = 0; if (realHeadRows) { for (let r = 0; r < realHeadRows.length; r++) { headerHeight += realHeadRows[r].getBoundingClientRect().height; } } // 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) { if (!shouldBeActive) { // Before we flag the clone for unmount, hand off any focused // filter input to its counterpart in the real header. This is the // "user is typing in a sticky filter input, filter shrinks the // table so the floating header hides" case — without this // handoff the user's focus (and caret position) would be lost // when the clone unmounts. this.__transferFocusToRealHeader(); } 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. Additionally, if a // real-header filter input was focused when we activated, hand // off to the clone once it exists so the user keeps typing in // the visible (floating) input. this.updateComplete.then(() => { this.__syncFloatingHeader(); this.__transferFocusToFloatingHeader(); }); return; } } if (!shouldBeActive) return; // Mirror table layout + per-cell widths so columns line up. The clone // exists at this point because __floatingActive === true. const floatTable = this.__floatingTableEl; if (!floatTable) return; floatTable.style.tableLayout = realTable.style.tableLayout || 'auto'; const floatHeadRows = floatTable.tHead?.rows; if (realHeadRows && floatHeadRows) { for (let r = 0; r < realHeadRows.length && r < floatHeadRows.length; r++) { const realCells = realHeadRows[r].cells; const floatCells = floatHeadRows[r].cells; for (let c = 0; c < realCells.length && c < floatCells.length; c++) { const w = realCells[c].getBoundingClientRect().width; (floatCells[c] as HTMLElement).style.width = `${w}px`; (floatCells[c] as HTMLElement).style.minWidth = `${w}px`; (floatCells[c] as HTMLElement).style.maxWidth = `${w}px`; } } } // Position the floating header. Clip horizontally to the scroll context // so a horizontally-scrolled inner container's header doesn't bleed // outside the container's border. const clipLeft = Math.max(tableRect.left, stick.left); const clipRight = Math.min(tableRect.right, stick.right); const clipWidth = Math.max(0, clipRight - clipLeft); fh.style.top = `${stick.top}px`; fh.style.left = `${clipLeft}px`; fh.style.width = `${clipWidth}px`; // 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() { super.disconnectedCallback(); this.teardownFloatingHeader(); if (this.__flashClearTimer) { clearTimeout(this.__flashClearTimer); this.__flashClearTimer = undefined; } } public async firstUpdated() { // Floating-header observers are wired up in `updated()` once the // table markup actually exists (it only renders when data.length > 0). } /** * Runs before each render. Drives two independent concerns: * * 1. **Selection rebind** — when `data` is reassigned to a fresh array * (typical live-data pattern), `selectedDataRow` still points at the * stale row object from the old array. We re-resolve it by rowKey so * consumers of `selectedDataRow` (footer indicator, header/footer * actions, copy fallback) see the live reference. `selectedIds`, * `__focusedCell`, `__editingCell`, `__selectionAnchorId` are all * keyed by string rowId and persist automatically — no change needed. * This runs regardless of `highlightUpdates` — it is a baseline * correctness fix for live data. * * 2. **Flash diff** — when `highlightUpdates === 'flash'`, diff the new * data against `__prevSnapshot` and populate `__flashingCells` with * the (rowId, colKey) pairs whose resolved cell value changed. A * single shared timer clears `__flashingCells` after * `highlightDuration` ms. Skipped if `rowKey` is missing (with a * one-shot console.warn; the render surface also shows a warning * banner). */ public willUpdate(changedProperties: Map): void { // --- Phase 1: selection rebind (always runs) --- if (changedProperties.has('data') && this.selectedDataRow && this.rowKey) { const prevId = this.getRowId(this.selectedDataRow); let found: T | undefined; for (const row of this.data) { if (this.getRowId(row) === prevId) { found = row; break; } } if (found) { if (found !== this.selectedDataRow) this.selectedDataRow = found; } else { this.selectedDataRow = undefined as unknown as T; } } // --- Phase 2: flash diff --- if (this.highlightUpdates !== 'flash') { // Mode was toggled off (or never on) — drop any lingering state so // re-enabling later starts with a clean slate. if (this.__prevSnapshot || this.__flashingCells.size > 0) { this.__prevSnapshot = undefined; if (this.__flashingCells.size > 0) this.__flashingCells = new Map(); if (this.__flashClearTimer) { clearTimeout(this.__flashClearTimer); this.__flashClearTimer = undefined; } } return; } if (!this.rowKey) { if (!this.__flashWarnedNoRowKey) { this.__flashWarnedNoRowKey = true; console.warn( '[dees-table] highlightUpdates="flash" requires `rowKey` to be set. Flash is disabled. ' + 'Set the rowKey property/attribute to a stable identifier on your row data (e.g. `rowKey="id"`).' ); } return; } if (!changedProperties.has('data')) return; const effectiveColumns = this.__getEffectiveColumns(); const visibleCols = effectiveColumns.filter((c) => !c.hidden); const nextSnapshot = new Map>(); const newlyFlashing = new Map>(); for (const row of this.data) { const rowId = this.getRowId(row); const cellMap = new Map(); for (const col of visibleCols) { cellMap.set(String(col.key), getCellValueFn(row, col, this.displayFunction)); } nextSnapshot.set(rowId, cellMap); const prevCells = this.__prevSnapshot?.get(rowId); if (!prevCells) continue; // new row — not an "update" for (const [colKey, nextVal] of cellMap) { if (prevCells.get(colKey) !== nextVal) { // Don't flash the cell the user is actively editing. if ( this.__editingCell && this.__editingCell.rowId === rowId && this.__editingCell.colKey === colKey ) continue; let set = newlyFlashing.get(rowId); if (!set) { set = new Set(); newlyFlashing.set(rowId, set); } set.add(colKey); } } } const hadPrev = !!this.__prevSnapshot; this.__prevSnapshot = nextSnapshot; if (!hadPrev) return; // first time seeing data — no flashes if (newlyFlashing.size === 0) return; // Merge with any in-flight flashes from a rapid second update so a cell // that changes twice before its animation ends gets a single clean // restart (via __flashTick / directives.keyed) instead of stacking. for (const [rowId, cols] of newlyFlashing) { const existing = this.__flashingCells.get(rowId); if (existing) { for (const c of cols) existing.add(c); } else { this.__flashingCells.set(rowId, cols); } } this.__flashTick++; // Reactivity nudge: we've mutated the Map in place, so give Lit a fresh // reference so the @state change fires for render. this.__flashingCells = new Map(this.__flashingCells); if (this.__flashClearTimer) clearTimeout(this.__flashClearTimer); this.__flashClearTimer = setTimeout(() => { this.__flashingCells = new Map(); this.__flashClearTimer = undefined; }, Math.max(0, this.highlightDuration)); } public async updated(changedProperties: Map): Promise { super.updated(changedProperties); // Feed highlightDuration into the CSS variable so JS and CSS stay in // sync via a single source of truth. if (changedProperties.has('highlightDuration')) { this.style.setProperty('--dees-table-flash-duration', `${this.highlightDuration}ms`); } // Only re-measure column widths when the data or schema actually changed // (or on first paint). `determineColumnWidths` is the single biggest // first-paint cost — it forces multiple layout flushes per row. const dataOrColsChanged = !this.__columnsSizedFor || this.__columnsSizedFor.data !== this.data || this.__columnsSizedFor.columns !== this.columns; if (dataOrColsChanged) { this.__columnsSizedFor = { data: this.data, columns: this.columns }; this.determineColumnWidths(); // Force re-measure of row height; structure may have changed. this.__rowHeightMeasured = false; } // Virtualization: measure row height after the first paint with rows, // then compute the visible range. Both ops only run when `virtualized` // is true, so the cost is zero for normal tables. if (this.virtualized) { this.__measureRowHeight(); this.__computeVirtualRange(); } // (Re)wire the scroll watchers (used by both the floating header in // Mode B and by virtualization). Skip entirely only when neither // feature needs them. if ( changedProperties.has('fixedHeight') || changedProperties.has('virtualized') || changedProperties.has('data') || changedProperties.has('columns') || !this.__floatingScrollHandler ) { const needsScrollWatchers = (!this.fixedHeight || this.virtualized) && this.data.length > 0; if (needsScrollWatchers) { this.setupFloatingHeader(); } else { this.teardownFloatingHeader(); } } // Only sync the floating header geometry when it's actually showing or // the table layout-affecting state changed. Avoids per-render layout // reads (getBoundingClientRect on every header cell) for typical updates // like sort changes or selection toggles. if ( !this.fixedHeight && this.data.length > 0 && (this.__floatingActive || dataOrColsChanged) ) { this.__syncFloatingHeader(); } if (this.searchable) { const existing = this.dataActions.find((actionArg) => actionArg.type?.includes('header') && actionArg.name === 'Search'); if (!existing) { this.dataActions.unshift({ name: 'Search', iconName: 'lucide:Search', type: ['header'], actionFunc: async () => { console.log('open search'); const searchGrid = this.shadowRoot!.querySelector('.searchGrid'); searchGrid!.classList.toggle('hidden'); } }); 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); } }); } }); } } public async determineColumnWidths() { const domtools = await this.domtoolsPromise; await domtools.convenience.smartdelay.delayFor(0); // Get the table element const table = this.shadowRoot!.querySelector('table'); if (!table) return; // Get the first row's cells to measure the widths const cells = table.rows[0].cells; const handleColumnByIndex = async (i: number, waitForRenderArg: boolean = false) => { const done = plugins.smartpromise.defer(); const cell = cells[i]; // Get computed width const width = window.getComputedStyle(cell).width; if (cell.textContent.includes('Actions')) { const neededWidth = this.dataActions.filter((actionArg) => actionArg.type?.includes('inRow')).length * 36; cell.style.width = `${Math.max(neededWidth, 68)}px`; } else { cell.style.width = width; } if (waitForRenderArg) { requestAnimationFrame(() => { done.resolve(); }); await done.promise; } }; if (cells[cells.length - 1].textContent.includes('Actions')) { await handleColumnByIndex(cells.length - 1, true); } for (let i = 0; i < cells.length; i++) { if (cells[i].textContent.includes('Actions')) { continue; } await handleColumnByIndex(i); } table.style.tableLayout = 'fixed'; } // 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, _effectiveColumns: Column[] ) { 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) { 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) { 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): Promise { 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 | undefined; const label = c?.header ?? d.key; return html`
  • ${i + 1}. ${label} ${d.dir === 'asc' ? '▲' : '▼'}
  • `; }); DeesModal.createAndShow({ heading: 'Replace multi-column sort?', width: 'small', showCloseButton: true, content: html`

    You currently have a ${this.sortBy.length}-column sort active:

      ${summary}

    Continuing will discard the cascade and replace it with a single sort on ${targetCol.header ?? String(targetCol.key)}.

    `, 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 | 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, effectiveColumns: Column[] ) { 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, effectiveColumns: Column[]) { 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 !== false).length; const maxSlot = Math.min( Math.max(cascadeLen + (existing ? 0 : 1), 1), Math.max(sortableColumnCount, 1) ); const items: any[] = []; // Single-sort shortcuts. These are destructive when a cascade is active, so // they go through confirmReplaceCascade just like a plain click. items.push({ name: 'Sort Ascending', iconName: cascadeLen === 1 && existing?.dir === 'asc' ? 'lucide:check' : 'lucide:arrowUp', action: async () => { if (await this.confirmReplaceCascade(col)) this.setSort(key, 'asc'); return null; }, }); items.push({ name: 'Sort Descending', iconName: cascadeLen === 1 && existing?.dir === 'desc' ? 'lucide:check' : 'lucide:arrowDown', action: async () => { if (await this.confirmReplaceCascade(col)) this.setSort(key, 'desc'); return null; }, }); items.push({ divider: true }); // Priority slot entries (1..maxSlot). Each slot has an asc/desc submenu. for (let slot = 1; slot <= maxSlot; slot++) { const ordinal = ordinalLabel(slot); const isCurrentSlot = existing && this.getSortPriority(key) === slot - 1; items.push({ name: `Set as ${ordinal} sort`, iconName: isCurrentSlot ? 'lucide:check' : 'lucide:listOrdered', submenu: [ { name: 'Ascending', iconName: 'lucide:arrowUp', action: async () => { this.addSortAt(key, slot - 1, 'asc'); return null; }, }, { name: 'Descending', iconName: 'lucide:arrowDown', action: async () => { this.addSortAt(key, slot - 1, 'desc'); return null; }, }, ], }); } items.push({ divider: true }); items.push({ name: 'Append to sort', iconName: 'lucide:plus', submenu: [ { name: 'Ascending', iconName: 'lucide:arrowUp', action: async () => { this.appendSort(key, 'asc'); return null; }, }, { name: 'Descending', iconName: 'lucide:arrowDown', action: async () => { this.appendSort(key, 'desc'); return null; }, }, ], }); if (existing) { items.push({ divider: true }); items.push({ name: 'Remove from sort', iconName: 'lucide:minus', action: async () => { this.removeSort(key); return null; }, }); } if (cascadeLen > 0) { if (!existing) items.push({ divider: true }); items.push({ name: 'Clear all sorts', iconName: 'lucide:trash', action: async () => { this.clearSorts(); return null; }, }); } items.push({ divider: true }); items.push({ name: this.showColumnFilters ? 'Hide column filters' : 'Show column filters', iconName: this.showColumnFilters ? 'lucide:filterX' : 'lucide:filter', action: async () => { this.showColumnFilters = !this.showColumnFilters; this.requestUpdate(); return null; }, }); return items; } // ─── sort: indicator + ARIA ────────────────────────────────────────── private getAriaSort(col: Column): '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) { 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`${arrow}`; } return html`${arrow}${idx + 1}`; } // filtering helpers public setFilterText(value: string) { const prev = this.filterText; this.filterText = value ?? ''; if (prev !== this.filterText) { this.dispatchEvent( new CustomEvent('filterChange', { detail: { text: this.filterText, columns: { ...this.columnFilters } }, bubbles: true, }) ); this.requestUpdate(); } } public setColumnFilter(key: string, value: string) { this.columnFilters = { ...this.columnFilters, [key]: value }; this.dispatchEvent( new CustomEvent('filterChange', { detail: { text: this.filterText, columns: { ...this.columnFilters } }, bubbles: true, }) ); this.requestUpdate(); } // selection helpers private getRowId(row: T): string { if (this.rowKey) { if (typeof this.rowKey === 'function') return this.rowKey(row); return String((row as any)[this.rowKey]); } const key = row as any as object; if (!this._rowIdMap.has(key)) { this._rowIdMap.set(key, String(++this._rowIdCounter)); } return this._rowIdMap.get(key)!; } private isRowSelected(row: T): boolean { return this.selectedIds.has(this.getRowId(row)); } private toggleRowSelected(row: T) { const id = this.getRowId(row); if (this.selectionMode === 'single') { this.selectedIds.clear(); this.selectedIds.add(id); } else if (this.selectionMode === 'multi') { if (this.selectedIds.has(id)) this.selectedIds.delete(id); else this.selectedIds.add(id); } this.emitSelectionChange(); this.requestUpdate(); } // ─── Delegated tbody event handlers ───────────────────────────────── // Hoisted from per- closures to a single set of handlers on . // 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 } | null { const row = this.__resolveRow(eventArg); if (!row) return null; const path = (eventArg.composedPath?.() || []) as EventTarget[]; let td: HTMLTableCellElement | null = null; for (const t of path) { const el = t as HTMLElement; if (el?.tagName === 'TD' && el.hasAttribute('data-col-key')) { td = el as HTMLTableCellElement; break; } } if (!td) return null; const colKey = td.getAttribute('data-col-key')!; const cols = this.__getEffectiveColumns(); const col = cols.find((c) => String(c.key) === colKey); if (!col) return null; return { item: row.item, rowIdx: row.rowIdx, col }; } private __isInActionsCol(eventArg: Event): boolean { const path = (eventArg.composedPath?.() || []) as EventTarget[]; for (const t of path) { const el = t as HTMLElement; if (el?.classList?.contains('actionsCol')) return true; } return false; } private __isInEditor(eventArg: Event): boolean { const path = (eventArg.composedPath?.() || []) as EventTarget[]; for (const t of path) { const el = t as HTMLElement; const tag = el?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || el?.isContentEditable) return true; if (tag && tag.startsWith('DEES-INPUT-')) return true; } return false; } private __onTbodyClick = (eventArg: MouseEvent) => { if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return; const cell = this.__resolveCell(eventArg); if (!cell) return; const view: T[] = (this as any)._lastViewData ?? []; // Cell focus (when editable) if (cell.col.editable || cell.col.editor) { this.__focusedCell = { rowId: this.getRowId(cell.item), colKey: String(cell.col.key), }; } // Row selection (file-manager style) this.handleRowClick(eventArg, cell.item, cell.rowIdx, view); }; private __onTbodyDblclick = (eventArg: MouseEvent) => { if (this.__isInEditor(eventArg) || this.__isInActionsCol(eventArg)) return; const cell = this.__resolveCell(eventArg); if (!cell) return; const isEditable = !!(cell.col.editable || cell.col.editor); if (isEditable) { eventArg.stopPropagation(); this.startEditing(cell.item, cell.col); return; } const dblAction = this.dataActions.find((a) => a.type?.includes('doubleClick')); if (dblAction) dblAction.actionFunc({ item: cell.item, table: this }); }; private __onTbodyMousedown = (eventArg: MouseEvent) => { // Suppress browser's native shift-click text selection so range-select // doesn't highlight text mid-table. if (eventArg.shiftKey && this.selectionMode !== 'single') eventArg.preventDefault(); }; private __onTbodyContextmenu = (eventArg: MouseEvent) => { if (this.__isInActionsCol(eventArg)) return; const row = this.__resolveRow(eventArg); if (!row) return; const item = row.item; // Match file-manager behavior: right-clicking a non-selected row makes // it the selection first. if (!this.isRowSelected(item)) { this.selectedDataRow = item; this.selectedIds.clear(); this.selectedIds.add(this.getRowId(item)); this.__selectionAnchorId = this.getRowId(item); this.emitSelectionChange(); this.requestUpdate(); } const userItems: plugins.tsclass.website.IMenuItem[] = this.getActionsForType('contextmenu').map( (action) => ({ name: action.name, iconName: action.iconName as any, action: async () => { await action.actionFunc({ item, table: this }); return null; }, }) ); const defaultItems: plugins.tsclass.website.IMenuItem[] = [ { name: this.selectedIds.size > 1 ? `Copy ${this.selectedIds.size} rows as JSON` : 'Copy row as JSON', iconName: 'lucide:Copy' as any, action: async () => { this.copySelectionAsJson(item); return null; }, }, ]; DeesContextmenu.openContextMenuWithOptions(eventArg, [...userItems, ...defaultItems]); }; private __onTbodyDragenter = (eventArg: DragEvent) => { eventArg.preventDefault(); eventArg.stopPropagation(); const row = this.__resolveRow(eventArg); if (!row) return; const tr = (eventArg.composedPath?.() || []).find( (t) => (t as HTMLElement)?.tagName === 'TR' ) as HTMLElement | undefined; if (tr) setTimeout(() => tr.classList.add('hasAttachment'), 0); }; private __onTbodyDragleave = (eventArg: DragEvent) => { eventArg.preventDefault(); eventArg.stopPropagation(); const tr = (eventArg.composedPath?.() || []).find( (t) => (t as HTMLElement)?.tagName === 'TR' ) as HTMLElement | undefined; if (tr) tr.classList.remove('hasAttachment'); }; private __onTbodyDragover = (eventArg: DragEvent) => { eventArg.preventDefault(); }; private __onTbodyDrop = async (eventArg: DragEvent) => { eventArg.preventDefault(); const row = this.__resolveRow(eventArg); if (!row) return; const item = row.item; const newFiles: File[] = []; for (const file of Array.from(eventArg.dataTransfer!.files)) { this.files.push(file); newFiles.push(file); this.requestUpdate(); } const existing: File[] | undefined = this.fileWeakMap.get(item as object); if (!existing) this.fileWeakMap.set(item as object, newFiles); else existing.push(...newFiles); }; /** * Handles row clicks with file-manager style selection semantics: * - plain click: select only this row, set anchor * - cmd/ctrl+click: toggle this row in/out, set anchor * - shift+click: select the contiguous range from the anchor to this row * * Multi-row click selection is always available (`selectionMode === 'none'` * and `'multi'` both behave this way) so consumers can always copy a set * of rows. Only `selectionMode === 'single'` restricts to one row. */ private handleRowClick(eventArg: MouseEvent, item: T, rowIndex: number, view: T[]) { const id = this.getRowId(item); if (this.selectionMode === 'single') { this.selectedDataRow = item; this.selectedIds.clear(); this.selectedIds.add(id); this.__selectionAnchorId = id; this.emitSelectionChange(); this.requestUpdate(); return; } // multi const isToggle = eventArg.metaKey || eventArg.ctrlKey; const isRange = eventArg.shiftKey; if (isRange && this.__selectionAnchorId !== undefined) { // Clear any text selection the browser may have created. window.getSelection?.()?.removeAllRanges(); const anchorIdx = view.findIndex((r) => this.getRowId(r) === this.__selectionAnchorId); if (anchorIdx >= 0) { const [a, b] = anchorIdx <= rowIndex ? [anchorIdx, rowIndex] : [rowIndex, anchorIdx]; this.selectedIds.clear(); for (let i = a; i <= b; i++) this.selectedIds.add(this.getRowId(view[i])); } else { // Anchor no longer in view (filter changed, etc.) — fall back to single select. this.selectedIds.clear(); this.selectedIds.add(id); this.__selectionAnchorId = id; } this.selectedDataRow = item; } else if (isToggle) { const wasSelected = this.selectedIds.has(id); if (wasSelected) { this.selectedIds.delete(id); // If we just deselected the focused row, move focus to another // selected row (or clear it) so the highlight goes away. if (this.selectedDataRow === item) { const remaining = view.find((r) => this.selectedIds.has(this.getRowId(r))); this.selectedDataRow = remaining as T; } } else { this.selectedIds.add(id); this.selectedDataRow = item; } this.__selectionAnchorId = id; } else { this.selectedDataRow = item; this.selectedIds.clear(); this.selectedIds.add(id); this.__selectionAnchorId = id; } this.emitSelectionChange(); this.requestUpdate(); } private setRowSelected(row: T, checked: boolean) { const id = this.getRowId(row); if (this.selectionMode === 'single') { this.selectedIds.clear(); if (checked) this.selectedIds.add(id); } else if (this.selectionMode === 'multi') { if (checked) this.selectedIds.add(id); else this.selectedIds.delete(id); } this.emitSelectionChange(); this.requestUpdate(); } private areAllVisibleSelected(): boolean { const view: T[] = (this as any)._lastViewData || []; if (view.length === 0) return false; for (const r of view) { if (!this.selectedIds.has(this.getRowId(r))) return false; } return true; } private isVisibleSelectionIndeterminate(): boolean { const view: T[] = (this as any)._lastViewData || []; if (view.length === 0) return false; let count = 0; for (const r of view) { if (this.selectedIds.has(this.getRowId(r))) count++; } return count > 0 && count < view.length; } private setSelectVisible(checked: boolean) { const view: T[] = (this as any)._lastViewData || []; if (checked) { for (const r of view) this.selectedIds.add(this.getRowId(r)); } else { for (const r of view) this.selectedIds.delete(this.getRowId(r)); } this.emitSelectionChange(); this.requestUpdate(); } private emitSelectionChange() { const selectedIds = Array.from(this.selectedIds); const selectedRows = this.data.filter((r) => this.selectedIds.has(this.getRowId(r))); this.dispatchEvent( new CustomEvent('selectionChange', { detail: { selectedIds, selectedRows }, bubbles: true, }) ); } getActionsForType(typeArg: ITableAction['type'][0]) { const actions: ITableAction[] = []; for (const action of this.dataActions) { if (!action.type?.includes(typeArg)) continue; actions.push(action); } return actions; } // ─── Cell editing ───────────────────────────────────────────────────── /** True if the column has any in-cell editor configured. */ private __isColumnEditable(col: Column): boolean { return !!(col.editable || col.editor); } /** Effective columns filtered to those that can be edited (visible only). */ private __editableColumns(effectiveColumns: Column[]): Column[] { 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) { if (!this.__isColumnEditable(col)) return; const rowId = this.getRowId(item); const colKey = String(col.key); this.__focusedCell = { rowId, colKey }; this.__editingCell = { rowId, colKey }; this.requestUpdate(); this.updateComplete.then(() => { const el = this.shadowRoot?.querySelector( '.editingCell dees-input-text, .editingCell dees-input-checkbox, ' + '.editingCell dees-input-dropdown, .editingCell dees-input-datepicker, ' + '.editingCell dees-input-tags' ) as any; el?.focus?.(); // Dropdown editors should auto-open so the user can pick immediately. if (el?.tagName === 'DEES-INPUT-DROPDOWN') { el.updateComplete?.then(() => el.toggleSelectionBox?.()); } }); } /** Closes the editor without committing. */ public cancelCellEdit() { this.__editingCell = undefined; this.requestUpdate(); } /** * Commits an editor value to the row. Runs `parse` then `validate`. On * validation failure, fires `cellEditError` and leaves the editor open. * On success, mutates `data` in place, fires `cellEdit`, and closes the * editor. */ public commitCellEdit(item: T, col: Column, 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; } } if (parsed !== oldValue) { (item as any)[col.key] = parsed; // Keep the flash-diff snapshot in sync so the next external update // does not see this user edit as an external change (which would // otherwise flash the cell the user just typed into). this.__recordCellInSnapshot(item, col); 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(); } /** * Updates the flash diff snapshot for a single cell to match its current * resolved value. Called from `commitCellEdit` so a user-initiated edit * does not register as an external change on the next diff pass. * No-op when flash mode is off or no snapshot exists yet. */ private __recordCellInSnapshot(item: T, col: Column): void { if (this.highlightUpdates !== 'flash' || !this.__prevSnapshot) return; if (!this.rowKey) return; const rowId = this.getRowId(item); let cellMap = this.__prevSnapshot.get(rowId); if (!cellMap) { cellMap = new Map(); this.__prevSnapshot.set(rowId, cellMap); } cellMap.set(String(col.key), getCellValueFn(item, col, this.displayFunction)); } /** Renders the appropriate dees-input-* component for this column. */ private renderCellEditor(item: T, col: Column): 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`) => { e.stopPropagation(); this.commitCellEdit(item, col, e.detail); }} >`; case 'dropdown': { const options = (col.editorOptions?.options as any[]) ?? []; const selected = options.find((o: any) => { if (o == null) return false; if (typeof o === 'string') return o === raw; return o.key === raw || o.option === raw; }) ?? null; return html`) => { e.stopPropagation(); const detail = e.detail; const newRaw = detail?.option ?? detail?.key ?? detail; this.commitCellEdit(item, col, newRaw); }} >`; } case 'date': return html` onTextCommit(e.target)} @keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)} >`; case 'tags': return html` onTextCommit(e.target)} @keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)} >`; case 'number': case 'text': default: return html` onTextCommit(e.target)} @keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)} >`; } } /** * 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) { 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[] = 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; } while (colIdx < 0) { colIdx += editableCols.length; rowIdx -= 1; } } if (dy !== 0) rowIdx += dy; // 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(); } } }