Compare commits

...

4 Commits

Author SHA1 Message Date
a1e808345e v3.65.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-07 15:32:10 +00:00
efea2d62d9 feat(dees-table): add schema-based in-cell editing with keyboard navigation and cell edit events 2026-04-07 15:32:10 +00:00
2f95979cc6 v3.64.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-07 14:34:19 +00:00
b3f098b41e feat(dees-table): add file-manager style row selection and JSON copy support 2026-04-07 14:34:19 +00:00
7 changed files with 637 additions and 109 deletions

View File

@@ -1,5 +1,21 @@
# 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
- adds optional selection checkbox rendering via the show-selection-checkbox property
- supports plain, ctrl/cmd, and shift-click row selection with range selection behavior
- adds Ctrl/Cmd+C and context menu actions to copy selected rows as formatted JSON
- updates row selection styling to prevent native text selection during range selection
## 2026-04-07 - 3.63.0 - feat(dees-table)
add floating header support with fixed-height table mode

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-catalog",
"version": "3.63.0",
"version": "3.65.0",
"private": false,
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"main": "dist_ts_web/index.js",

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-catalog',
version: '3.63.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.'
}

View File

@@ -55,36 +55,66 @@ export const demoFunc = () => html`
<div class="demo-container">
<div class="demo-section">
<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
heading1="Current Account Statement"
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=${[
{
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`
<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>
<style>
dees-table[sticky-header] { --table-max-height: 220px; }
dees-table[fixed-height] { --table-max-height: 220px; }
</style>
<dees-table
heading1="Employees"
heading2="Quick filter per column + sticky header"
.showColumnFilters=${true}
.stickyHeader=${true}
.fixedHeight=${true}
.columns=${[
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'Email', sortable: true },
@@ -669,7 +699,7 @@ export const demoFunc = () => html`
</style>
<dees-table
id="scrollSmallHeight"
.stickyHeader=${true}
.fixedHeight=${true}
heading1="People Directory (Scrollable)"
heading2="Forced scrolling with many items"
.columns=${[

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,
@@ -184,6 +191,14 @@ export class DeesTable<T> extends DeesElement {
accessor columnFilters: Record<string, string> = {};
@property({ type: Boolean, attribute: 'show-column-filters' })
accessor showColumnFilters: boolean = false;
/**
* When true, the table renders a leftmost checkbox column for click-driven
* (de)selection. Row selection by mouse (plain/shift/ctrl click) is always
* available regardless of this flag.
*/
@property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
accessor showSelectionCheckbox: boolean = false;
/**
* When set, the table renders inside a fixed-height scroll container
* (`max-height: var(--table-max-height, 360px)`) and the header sticks
@@ -209,9 +224,146 @@ export class DeesTable<T> extends DeesElement {
accessor selectedIds: Set<string> = new Set();
private _rowIdMap = new WeakMap<object, string>();
private _rowIdCounter = 0;
/**
* Anchor row id for shift+click range selection. Set whenever the user
* makes a non-range click (plain or cmd/ctrl) so the next shift+click
* can compute a contiguous range from this anchor.
*/
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.
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
this.addEventListener('keydown', this.__handleHostKeydown);
}
/**
* Ctrl/Cmd+C copies the currently selected rows as a JSON array. Falls
* back to copying the focused-row (`selectedDataRow`) if no multi
* selection exists. No-op if a focused input/textarea would normally
* receive the copy.
*/
private __handleHostKeydown = (eventArg: KeyboardEvent) => {
// 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' || (t as HTMLElement)?.isContentEditable) {
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[] = [];
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;
}
};
/**
* Copies the current selection as a JSON array. If `fallbackRow` is given
* and there is no multi-selection, that row is copied instead. Used both
* by the Ctrl/Cmd+C handler and by the default context-menu action.
*/
public copySelectionAsJson(fallbackRow?: T) {
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 (fallbackRow) {
rows.push(fallbackRow);
} else if (this.selectedDataRow) {
rows.push(this.selectedDataRow);
}
if (rows.length === 0) return;
this.__writeRowsAsJson(rows);
}
private __writeRowsAsJson(rows: T[]) {
try {
const json = JSON.stringify(rows, null, 2);
navigator.clipboard?.writeText(json);
} catch {
/* ignore — clipboard may be unavailable */
}
}
public static styles = tableStyles;
@@ -319,15 +471,11 @@ export class DeesTable<T> extends DeesElement {
};
return html`
<tr
@click=${() => {
this.selectedDataRow = itemArg;
if (this.selectionMode === 'single') {
const id = this.getRowId(itemArg);
this.selectedIds.clear();
this.selectedIds.add(id);
this.emitSelectionChange();
this.requestUpdate();
}
@click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
@mousedown=${(e: MouseEvent) => {
// Prevent the browser's native shift-click text
// selection so range-select doesn't highlight text.
if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
}}
@dragenter=${async (eventArg: DragEvent) => {
eventArg.preventDefault();
@@ -362,27 +510,51 @@ export class DeesTable<T> extends DeesElement {
}
}}
@contextmenu=${async (eventArg: MouseEvent) => {
DeesContextmenu.openContextMenuWithOptions(
eventArg,
this.getActionsForType('contextmenu').map((action) => {
const menuItem: plugins.tsclass.website.IMenuItem = {
name: action.name,
iconName: action.iconName as any,
action: async () => {
await action.actionFunc({
item: itemArg,
table: this,
});
return null;
},
};
return menuItem;
})
);
// If the right-clicked row isn't part of the
// current selection, treat it like a plain click
// first so the context menu acts on a sensible
// selection (matches file-manager behavior).
if (!this.isRowSelected(itemArg)) {
this.selectedDataRow = itemArg;
this.selectedIds.clear();
this.selectedIds.add(this.getRowId(itemArg));
this.__selectionAnchorId = this.getRowId(itemArg);
this.emitSelectionChange();
this.requestUpdate();
}
const userItems: plugins.tsclass.website.IMenuItem[] =
this.getActionsForType('contextmenu').map((action) => ({
name: action.name,
iconName: action.iconName as any,
action: async () => {
await action.actionFunc({
item: itemArg,
table: this,
});
return null;
},
}));
const defaultItems: plugins.tsclass.website.IMenuItem[] = [
{
name:
this.selectedIds.size > 1
? `Copy ${this.selectedIds.size} rows as JSON`
: 'Copy row as JSON',
iconName: 'lucide:Copy' as any,
action: async () => {
this.copySelectionAsJson(itemArg);
return null;
},
},
];
DeesContextmenu.openContextMenuWithOptions(eventArg, [
...userItems,
...defaultItems,
]);
}}
class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
>
${this.selectionMode !== 'none'
${this.showSelectionCheckbox
? html`<td style="width:42px; text-align:center;">
<dees-input-checkbox
.value=${this.isRowSelected(itemArg)}
@@ -401,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>
`;
})}
@@ -502,7 +702,7 @@ export class DeesTable<T> extends DeesElement {
private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult {
return html`
<tr>
${this.selectionMode !== 'none'
${this.showSelectionCheckbox
? html`
<th style="width:42px; text-align:center;">
${this.selectionMode === 'multi'
@@ -547,7 +747,7 @@ export class DeesTable<T> extends DeesElement {
</tr>
${this.showColumnFilters
? html`<tr class="filtersRow">
${this.selectionMode !== 'none'
${this.showSelectionCheckbox
? html`<th style="width:42px;"></th>`
: html``}
${effectiveColumns
@@ -1302,6 +1502,74 @@ export class DeesTable<T> extends DeesElement {
this.requestUpdate();
}
/**
* Handles row clicks with file-manager style selection semantics:
* - plain click: select only this row, set anchor
* - cmd/ctrl+click: toggle this row in/out, set anchor
* - shift+click: select the contiguous range from the anchor to this row
*
* Multi-row click selection is always available (`selectionMode === 'none'`
* and `'multi'` both behave this way) so consumers can always copy a set
* of rows. Only `selectionMode === 'single'` restricts to one row.
*/
private handleRowClick(eventArg: MouseEvent, item: T, rowIndex: number, view: T[]) {
const id = this.getRowId(item);
if (this.selectionMode === 'single') {
this.selectedDataRow = item;
this.selectedIds.clear();
this.selectedIds.add(id);
this.__selectionAnchorId = id;
this.emitSelectionChange();
this.requestUpdate();
return;
}
// multi
const isToggle = eventArg.metaKey || eventArg.ctrlKey;
const isRange = eventArg.shiftKey;
if (isRange && this.__selectionAnchorId !== undefined) {
// Clear any text selection the browser may have created.
window.getSelection?.()?.removeAllRanges();
const anchorIdx = view.findIndex((r) => this.getRowId(r) === this.__selectionAnchorId);
if (anchorIdx >= 0) {
const [a, b] = anchorIdx <= rowIndex ? [anchorIdx, rowIndex] : [rowIndex, anchorIdx];
this.selectedIds.clear();
for (let i = a; i <= b; i++) this.selectedIds.add(this.getRowId(view[i]));
} else {
// Anchor no longer in view (filter changed, etc.) — fall back to single select.
this.selectedIds.clear();
this.selectedIds.add(id);
this.__selectionAnchorId = id;
}
this.selectedDataRow = item;
} else if (isToggle) {
const wasSelected = this.selectedIds.has(id);
if (wasSelected) {
this.selectedIds.delete(id);
// If we just deselected the focused row, move focus to another
// selected row (or clear it) so the highlight goes away.
if (this.selectedDataRow === item) {
const remaining = view.find((r) => this.selectedIds.has(this.getRowId(r)));
this.selectedDataRow = remaining as T;
}
} else {
this.selectedIds.add(id);
this.selectedDataRow = item;
}
this.__selectionAnchorId = id;
} else {
this.selectedDataRow = item;
this.selectedIds.clear();
this.selectedIds.add(id);
this.__selectionAnchorId = id;
}
this.emitSelectionChange();
this.requestUpdate();
}
private setRowSelected(row: T, checked: boolean) {
const id = this.getRowId(row);
if (this.selectionMode === 'single') {
@@ -1365,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();
}
}
}

View File

@@ -196,6 +196,7 @@ export const tableStyles: CSSResult[] = [
tbody tr {
transition: background-color 0.15s ease;
position: relative;
user-select: none;
}
/* Default horizontal lines (bottom border only) */
@@ -371,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 */

View File

@@ -15,6 +15,34 @@ export interface ITableAction<T = 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> {
key: keyof T | string;
header?: string | TemplateResult;
@@ -24,6 +52,18 @@ export interface Column<T = any> {
/** 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<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;
}
/**