diff --git a/changelog.md b/changelog.md index e197239..37b7619 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-07 - 3.65.0 - feat(dees-table) +add schema-based in-cell editing with keyboard navigation and cell edit events + +- replace editableFields with per-column editor configuration for text, number, checkbox, dropdown, date, and tags inputs +- add focused/editing cell state with arrow key navigation plus Enter, Tab, Shift+Tab, F2, and Escape editing controls +- dispatch cellEdit and cellEditError events with typed payloads and support column-level format, parse, validate, and editorOptions hooks +- update table styles and demos to reflect editable cell behavior and rename sticky header usage to fixedHeight + ## 2026-04-07 - 3.64.0 - feat(dees-table) add file-manager style row selection and JSON copy support diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index d532ed7..39821ed 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.64.0', + version: '3.65.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts b/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts index 5012cc1..f0fadb0 100644 --- a/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts +++ b/ts_web/elements/00group-dataview/dees-table/dees-table.demo.ts @@ -55,36 +55,66 @@ export const demoFunc = () => html`

Basic Table with Actions

-

A standard table with row actions, editable fields, and context menu support. Double-click on descriptions to edit. Grid lines are enabled by default.

+

A standard table with row actions, editable cells, and context menu support. Double-click any cell to edit. Tab moves to the next editable cell, Enter to the row below, Esc cancels.

console.log('cellEdit', e.detail)} .data=${[ { date: '2021-04-01', amount: '2464.65 €', - description: 'Printing Paper (Office Supplies) - STAPLES BREMEN', + category: 'office', + description: 'Printing Paper - STAPLES BREMEN', + reconciled: true, }, { date: '2021-04-02', amount: '165.65 €', - description: 'Logitech Mouse (Hardware) - logi.com OnlineShop', + category: 'hardware', + description: 'Logitech Mouse - logi.com OnlineShop', + reconciled: false, }, { date: '2021-04-03', amount: '2999,00 €', - description: 'Macbook Pro 16inch (Hardware) - Apple.de OnlineShop', + category: 'hardware', + description: 'Macbook Pro 16inch - Apple.de OnlineShop', + reconciled: false, }, { date: '2021-04-01', amount: '2464.65 €', + category: 'office', description: 'Office-Supplies - STAPLES BREMEN', + reconciled: true, }, { date: '2021-04-01', amount: '2464.65 €', + category: 'office', description: 'Office-Supplies - STAPLES BREMEN', + reconciled: true, }, ]} dataName="transactions" @@ -510,13 +540,13 @@ export const demoFunc = () => html`

Column Filters + Sticky Header (New)

Per-column quick filters and sticky header with internal scroll. Try filtering the Name column. Uses --table-max-height var.

html` extends DeesElement { }) accessor selectedDataRow!: T; - @property({ - type: Array, - }) - accessor editableFields: string[] = []; - @property({ type: Boolean, reflect: true, @@ -224,6 +231,20 @@ export class DeesTable extends DeesElement { */ 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; + constructor() { super(); // Make the host focusable so it can receive Ctrl/Cmd+C for copy. @@ -238,24 +259,84 @@ export class DeesTable extends DeesElement { * receive the copy. */ private __handleHostKeydown = (eventArg: KeyboardEvent) => { - const isCopy = (eventArg.metaKey || eventArg.ctrlKey) && (eventArg.key === 'c' || eventArg.key === 'C'); - if (!isCopy) return; - // Don't hijack copy when the user is selecting text in an input/textarea. + // 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') return; - if ((t as HTMLElement)?.isContentEditable) return; + if (tag === 'INPUT' || tag === 'TEXTAREA' || (t as HTMLElement)?.isContentEditable) { + inEditor = true; + break; + } } - 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); + + // 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; } - if (rows.length === 0) return; - eventArg.preventDefault(); - this.__writeRowsAsJson(rows); }; /** @@ -492,20 +573,48 @@ export class DeesTable extends DeesElement { ? col.renderer(value, itemArg, { rowIndex, colIndex, column: col }) : value; const editKey = String(col.key); + const isEditable = !!(col.editable || col.editor); + const rowId = this.getRowId(itemArg); + const isFocused = + this.__focusedCell?.rowId === rowId && + this.__focusedCell?.colKey === editKey; + const isEditing = + this.__editingCell?.rowId === rowId && + this.__editingCell?.colKey === editKey; + const cellClasses = [ + isEditable ? 'editable' : '', + isFocused && !isEditing ? 'focused' : '', + isEditing ? 'editingCell' : '', + ] + .filter(Boolean) + .join(' '); return html` { + if (isEditing) { + e.stopPropagation(); + return; + } + if (isEditable) { + this.__focusedCell = { rowId, colKey: editKey }; + } + }} @dblclick=${(e: Event) => { const dblAction = this.dataActions.find((actionArg) => actionArg.type?.includes('doubleClick') ); - if (this.editableFields.includes(editKey)) { - this.handleCellEditing(e, itemArg, editKey); + if (isEditable) { + e.stopPropagation(); + this.startEditing(itemArg, col); } else if (dblAction) { dblAction.actionFunc({ item: itemArg, table: this }); } }} > -
${content}
+
+ ${isEditing ? this.renderCellEditor(itemArg, col) : content} +
`; })} @@ -1524,43 +1633,216 @@ export class DeesTable extends DeesElement { 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; + // ─── Cell editing ───────────────────────────────────────────────────── - const blurInput = async (blurArg = true, saveArg = false) => { - if (blurArg) { - input.blur(); + /** 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?.(); + }); + } + + /** 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 (saveArg) { - (itemArg as any)[key] = input.value as any; // Convert string to T (you might need better type casting depending on your data structure) - this.changeSubject.next(this); + } + if (parsed !== oldValue) { + (item as any)[col.key] = parsed; + 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(); + } + + /** 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) => (o?.option ?? o?.key ?? o) === value) ?? null; + return html`) => { + e.stopPropagation(); + const detail = e.detail; + const newRaw = detail?.option ?? detail?.key ?? detail; + this.commitCellEdit(item, col, newRaw); + }} + >`; } - input.remove(); - target.style.color = originalColor; + + 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(); - }; - - // 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(); + } } } diff --git a/ts_web/elements/00group-dataview/dees-table/styles.ts b/ts_web/elements/00group-dataview/dees-table/styles.ts index d222c4c..fc23de1 100644 --- a/ts_web/elements/00group-dataview/dees-table/styles.ts +++ b/ts_web/elements/00group-dataview/dees-table/styles.ts @@ -372,32 +372,32 @@ export const tableStyles: CSSResult[] = [ 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 var(--dees-color-border-default); - border-radius: 6px; - background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')}; - color: var(--dees-color-text-primary); - 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); + + /* Editable cell affordances */ + td.editable { + cursor: text; } - - 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)')}; + td.focused { + outline: 2px solid ${cssManager.bdTheme( + 'hsl(222.2 47.4% 51.2% / 0.6)', + 'hsl(217.2 91.2% 59.8% / 0.6)' + )}; + outline-offset: -2px; + } + td.editingCell { + padding: 0; + } + td.editingCell .innerCellContainer { + padding: 0; + line-height: normal; + } + td.editingCell dees-input-text, + td.editingCell dees-input-checkbox, + td.editingCell dees-input-dropdown, + td.editingCell dees-input-datepicker, + td.editingCell dees-input-tags { + display: block; + width: 100%; } /* filter row */ diff --git a/ts_web/elements/00group-dataview/dees-table/types.ts b/ts_web/elements/00group-dataview/dees-table/types.ts index bd03625..e76b502 100644 --- a/ts_web/elements/00group-dataview/dees-table/types.ts +++ b/ts_web/elements/00group-dataview/dees-table/types.ts @@ -15,6 +15,34 @@ export interface ITableAction { actionFunc: (actionDataArg: ITableActionDataArg) => Promise; } +/** + * Available cell editor types. Each maps to a dees-input-* component. + * Use `editor` on `Column` to opt a column into in-cell editing. + */ +export type TCellEditorType = + | 'text' + | 'number' + | 'checkbox' + | 'dropdown' + | 'date' + | 'tags'; + +/** Detail payload for the `cellEdit` CustomEvent dispatched on commit. */ +export interface ICellEditDetail { + row: T; + key: string; + oldValue: any; + newValue: any; +} + +/** Detail payload for the `cellEditError` CustomEvent dispatched on validation failure. */ +export interface ICellEditErrorDetail { + row: T; + key: string; + value: any; + message: string; +} + export interface Column { key: keyof T | string; header?: string | TemplateResult; @@ -24,6 +52,18 @@ export interface Column { /** whether this column participates in per-column quick filtering (default: true) */ filterable?: boolean; hidden?: boolean; + /** Marks the column as editable. Shorthand for `editor: 'text'` if no editor is specified. */ + editable?: boolean; + /** Editor type — picks the dees-input-* component used for in-cell editing. */ + editor?: TCellEditorType; + /** Editor-specific options forwarded to the editor (e.g. `{ options: [...] }` for dropdowns). */ + editorOptions?: Record; + /** Convert raw row value -> editor value. Defaults to identity. */ + format?: (raw: any, row: T) => any; + /** Convert editor value -> raw row value. Defaults to identity. */ + parse?: (editorValue: any, row: T) => any; + /** Validate the parsed value before commit. Return string for error, true/void for ok. */ + validate?: (value: any, row: T) => true | string | void; } /**