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;
}
/**