import * as colors from './00colors.js'; import * as plugins from './00plugins.js'; import { demoFunc } from './dees-table.demo.js'; import { customElement, html, DeesElement, property, type TemplateResult, cssManager, css, unsafeCSS, type CSSResult, state, resolveExec, } 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; } 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[] = []; @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[] = []; public files: File[] = []; public fileWeakMap = new WeakMap(); public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject(); constructor() { super(); } public static styles = [ cssManager.defaultStyles, css` .mainbox { color: ${cssManager.bdTheme('#333', '#fff')}; font-family: 'Geist Sans', sans-serif; font-weight: 400; font-size: 14px; padding: 16px; display: block; width: 100%; min-height: 50px; background: ${cssManager.bdTheme('#ffffff', '#222222')}; border-radius: 3px; border-top: 1px solid ${cssManager.bdTheme('#fff', '#ffffff10')}; box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.3); overflow-x: auto; cursor: default; } .header { display: flex; justify-content: flex-end; align-items: center; font-family: 'Geist Sans', sans-serif; } .headingContainer { } .heading { } .heading1 { font-weight: 600; } .heading2 { opacity: 0.6; } .headingSeparation { margin-top: 7px; border-bottom: 1px solid ${cssManager.bdTheme('#bcbcbc', '#444444')}; } .headerActions { user-select: none; display: flex; flex-direction: row; margin-left: auto; } .headerAction { display: flex; flex-direction: row; color: ${cssManager.bdTheme('#333', '#ccc')}; margin-left: 16px; } .headerAction:hover { color: ${cssManager.bdTheme('#555', '#fff')}; } .headerAction dees-icon { margin-right: 8px; } .searchGrid { background: ${cssManager.bdTheme('#fff', '#111111')}; display: grid; grid-gap: 16px; grid-template-columns: 1fr 200px; margin-top: 16px; padding: 0px 16px; border-top: 1px solid ${cssManager.bdTheme('#fff', '#ffffff20')}; border-radius: 8px; } .searchGrid.hidden { height: 0px; opacity: 0; overflow: hidden; margin-top: 0px; } table, .noDataSet { margin-top: 16px; color: ${cssManager.bdTheme('#333', '#fff')}; border-collapse: collapse; width: 100%; } .noDataSet { text-align: center; } tr { border-bottom: 1px dashed ${cssManager.bdTheme('#999', '#808080')}; text-align: left; } tr:last-child { border-bottom: none; text-align: left; } tr:hover { } tr:hover td { background: ${cssManager.bdTheme('#22222210', '#ffffff10')}; } tr:first-child:hover { cursor: auto; } tr:first-child:hover .innerCellContainer { background: none; } tr.selected td { background: ${cssManager.bdTheme('#22222220', '#ffffff20')}; } tr.hasAttachment td { background: ${cssManager.bdTheme('#0098847c', '#0098847c')}; } th { text-transform: none; font-family: 'Geist Sans', sans-serif; font-weight: 500; } th, td { position: relative; vertical-align: top; padding: 0px; border-right: 1px dashed ${cssManager.bdTheme('#999', '#808080')}; } .innerCellContainer { min-height: 36px; position: relative; height: 100%; width: 100%; padding: 6px 8px; line-height: 24px; } th:first-child .innerCellContainer, td:first-child .innerCellContainer { padding-left: 0px; } th:last-child .innerCellContainer, td:last-child .innerCellContainer { padding-right: 0px; } th:last-child, td:last-child { border-right: none; } td input { width: 100%; height: 100%; outline: none; border: 2px solid #fa6101; top: 0px; bottom: 0px; right: 0px; left: 0px; position: absolute; background: #fa610140; color: ${cssManager.bdTheme('#333', '#fff')}; font-family: inherit; font-size: inherit; font-weight: inherit; padding: 0px 6px; } .actionsContainer { display: flex; flex-direction: row; height: 24px; transform: translateY(-4px); margin-left: -6px; } .action { position: relative; padding: 8px 10px; line-height: 24px; height: 32px; size: 16px; border-radius: 8px; } .action:hover { background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)}; } .action:active { background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blueActive)}; } .action:hover dees-icon { filter: ${cssManager.bdTheme('invert(1) brightness(3)', '')}; } .footer { font-family: 'Geist Sans', sans-serif; font-size: 14px; color: ${cssManager.bdTheme('#111', '#ffffff90')}; background: ${cssManager.bdTheme('#eeeeeb', '#00000050')}; margin: 16px -16px -16px -16px; border-bottom-left-radius: 3px; border-bottom-right-radius: 3px; display: flex; } .tableStatistics { padding: 8px 16px; } .footerActions { margin-left: auto; } .footerActions .footerAction { padding: 8px 16px; display: flex; user-select: none; } .footerActions .footerAction:hover { background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)}; color: #fff; } .footerActions .footerAction dees-icon { display: flex; margin-right: 8px; } .footerActions .footerAction:hover dees-icon { } `, ]; public render(): TemplateResult { return html`
${this.label || this.heading1}
${this.heading2}
${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 ? (() => { // Only pick up the keys from the first transformed data object // as all data objects are assumed to have the same structure const firstTransformedItem = this.displayFunction(this.data[0]); const headings: string[] = Object.keys(firstTransformedItem); return html` ${headings.map( (headingArg) => html` ` )} ${(() => { if (this.dataActions && this.dataActions.length > 0) { return html` `; } })()} ${this.data.map((itemArg) => { const transformedItem = this.displayFunction(itemArg); 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); console.log('dragenter'); console.log(realTarget); 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' : ''}" > ${headings.map( (headingArg) => html` ` )} ${(() => { if (this.dataActions && this.dataActions.length > 0) { return html` `; } })()} `; })}
${headingArg}
Actions
{ if (this.editableFields.includes(headingArg)) { this.handleCellEditing(e, itemArg, headingArg); } else { const wantedAction = this.dataActions.find((actionArg) => actionArg.type.includes('doubleClick') ); if (wantedAction) { wantedAction.actionFunc({ item: itemArg, table: this, }); } } }} >
${transformedItem[headingArg]}
${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'; } 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) { const domtools = 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[key] as unknown 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(); } }