feat(dees-table): add schema-based in-cell editing with keyboard navigation and cell edit events
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-07 - 3.64.0 - feat(dees-table)
|
||||||
add file-manager style row selection and JSON copy support
|
add file-manager style row selection and JSON copy support
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-catalog',
|
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.'
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,36 +55,66 @@ export const demoFunc = () => html`
|
|||||||
<div class="demo-container">
|
<div class="demo-container">
|
||||||
<div class="demo-section">
|
<div class="demo-section">
|
||||||
<h2 class="demo-title">Basic Table with Actions</h2>
|
<h2 class="demo-title">Basic Table with Actions</h2>
|
||||||
<p class="demo-description">A standard table with row actions, editable fields, and context menu support. Double-click on descriptions to edit. Grid lines are enabled by default.</p>
|
<p class="demo-description">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.</p>
|
||||||
<dees-table
|
<dees-table
|
||||||
heading1="Current Account Statement"
|
heading1="Current Account Statement"
|
||||||
heading2="Bunq - Payment Account 2 - April 2021"
|
heading2="Bunq - Payment Account 2 - April 2021"
|
||||||
.editableFields="${['description']}"
|
.columns=${[
|
||||||
|
{ key: 'date', header: 'Date', sortable: true, editable: true, editor: 'date' },
|
||||||
|
{ key: 'amount', header: 'Amount', editable: true, editor: 'text' },
|
||||||
|
{
|
||||||
|
key: 'category',
|
||||||
|
header: 'Category',
|
||||||
|
editable: true,
|
||||||
|
editor: 'dropdown',
|
||||||
|
editorOptions: {
|
||||||
|
options: [
|
||||||
|
{ option: 'Office Supplies', key: 'office' },
|
||||||
|
{ option: 'Hardware', key: 'hardware' },
|
||||||
|
{ option: 'Software', key: 'software' },
|
||||||
|
{ option: 'Travel', key: 'travel' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'description', header: 'Description', editable: true },
|
||||||
|
{ key: 'reconciled', header: 'OK', editable: true, editor: 'checkbox' },
|
||||||
|
]}
|
||||||
|
@cellEdit=${(e: CustomEvent) => console.log('cellEdit', e.detail)}
|
||||||
.data=${[
|
.data=${[
|
||||||
{
|
{
|
||||||
date: '2021-04-01',
|
date: '2021-04-01',
|
||||||
amount: '2464.65 €',
|
amount: '2464.65 €',
|
||||||
description: 'Printing Paper (Office Supplies) - STAPLES BREMEN',
|
category: 'office',
|
||||||
|
description: 'Printing Paper - STAPLES BREMEN',
|
||||||
|
reconciled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-04-02',
|
date: '2021-04-02',
|
||||||
amount: '165.65 €',
|
amount: '165.65 €',
|
||||||
description: 'Logitech Mouse (Hardware) - logi.com OnlineShop',
|
category: 'hardware',
|
||||||
|
description: 'Logitech Mouse - logi.com OnlineShop',
|
||||||
|
reconciled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-04-03',
|
date: '2021-04-03',
|
||||||
amount: '2999,00 €',
|
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',
|
date: '2021-04-01',
|
||||||
amount: '2464.65 €',
|
amount: '2464.65 €',
|
||||||
|
category: 'office',
|
||||||
description: 'Office-Supplies - STAPLES BREMEN',
|
description: 'Office-Supplies - STAPLES BREMEN',
|
||||||
|
reconciled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '2021-04-01',
|
date: '2021-04-01',
|
||||||
amount: '2464.65 €',
|
amount: '2464.65 €',
|
||||||
|
category: 'office',
|
||||||
description: 'Office-Supplies - STAPLES BREMEN',
|
description: 'Office-Supplies - STAPLES BREMEN',
|
||||||
|
reconciled: true,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
dataName="transactions"
|
dataName="transactions"
|
||||||
@@ -510,13 +540,13 @@ export const demoFunc = () => html`
|
|||||||
<h2 class="demo-title">Column Filters + Sticky Header (New)</h2>
|
<h2 class="demo-title">Column Filters + Sticky Header (New)</h2>
|
||||||
<p class="demo-description">Per-column quick filters and sticky header with internal scroll. Try filtering the Name column. Uses --table-max-height var.</p>
|
<p class="demo-description">Per-column quick filters and sticky header with internal scroll. Try filtering the Name column. Uses --table-max-height var.</p>
|
||||||
<style>
|
<style>
|
||||||
dees-table[sticky-header] { --table-max-height: 220px; }
|
dees-table[fixed-height] { --table-max-height: 220px; }
|
||||||
</style>
|
</style>
|
||||||
<dees-table
|
<dees-table
|
||||||
heading1="Employees"
|
heading1="Employees"
|
||||||
heading2="Quick filter per column + sticky header"
|
heading2="Quick filter per column + sticky header"
|
||||||
.showColumnFilters=${true}
|
.showColumnFilters=${true}
|
||||||
.stickyHeader=${true}
|
.fixedHeight=${true}
|
||||||
.columns=${[
|
.columns=${[
|
||||||
{ key: 'name', header: 'Name', sortable: true },
|
{ key: 'name', header: 'Name', sortable: true },
|
||||||
{ key: 'email', header: 'Email', sortable: true },
|
{ key: 'email', header: 'Email', sortable: true },
|
||||||
@@ -669,7 +699,7 @@ export const demoFunc = () => html`
|
|||||||
</style>
|
</style>
|
||||||
<dees-table
|
<dees-table
|
||||||
id="scrollSmallHeight"
|
id="scrollSmallHeight"
|
||||||
.stickyHeader=${true}
|
.fixedHeight=${true}
|
||||||
heading1="People Directory (Scrollable)"
|
heading1="People Directory (Scrollable)"
|
||||||
heading2="Forced scrolling with many items"
|
heading2="Forced scrolling with many items"
|
||||||
.columns=${[
|
.columns=${[
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import * as plugins from '../../00plugins.js';
|
import * as plugins from '../../00plugins.js';
|
||||||
import { demoFunc } from './dees-table.demo.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 { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
|
||||||
import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
|
import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
import * as domtools from '@design.estate/dees-domtools';
|
||||||
import { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.js';
|
import { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.js';
|
||||||
import { tableStyles } from './styles.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 {
|
import {
|
||||||
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
|
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
|
||||||
computeEffectiveColumns as computeEffectiveColumnsFn,
|
computeEffectiveColumns as computeEffectiveColumnsFn,
|
||||||
@@ -138,11 +150,6 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
})
|
})
|
||||||
accessor selectedDataRow!: T;
|
accessor selectedDataRow!: T;
|
||||||
|
|
||||||
@property({
|
|
||||||
type: Array,
|
|
||||||
})
|
|
||||||
accessor editableFields: string[] = [];
|
|
||||||
|
|
||||||
@property({
|
@property({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
reflect: true,
|
reflect: true,
|
||||||
@@ -224,6 +231,20 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
*/
|
*/
|
||||||
private __selectionAnchorId?: string;
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
// Make the host focusable so it can receive Ctrl/Cmd+C for copy.
|
// Make the host focusable so it can receive Ctrl/Cmd+C for copy.
|
||||||
@@ -238,15 +259,23 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
* receive the copy.
|
* receive the copy.
|
||||||
*/
|
*/
|
||||||
private __handleHostKeydown = (eventArg: KeyboardEvent) => {
|
private __handleHostKeydown = (eventArg: KeyboardEvent) => {
|
||||||
const isCopy = (eventArg.metaKey || eventArg.ctrlKey) && (eventArg.key === 'c' || eventArg.key === 'C');
|
// Detect whether the keydown originated inside an editor (input/textarea
|
||||||
if (!isCopy) return;
|
// or contenteditable). Used to skip both copy hijacking and grid nav.
|
||||||
// Don't hijack copy when the user is selecting text in an input/textarea.
|
|
||||||
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
const path = (eventArg.composedPath?.() || []) as EventTarget[];
|
||||||
|
let inEditor = false;
|
||||||
for (const t of path) {
|
for (const t of path) {
|
||||||
const tag = (t as HTMLElement)?.tagName;
|
const tag = (t as HTMLElement)?.tagName;
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || (t as HTMLElement)?.isContentEditable) {
|
||||||
if ((t as HTMLElement)?.isContentEditable) return;
|
inEditor = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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[] = [];
|
const rows: T[] = [];
|
||||||
if (this.selectedIds.size > 0) {
|
if (this.selectedIds.size > 0) {
|
||||||
for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
|
for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
|
||||||
@@ -256,6 +285,58 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
eventArg.preventDefault();
|
eventArg.preventDefault();
|
||||||
this.__writeRowsAsJson(rows);
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -492,20 +573,48 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
? col.renderer(value, itemArg, { rowIndex, colIndex, column: col })
|
? col.renderer(value, itemArg, { rowIndex, colIndex, column: col })
|
||||||
: value;
|
: value;
|
||||||
const editKey = String(col.key);
|
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`
|
return html`
|
||||||
<td
|
<td
|
||||||
|
class=${cellClasses}
|
||||||
|
@click=${(e: MouseEvent) => {
|
||||||
|
if (isEditing) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEditable) {
|
||||||
|
this.__focusedCell = { rowId, colKey: editKey };
|
||||||
|
}
|
||||||
|
}}
|
||||||
@dblclick=${(e: Event) => {
|
@dblclick=${(e: Event) => {
|
||||||
const dblAction = this.dataActions.find((actionArg) =>
|
const dblAction = this.dataActions.find((actionArg) =>
|
||||||
actionArg.type?.includes('doubleClick')
|
actionArg.type?.includes('doubleClick')
|
||||||
);
|
);
|
||||||
if (this.editableFields.includes(editKey)) {
|
if (isEditable) {
|
||||||
this.handleCellEditing(e, itemArg, editKey);
|
e.stopPropagation();
|
||||||
|
this.startEditing(itemArg, col);
|
||||||
} else if (dblAction) {
|
} else if (dblAction) {
|
||||||
dblAction.actionFunc({ item: itemArg, table: this });
|
dblAction.actionFunc({ item: itemArg, table: this });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="innerCellContainer">${content}</div>
|
<div class="innerCellContainer">
|
||||||
|
${isEditing ? this.renderCellEditor(itemArg, col) : content}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
@@ -1524,43 +1633,216 @@ export class DeesTable<T> extends DeesElement {
|
|||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleCellEditing(event: Event, itemArg: T, key: string) {
|
// ─── Cell editing ─────────────────────────────────────────────────────
|
||||||
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;
|
|
||||||
|
|
||||||
const blurInput = async (blurArg = true, saveArg = false) => {
|
/** True if the column has any in-cell editor configured. */
|
||||||
if (blurArg) {
|
private __isColumnEditable(col: Column<T>): boolean {
|
||||||
input.blur();
|
return !!(col.editable || col.editor);
|
||||||
}
|
}
|
||||||
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)
|
/** 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 (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.changeSubject.next(this);
|
||||||
}
|
}
|
||||||
input.remove();
|
this.__editingCell = undefined;
|
||||||
target.style.color = originalColor;
|
|
||||||
this.requestUpdate();
|
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
|
/** Renders the appropriate dees-input-* component for this column. */
|
||||||
target.appendChild(input);
|
private renderCellEditor(item: T, col: Column<T>): TemplateResult {
|
||||||
input.focus();
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,32 +372,32 @@ export const tableStyles: CSSResult[] = [
|
|||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
line-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
td input:focus {
|
/* Editable cell affordances */
|
||||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
td.editable {
|
||||||
outline: 2px solid transparent;
|
cursor: text;
|
||||||
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 */
|
/* filter row */
|
||||||
|
|||||||
@@ -15,6 +15,34 @@ export interface ITableAction<T = any> {
|
|||||||
actionFunc: (actionDataArg: ITableActionDataArg<T>) => Promise<any>;
|
actionFunc: (actionDataArg: ITableActionDataArg<T>) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available cell editor types. Each maps to a dees-input-* component.
|
||||||
|
* Use `editor` on `Column<T>` 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<T = any> {
|
||||||
|
row: T;
|
||||||
|
key: string;
|
||||||
|
oldValue: any;
|
||||||
|
newValue: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detail payload for the `cellEditError` CustomEvent dispatched on validation failure. */
|
||||||
|
export interface ICellEditErrorDetail<T = any> {
|
||||||
|
row: T;
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Column<T = any> {
|
export interface Column<T = any> {
|
||||||
key: keyof T | string;
|
key: keyof T | string;
|
||||||
header?: string | TemplateResult;
|
header?: string | TemplateResult;
|
||||||
@@ -24,6 +52,18 @@ export interface Column<T = any> {
|
|||||||
/** whether this column participates in per-column quick filtering (default: true) */
|
/** whether this column participates in per-column quick filtering (default: true) */
|
||||||
filterable?: boolean;
|
filterable?: boolean;
|
||||||
hidden?: 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<string, any>;
|
||||||
|
/** 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user