feat(dees-table): add schema-based in-cell editing with keyboard navigation and cell edit events

This commit is contained in:
2026-04-07 15:32:10 +00:00
parent 2f95979cc6
commit efea2d62d9
6 changed files with 451 additions and 91 deletions

View File

@@ -1,13 +1,25 @@
import * as plugins from '../../00plugins.js';
import { demoFunc } from './dees-table.demo.js';
import { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element';
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, TDisplayFunction } from './types.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,
@@ -138,11 +150,6 @@ export class DeesTable<T> extends DeesElement {
})
accessor selectedDataRow!: T;
@property({
type: Array,
})
accessor editableFields: string[] = [];
@property({
type: Boolean,
reflect: true,
@@ -224,6 +231,20 @@ export class DeesTable<T> 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<T> 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<T>[] =
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<T> 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`
<td
class=${cellClasses}
@click=${(e: MouseEvent) => {
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 });
}
}}
>
<div class="innerCellContainer">${content}</div>
<div class="innerCellContainer">
${isEditing ? this.renderCellEditor(itemArg, col) : content}
</div>
</td>
`;
})}
@@ -1524,43 +1633,216 @@ export class DeesTable<T> 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<T>): boolean {
return !!(col.editable || col.editor);
}
/** Effective columns filtered to those that can be edited (visible only). */
private __editableColumns(effectiveColumns: Column<T>[]): Column<T>[] {
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<T>) {
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<T>, 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<T>): 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`<dees-input-checkbox
.value=${!!value}
@newValue=${(e: CustomEvent<boolean>) => {
e.stopPropagation();
this.commitCellEdit(item, col, e.detail);
}}
></dees-input-checkbox>`;
case 'dropdown': {
const options = (col.editorOptions?.options as any[]) ?? [];
const selected =
options.find((o: any) => (o?.option ?? o?.key ?? o) === value) ?? null;
return html`<dees-input-dropdown
.options=${options}
.selectedOption=${selected}
@selectedOption=${(e: CustomEvent<any>) => {
e.stopPropagation();
const detail = e.detail;
const newRaw = detail?.option ?? detail?.key ?? detail;
this.commitCellEdit(item, col, newRaw);
}}
></dees-input-dropdown>`;
}
input.remove();
target.style.color = originalColor;
case 'date':
return html`<dees-input-datepicker
.value=${value}
@focusout=${(e: any) => onTextCommit(e.target)}
@keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
></dees-input-datepicker>`;
case 'tags':
return html`<dees-input-tags
.value=${(value as any) ?? []}
@focusout=${(e: any) => onTextCommit(e.target)}
@keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
></dees-input-tags>`;
case 'number':
case 'text':
default:
return html`<dees-input-text
.value=${value == null ? '' : String(value)}
@focusout=${(e: any) => onTextCommit(e.target)}
@keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
></dees-input-text>`;
}
}
/**
* 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<T>) {
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<T>[] = 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();
}
}
}