import * as plugins from '../00plugins.js'; import { demoFunc } from './dees-table.demo.js'; import { cssGeistFontFamily } from '../00fonts.js'; import { customElement, html, DeesElement, property, type TemplateResult, cssManager, css, directives, } from '@design.estate/dees-element'; import { DeesContextmenu } from '../dees-contextmenu.js'; import * as domtools from '@design.estate/dees-domtools'; import { type TIconKey } from '../dees-icon.js'; declare global { interface HTMLElementTagNameMap { 'dees-table': DeesTable; } } // interfaces export interface ITableAction { name: string; iconName: TIconKey; /** * the table behaviour to use for this action * e.g. upload: allows to upload files to the table */ useTableBehaviour?: 'upload' | 'cancelUpload' | 'none'; /** * the type of the action */ type: ( | 'inRow' | 'contextmenu' | 'doubleClick' | 'footer' | 'header' | 'preview' | 'keyCombination' )[]; /** * allows to check if the action is relevant for the given item * @param itemArg * @returns */ actionRelevancyCheckFunc?: (itemArg: T) => boolean; /** * the actual action function implementation * @param itemArg * @returns */ actionFunc: (actionDataArg: ITableActionDataArg) => Promise; } export interface ITableActionDataArg { item: T; table: DeesTable; } // schema-first columns API (Phase 1) export interface Column { /** key in the raw item or a computed key name */ key: keyof T | string; /** header label or template; defaults to key */ header?: string | TemplateResult; /** compute the cell value when not reading directly by key */ value?: (row: T) => any; /** optional cell renderer */ renderer?: (value: any, row: T, ctx: { rowIndex: number; colIndex: number; column: Column }) => TemplateResult | string; /** reserved for future phases; present to sketch intent */ sortable?: boolean; hidden?: boolean; } export type TDisplayFunction = (itemArg: T) => object; // the table implementation @customElement('dees-table') export class DeesTable extends DeesElement { public static demo = demoFunc; // INSTANCE @property({ type: String, }) public heading1: string = 'heading 1'; @property({ type: String, }) public heading2: string = 'heading 2'; @property({ type: Array, }) public data: T[] = []; // dees-form compatibility ----------------------------------------- @property({ type: String, }) public key: string; @property({ type: String, }) public label: string; @property({ type: Boolean, }) public disabled: boolean = false; @property({ type: Boolean, }) public 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, }) public dataName: string; @property({ type: Boolean, }) searchable: boolean = true; @property({ type: Array, }) public dataActions: ITableAction[] = []; // schema-first columns API @property({ attribute: false }) public 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 }) public rowKey?: keyof T | ((row: T) => string); /** * When true and columns are provided, merge any missing columns discovered * via displayFunction into the effective schema. */ @property({ type: Boolean }) public augmentFromDisplayFunction: boolean = false; @property({ attribute: false, }) public displayFunction: TDisplayFunction = (itemArg: T) => itemArg as any; @property({ attribute: false, }) public reverseDisplayFunction: (itemArg: any) => T = (itemArg: any) => itemArg as T; @property({ type: Object, }) public selectedDataRow: T; @property({ type: Array, }) public editableFields: string[] = []; @property({ type: Boolean, reflect: true, attribute: 'show-vertical-lines' }) public showVerticalLines: boolean = false; @property({ type: Boolean, reflect: true, attribute: 'show-horizontal-lines' }) public showHorizontalLines: boolean = false; @property({ type: Boolean, reflect: true, attribute: 'show-grid' }) public showGrid: boolean = true; public files: File[] = []; public fileWeakMap = new WeakMap(); public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject(); // simple client-side sorting (Phase 1) @property({ attribute: false }) private sortKey?: string; @property({ attribute: false }) private sortDir: 'asc' | 'desc' | null = null; constructor() { super(); } public static styles = [ cssManager.defaultStyles, css` :host { display: block; width: 100%; } .mainbox { color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; font-family: ${cssGeistFontFamily}; font-weight: 400; font-size: 14px; display: block; width: 100%; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 8px; overflow: hidden; cursor: default; } .header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; min-height: 64px; border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; } .headingContainer { flex: 1; } .heading { line-height: 1.5; } .heading1 { font-size: 18px; font-weight: 600; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; letter-spacing: -0.025em; } .heading2 { font-size: 14px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; margin-top: 2px; } .headingSeparation { display: none; } .headerActions { user-select: none; display: flex; flex-direction: row; gap: 8px; } .headerAction { display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-size: 14px; font-weight: 500; color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')}; background: transparent; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 6px; cursor: pointer; transition: all 0.15s ease; } .headerAction:hover { color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; } .headerAction dees-icon { width: 14px; height: 14px; } .searchGrid { display: grid; grid-gap: 16px; grid-template-columns: 1fr 200px; padding: 16px 24px; background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(0 0% 3.9%)')}; border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; transition: all 0.2s ease; } .searchGrid.hidden { height: 0px; opacity: 0; overflow: hidden; padding: 0px 24px; border-bottom-width: 0px; } table { width: 100%; caption-side: bottom; font-size: 14px; border-collapse: separate; border-spacing: 0; } .noDataSet { padding: 48px 24px; text-align: center; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; } thead { background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')}; border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; } tbody tr { transition: background-color 0.15s ease; position: relative; } /* Default horizontal lines (bottom border only) */ tbody tr { border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; } tbody tr:last-child { border-bottom: none; } /* Full horizontal lines when enabled */ :host([show-horizontal-lines]) tbody tr { border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; } :host([show-horizontal-lines]) tbody tr:first-child { border-top: none; } :host([show-horizontal-lines]) tbody tr:last-child { border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; } tbody tr:hover { background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.5)', 'hsl(0 0% 14.9% / 0.5)')}; } /* Column hover effect for better traceability */ td { position: relative; } td::after { content: ''; position: absolute; top: -1000px; bottom: -1000px; left: 0; right: 0; background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.3)', 'hsl(0 0% 14.9% / 0.3)')}; opacity: 0; pointer-events: none; transition: opacity 0.15s ease; z-index: -1; } td:hover::after { opacity: 1; } /* Grid mode - shows both vertical and horizontal lines */ :host([show-grid]) th { border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-left: none; border-top: none; } :host([show-grid]) td { border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-left: none; border-top: none; } :host([show-grid]) th:first-child, :host([show-grid]) td:first-child { border-left: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; } :host([show-grid]) tbody tr:first-child td { border-top: none; } tbody tr.selected { background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')}; } tbody tr.hasAttachment { background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 76.2% 36.3% / 0.1)')}; } th { height: 48px; padding: 12px 24px; text-align: left; font-weight: 500; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; letter-spacing: -0.01em; } :host([show-vertical-lines]) th { border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; } td { padding: 12px 24px; vertical-align: middle; color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; } :host([show-vertical-lines]) td { border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; } th:first-child, td:first-child { padding-left: 24px; } th:last-child, td:last-child { padding-right: 24px; } :host([show-vertical-lines]) th:last-child, :host([show-vertical-lines]) td:last-child { border-right: none; } .innerCellContainer { position: relative; min-height: 24px; line-height: 24px; } td input { position: absolute; top: 4px; bottom: 4px; left: 20px; right: 20px; width: calc(100% - 40px); height: calc(100% - 8px); padding: 0 12px; outline: none; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 6px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')}; font-family: inherit; font-size: inherit; font-weight: inherit; transition: all 0.15s ease; box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); } td input:focus { border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')}; outline: 2px solid transparent; outline-offset: 2px; box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.2)', 'hsl(217.2 91.2% 59.8% / 0.2)')}; } .actionsContainer { display: flex; flex-direction: row; gap: 4px; } .action { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; cursor: pointer; transition: all 0.15s ease; } .action:hover { background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')}; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; } .action:active { background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 11.8%)')}; } .action dees-icon { width: 16px; height: 16px; } .footer { display: flex; align-items: center; justify-content: space-between; height: 52px; padding: 0 24px; font-size: 14px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')}; border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; } .tableStatistics { font-weight: 500; } .footerActions { display: flex; gap: 8px; } .footerActions .footerAction { display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-weight: 500; color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')}; border-radius: 6px; cursor: pointer; user-select: none; transition: all 0.15s ease; } .footerActions .footerAction:hover { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; } .footerActions .footerAction dees-icon { width: 14px; height: 14px; } `, ]; public render(): TemplateResult { const usingColumns = Array.isArray(this.columns) && this.columns.length > 0; const effectiveColumns: Column[] = usingColumns ? this.computeEffectiveColumns() : this.computeColumnsFromDisplayFunction(); 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.data.length > 0 ? html` ${effectiveColumns .filter((c) => !c.hidden) .map((col) => { const isSortable = !!col.sortable; const ariaSort = this.getAriaSort(col); return html` `; })} ${(() => { if (this.dataActions && this.dataActions.length > 0) { return html` `; } })()} ${this.getViewData(effectiveColumns).map((itemArg, rowIndex) => { const getTr = (elementArg: HTMLElement): HTMLElement => { if (elementArg.tagName === 'TR') { return elementArg; } else { return getTr(elementArg.parentElement); } }; return html` { this.selectedDataRow = itemArg; }} @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 = []; 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) => { DeesContextmenu.openContextMenuWithOptions( eventArg, this.getActionsForType('contextmenu').map((action) => { const menuItem: plugins.tsclass.website.IMenuItem = { name: action.name, iconName: action.iconName as any, action: async () => { await action.actionFunc({ item: itemArg, table: this, }); return null; }, }; return menuItem; }) ); }} class="${itemArg === this.selectedDataRow ? 'selected' : ''}" > ${effectiveColumns .filter((c) => !c.hidden) .map((col, colIndex) => { const value = this.getCellValue(itemArg, col); const content = col.renderer ? col.renderer(value, itemArg, { rowIndex, colIndex, column: col }) : value; const editKey = String(col.key); return html` `; })} ${(() => { if (this.dataActions && this.dataActions.length > 0) { return html` `; } })()} `; })}
(isSortable ? this.toggleSort(col) : null)} > ${col.header ?? (col.key as any)} ${this.renderSortIndicator(col)} Actions
{ const dblAction = this.dataActions.find((actionArg) => actionArg.type.includes('doubleClick') ); if (this.editableFields.includes(editKey)) { this.handleCellEditing(e, itemArg, editKey); } else if (dblAction) { dblAction.actionFunc({ item: itemArg, table: this }); } }} >
${content}
${this.getActionsForType('inRow').map( (actionArg) => html`
actionArg.actionFunc({ item: itemArg, table: this, })} > ${actionArg.iconName ? html` ` : actionArg.name}
` )}
` : html`
No data set!
`}
`; } public async firstUpdated() { } public async updated(changedProperties: Map): Promise { super.updated(changedProperties); this.determineColumnWidths(); if (this.searchable) { const existing = this.dataActions.find((actionArg) => actionArg.type.includes('header') && actionArg.name === 'Search'); if (!existing) { this.dataActions.unshift({ name: 'Search', iconName: 'magnifyingGlass', type: ['header'], actionFunc: async () => { console.log('open search'); const searchGrid = this.shadowRoot.querySelector('.searchGrid'); searchGrid.classList.toggle('hidden'); } }); console.log(this.dataActions); this.requestUpdate(); }; } } 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'; } private computeColumnsFromDisplayFunction(): Column[] { if (!this.data || this.data.length === 0) return []; const firstTransformedItem = this.displayFunction(this.data[0]); const keys: string[] = Object.keys(firstTransformedItem); return keys.map((key) => ({ key, header: key, value: (row: T) => this.displayFunction(row)[key], })); } private computeEffectiveColumns(): Column[] { const base = (this.columns || []).slice(); if (!this.augmentFromDisplayFunction) return base; const fromDisplay = this.computeColumnsFromDisplayFunction(); const existingKeys = new Set(base.map((c) => String(c.key))); for (const col of fromDisplay) { if (!existingKeys.has(String(col.key))) { base.push(col); } } return base; } private getCellValue(row: T, col: Column): any { return col.value ? col.value(row) : (row as any)[col.key as any]; } private getViewData(effectiveColumns: Column[]): T[] { if (!this.sortKey || !this.sortDir) return this.data; const col = effectiveColumns.find((c) => String(c.key) === this.sortKey); if (!col) return this.data; const arr = this.data.slice(); const dir = this.sortDir === 'asc' ? 1 : -1; arr.sort((a, b) => { const va = this.getCellValue(a, col); const vb = this.getCellValue(b, col); if (va == null && vb == null) return 0; if (va == null) return -1 * dir; if (vb == null) return 1 * dir; if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir; const sa = String(va).toLowerCase(); const sb = String(vb).toLowerCase(); if (sa < sb) return -1 * dir; if (sa > sb) return 1 * dir; return 0; }); return arr; } private toggleSort(col: Column) { const key = String(col.key); if (this.sortKey !== key) { this.sortKey = key; this.sortDir = 'asc'; } else { if (this.sortDir === 'asc') this.sortDir = 'desc'; else if (this.sortDir === 'desc') { this.sortDir = null; this.sortKey = undefined; } else this.sortDir = 'asc'; } this.dispatchEvent( new CustomEvent('sortChange', { detail: { key: this.sortKey, dir: this.sortDir }, bubbles: true, }) ); this.requestUpdate(); } private getAriaSort(col: Column): 'none' | 'ascending' | 'descending' { if (String(col.key) !== this.sortKey || !this.sortDir) return 'none'; return this.sortDir === 'asc' ? 'ascending' : 'descending'; } private renderSortIndicator(col: Column) { if (String(col.key) !== this.sortKey || !this.sortDir) return html``; return html`${this.sortDir === 'asc' ? '▲' : '▼'}`; } 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; } async handleCellEditing(event: Event, itemArg: T, key: string) { await this.domtoolsPromise; const target = event.target as HTMLElement; const originalColor = target.style.color; target.style.color = 'transparent'; const transformedItem = this.displayFunction(itemArg); const initialValue = ((transformedItem as any)[key] ?? (itemArg as any)[key] ?? '') as string; // Create an input element const input = document.createElement('input'); input.type = 'text'; input.value = initialValue; const blurInput = async (blurArg = true, saveArg = false) => { if (blurArg) { input.blur(); } if (saveArg) { itemArg[key] = input.value as any; // Convert string to T (you might need better type casting depending on your data structure) this.changeSubject.next(this); } input.remove(); target.style.color = originalColor; this.requestUpdate(); }; // When the input loses focus or the Enter key is pressed, update the data input.addEventListener('blur', () => { blurInput(false, false); }); input.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { blurInput(true, true); // This will trigger the blur event handler above } }); // Replace the cell's content with the input target.appendChild(input); input.focus(); } }