feat(dees-table): add schema-based in-cell editing with keyboard navigation and cell edit events
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user