@design.estate/dees-catalog 3.63.0 → 3.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,25 @@
1
1
  import * as plugins from '../../00plugins.js';
2
2
  import { demoFunc } from './dees-table.demo.js';
3
- import { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element';
3
+ import { customElement, html, DeesElement, property, state, type TemplateResult, directives } from '@design.estate/dees-element';
4
4
 
5
5
  import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
6
6
  import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
7
7
  import * as domtools from '@design.estate/dees-domtools';
8
8
  import { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.js';
9
9
  import { tableStyles } from './styles.js';
10
- import type { Column, ISortDescriptor, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
10
+ import type {
11
+ Column,
12
+ ISortDescriptor,
13
+ ITableAction,
14
+ ITableActionDataArg,
15
+ TCellEditorType,
16
+ TDisplayFunction,
17
+ } from './types.js';
18
+ import '../../00group-input/dees-input-text/index.js';
19
+ import '../../00group-input/dees-input-checkbox/index.js';
20
+ import '../../00group-input/dees-input-dropdown/index.js';
21
+ import '../../00group-input/dees-input-datepicker/index.js';
22
+ import '../../00group-input/dees-input-tags/index.js';
11
23
  import {
12
24
  computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
13
25
  computeEffectiveColumns as computeEffectiveColumnsFn,
@@ -138,11 +150,6 @@ export class DeesTable<T> extends DeesElement {
138
150
  })
139
151
  accessor selectedDataRow!: T;
140
152
 
141
- @property({
142
- type: Array,
143
- })
144
- accessor editableFields: string[] = [];
145
-
146
153
  @property({
147
154
  type: Boolean,
148
155
  reflect: true,
@@ -184,6 +191,14 @@ export class DeesTable<T> extends DeesElement {
184
191
  accessor columnFilters: Record<string, string> = {};
185
192
  @property({ type: Boolean, attribute: 'show-column-filters' })
186
193
  accessor showColumnFilters: boolean = false;
194
+ /**
195
+ * When true, the table renders a leftmost checkbox column for click-driven
196
+ * (de)selection. Row selection by mouse (plain/shift/ctrl click) is always
197
+ * available regardless of this flag.
198
+ */
199
+ @property({ type: Boolean, reflect: true, attribute: 'show-selection-checkbox' })
200
+ accessor showSelectionCheckbox: boolean = false;
201
+
187
202
  /**
188
203
  * When set, the table renders inside a fixed-height scroll container
189
204
  * (`max-height: var(--table-max-height, 360px)`) and the header sticks
@@ -209,9 +224,146 @@ export class DeesTable<T> extends DeesElement {
209
224
  accessor selectedIds: Set<string> = new Set();
210
225
  private _rowIdMap = new WeakMap<object, string>();
211
226
  private _rowIdCounter = 0;
227
+ /**
228
+ * Anchor row id for shift+click range selection. Set whenever the user
229
+ * makes a non-range click (plain or cmd/ctrl) so the next shift+click
230
+ * can compute a contiguous range from this anchor.
231
+ */
232
+ private __selectionAnchorId?: string;
233
+
234
+ /**
235
+ * Cell currently focused for keyboard navigation. When set, the cell shows
236
+ * a focus ring and Enter/F2 enters edit mode. Independent from row selection.
237
+ */
238
+ @state()
239
+ private accessor __focusedCell: { rowId: string; colKey: string } | undefined = undefined;
240
+
241
+ /**
242
+ * Cell currently being edited. When set, that cell renders an editor
243
+ * (dees-input-*) instead of its display content.
244
+ */
245
+ @state()
246
+ private accessor __editingCell: { rowId: string; colKey: string } | undefined = undefined;
212
247
 
213
248
  constructor() {
214
249
  super();
250
+ // Make the host focusable so it can receive Ctrl/Cmd+C for copy.
251
+ if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
252
+ this.addEventListener('keydown', this.__handleHostKeydown);
253
+ }
254
+
255
+ /**
256
+ * Ctrl/Cmd+C copies the currently selected rows as a JSON array. Falls
257
+ * back to copying the focused-row (`selectedDataRow`) if no multi
258
+ * selection exists. No-op if a focused input/textarea would normally
259
+ * receive the copy.
260
+ */
261
+ private __handleHostKeydown = (eventArg: KeyboardEvent) => {
262
+ // Detect whether the keydown originated inside an editor (input/textarea
263
+ // or contenteditable). Used to skip both copy hijacking and grid nav.
264
+ const path = (eventArg.composedPath?.() || []) as EventTarget[];
265
+ let inEditor = false;
266
+ for (const t of path) {
267
+ const tag = (t as HTMLElement)?.tagName;
268
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || (t as HTMLElement)?.isContentEditable) {
269
+ inEditor = true;
270
+ break;
271
+ }
272
+ }
273
+
274
+ // Ctrl/Cmd+C → copy selected rows as JSON (unless typing in an input).
275
+ const isCopy =
276
+ (eventArg.metaKey || eventArg.ctrlKey) && (eventArg.key === 'c' || eventArg.key === 'C');
277
+ if (isCopy) {
278
+ if (inEditor) return;
279
+ const rows: T[] = [];
280
+ if (this.selectedIds.size > 0) {
281
+ for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
282
+ } else if (this.selectedDataRow) {
283
+ rows.push(this.selectedDataRow);
284
+ }
285
+ if (rows.length === 0) return;
286
+ eventArg.preventDefault();
287
+ this.__writeRowsAsJson(rows);
288
+ return;
289
+ }
290
+
291
+ // Cell navigation only when no editor is open.
292
+ if (inEditor || this.__editingCell) return;
293
+ switch (eventArg.key) {
294
+ case 'ArrowLeft':
295
+ eventArg.preventDefault();
296
+ this.moveFocusedCell(-1, 0, false);
297
+ return;
298
+ case 'ArrowRight':
299
+ eventArg.preventDefault();
300
+ this.moveFocusedCell(+1, 0, false);
301
+ return;
302
+ case 'ArrowUp':
303
+ eventArg.preventDefault();
304
+ this.moveFocusedCell(0, -1, false);
305
+ return;
306
+ case 'ArrowDown':
307
+ eventArg.preventDefault();
308
+ this.moveFocusedCell(0, +1, false);
309
+ return;
310
+ case 'Enter':
311
+ case 'F2': {
312
+ if (!this.__focusedCell) return;
313
+ const view: T[] = (this as any)._lastViewData ?? [];
314
+ const item = view.find((r) => this.getRowId(r) === this.__focusedCell!.rowId);
315
+ if (!item) return;
316
+ const allCols: Column<T>[] =
317
+ Array.isArray(this.columns) && this.columns.length > 0
318
+ ? computeEffectiveColumnsFn(
319
+ this.columns,
320
+ this.augmentFromDisplayFunction,
321
+ this.displayFunction,
322
+ this.data
323
+ )
324
+ : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
325
+ const col = allCols.find((c) => String(c.key) === this.__focusedCell!.colKey);
326
+ if (!col || !this.__isColumnEditable(col)) return;
327
+ eventArg.preventDefault();
328
+ this.startEditing(item, col);
329
+ return;
330
+ }
331
+ case 'Escape':
332
+ if (this.__focusedCell) {
333
+ this.__focusedCell = undefined;
334
+ this.requestUpdate();
335
+ }
336
+ return;
337
+ default:
338
+ return;
339
+ }
340
+ };
341
+
342
+ /**
343
+ * Copies the current selection as a JSON array. If `fallbackRow` is given
344
+ * and there is no multi-selection, that row is copied instead. Used both
345
+ * by the Ctrl/Cmd+C handler and by the default context-menu action.
346
+ */
347
+ public copySelectionAsJson(fallbackRow?: T) {
348
+ const rows: T[] = [];
349
+ if (this.selectedIds.size > 0) {
350
+ for (const r of this.data) if (this.selectedIds.has(this.getRowId(r))) rows.push(r);
351
+ } else if (fallbackRow) {
352
+ rows.push(fallbackRow);
353
+ } else if (this.selectedDataRow) {
354
+ rows.push(this.selectedDataRow);
355
+ }
356
+ if (rows.length === 0) return;
357
+ this.__writeRowsAsJson(rows);
358
+ }
359
+
360
+ private __writeRowsAsJson(rows: T[]) {
361
+ try {
362
+ const json = JSON.stringify(rows, null, 2);
363
+ navigator.clipboard?.writeText(json);
364
+ } catch {
365
+ /* ignore — clipboard may be unavailable */
366
+ }
215
367
  }
216
368
 
217
369
  public static styles = tableStyles;
@@ -319,15 +471,11 @@ export class DeesTable<T> extends DeesElement {
319
471
  };
320
472
  return html`
321
473
  <tr
322
- @click=${() => {
323
- this.selectedDataRow = itemArg;
324
- if (this.selectionMode === 'single') {
325
- const id = this.getRowId(itemArg);
326
- this.selectedIds.clear();
327
- this.selectedIds.add(id);
328
- this.emitSelectionChange();
329
- this.requestUpdate();
330
- }
474
+ @click=${(e: MouseEvent) => this.handleRowClick(e, itemArg, rowIndex, viewData)}
475
+ @mousedown=${(e: MouseEvent) => {
476
+ // Prevent the browser's native shift-click text
477
+ // selection so range-select doesn't highlight text.
478
+ if (e.shiftKey && this.selectionMode !== 'single') e.preventDefault();
331
479
  }}
332
480
  @dragenter=${async (eventArg: DragEvent) => {
333
481
  eventArg.preventDefault();
@@ -362,27 +510,51 @@ export class DeesTable<T> extends DeesElement {
362
510
  }
363
511
  }}
364
512
  @contextmenu=${async (eventArg: MouseEvent) => {
365
- DeesContextmenu.openContextMenuWithOptions(
366
- eventArg,
367
- this.getActionsForType('contextmenu').map((action) => {
368
- const menuItem: plugins.tsclass.website.IMenuItem = {
369
- name: action.name,
370
- iconName: action.iconName as any,
371
- action: async () => {
372
- await action.actionFunc({
373
- item: itemArg,
374
- table: this,
375
- });
376
- return null;
377
- },
378
- };
379
- return menuItem;
380
- })
381
- );
513
+ // If the right-clicked row isn't part of the
514
+ // current selection, treat it like a plain click
515
+ // first so the context menu acts on a sensible
516
+ // selection (matches file-manager behavior).
517
+ if (!this.isRowSelected(itemArg)) {
518
+ this.selectedDataRow = itemArg;
519
+ this.selectedIds.clear();
520
+ this.selectedIds.add(this.getRowId(itemArg));
521
+ this.__selectionAnchorId = this.getRowId(itemArg);
522
+ this.emitSelectionChange();
523
+ this.requestUpdate();
524
+ }
525
+ const userItems: plugins.tsclass.website.IMenuItem[] =
526
+ this.getActionsForType('contextmenu').map((action) => ({
527
+ name: action.name,
528
+ iconName: action.iconName as any,
529
+ action: async () => {
530
+ await action.actionFunc({
531
+ item: itemArg,
532
+ table: this,
533
+ });
534
+ return null;
535
+ },
536
+ }));
537
+ const defaultItems: plugins.tsclass.website.IMenuItem[] = [
538
+ {
539
+ name:
540
+ this.selectedIds.size > 1
541
+ ? `Copy ${this.selectedIds.size} rows as JSON`
542
+ : 'Copy row as JSON',
543
+ iconName: 'lucide:Copy' as any,
544
+ action: async () => {
545
+ this.copySelectionAsJson(itemArg);
546
+ return null;
547
+ },
548
+ },
549
+ ];
550
+ DeesContextmenu.openContextMenuWithOptions(eventArg, [
551
+ ...userItems,
552
+ ...defaultItems,
553
+ ]);
382
554
  }}
383
- class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
555
+ class="${itemArg === this.selectedDataRow || this.isRowSelected(itemArg) ? 'selected' : ''}"
384
556
  >
385
- ${this.selectionMode !== 'none'
557
+ ${this.showSelectionCheckbox
386
558
  ? html`<td style="width:42px; text-align:center;">
387
559
  <dees-input-checkbox
388
560
  .value=${this.isRowSelected(itemArg)}
@@ -401,20 +573,48 @@ export class DeesTable<T> extends DeesElement {
401
573
  ? col.renderer(value, itemArg, { rowIndex, colIndex, column: col })
402
574
  : value;
403
575
  const editKey = String(col.key);
576
+ const isEditable = !!(col.editable || col.editor);
577
+ const rowId = this.getRowId(itemArg);
578
+ const isFocused =
579
+ this.__focusedCell?.rowId === rowId &&
580
+ this.__focusedCell?.colKey === editKey;
581
+ const isEditing =
582
+ this.__editingCell?.rowId === rowId &&
583
+ this.__editingCell?.colKey === editKey;
584
+ const cellClasses = [
585
+ isEditable ? 'editable' : '',
586
+ isFocused && !isEditing ? 'focused' : '',
587
+ isEditing ? 'editingCell' : '',
588
+ ]
589
+ .filter(Boolean)
590
+ .join(' ');
404
591
  return html`
405
592
  <td
593
+ class=${cellClasses}
594
+ @click=${(e: MouseEvent) => {
595
+ if (isEditing) {
596
+ e.stopPropagation();
597
+ return;
598
+ }
599
+ if (isEditable) {
600
+ this.__focusedCell = { rowId, colKey: editKey };
601
+ }
602
+ }}
406
603
  @dblclick=${(e: Event) => {
407
604
  const dblAction = this.dataActions.find((actionArg) =>
408
605
  actionArg.type?.includes('doubleClick')
409
606
  );
410
- if (this.editableFields.includes(editKey)) {
411
- this.handleCellEditing(e, itemArg, editKey);
607
+ if (isEditable) {
608
+ e.stopPropagation();
609
+ this.startEditing(itemArg, col);
412
610
  } else if (dblAction) {
413
611
  dblAction.actionFunc({ item: itemArg, table: this });
414
612
  }
415
613
  }}
416
614
  >
417
- <div class="innerCellContainer">${content}</div>
615
+ <div class="innerCellContainer">
616
+ ${isEditing ? this.renderCellEditor(itemArg, col) : content}
617
+ </div>
418
618
  </td>
419
619
  `;
420
620
  })}
@@ -502,7 +702,7 @@ export class DeesTable<T> extends DeesElement {
502
702
  private renderHeaderRows(effectiveColumns: Column<T>[]): TemplateResult {
503
703
  return html`
504
704
  <tr>
505
- ${this.selectionMode !== 'none'
705
+ ${this.showSelectionCheckbox
506
706
  ? html`
507
707
  <th style="width:42px; text-align:center;">
508
708
  ${this.selectionMode === 'multi'
@@ -547,7 +747,7 @@ export class DeesTable<T> extends DeesElement {
547
747
  </tr>
548
748
  ${this.showColumnFilters
549
749
  ? html`<tr class="filtersRow">
550
- ${this.selectionMode !== 'none'
750
+ ${this.showSelectionCheckbox
551
751
  ? html`<th style="width:42px;"></th>`
552
752
  : html``}
553
753
  ${effectiveColumns
@@ -1302,6 +1502,74 @@ export class DeesTable<T> extends DeesElement {
1302
1502
  this.requestUpdate();
1303
1503
  }
1304
1504
 
1505
+ /**
1506
+ * Handles row clicks with file-manager style selection semantics:
1507
+ * - plain click: select only this row, set anchor
1508
+ * - cmd/ctrl+click: toggle this row in/out, set anchor
1509
+ * - shift+click: select the contiguous range from the anchor to this row
1510
+ *
1511
+ * Multi-row click selection is always available (`selectionMode === 'none'`
1512
+ * and `'multi'` both behave this way) so consumers can always copy a set
1513
+ * of rows. Only `selectionMode === 'single'` restricts to one row.
1514
+ */
1515
+ private handleRowClick(eventArg: MouseEvent, item: T, rowIndex: number, view: T[]) {
1516
+ const id = this.getRowId(item);
1517
+
1518
+ if (this.selectionMode === 'single') {
1519
+ this.selectedDataRow = item;
1520
+ this.selectedIds.clear();
1521
+ this.selectedIds.add(id);
1522
+ this.__selectionAnchorId = id;
1523
+ this.emitSelectionChange();
1524
+ this.requestUpdate();
1525
+ return;
1526
+ }
1527
+
1528
+ // multi
1529
+ const isToggle = eventArg.metaKey || eventArg.ctrlKey;
1530
+ const isRange = eventArg.shiftKey;
1531
+
1532
+ if (isRange && this.__selectionAnchorId !== undefined) {
1533
+ // Clear any text selection the browser may have created.
1534
+ window.getSelection?.()?.removeAllRanges();
1535
+ const anchorIdx = view.findIndex((r) => this.getRowId(r) === this.__selectionAnchorId);
1536
+ if (anchorIdx >= 0) {
1537
+ const [a, b] = anchorIdx <= rowIndex ? [anchorIdx, rowIndex] : [rowIndex, anchorIdx];
1538
+ this.selectedIds.clear();
1539
+ for (let i = a; i <= b; i++) this.selectedIds.add(this.getRowId(view[i]));
1540
+ } else {
1541
+ // Anchor no longer in view (filter changed, etc.) — fall back to single select.
1542
+ this.selectedIds.clear();
1543
+ this.selectedIds.add(id);
1544
+ this.__selectionAnchorId = id;
1545
+ }
1546
+ this.selectedDataRow = item;
1547
+ } else if (isToggle) {
1548
+ const wasSelected = this.selectedIds.has(id);
1549
+ if (wasSelected) {
1550
+ this.selectedIds.delete(id);
1551
+ // If we just deselected the focused row, move focus to another
1552
+ // selected row (or clear it) so the highlight goes away.
1553
+ if (this.selectedDataRow === item) {
1554
+ const remaining = view.find((r) => this.selectedIds.has(this.getRowId(r)));
1555
+ this.selectedDataRow = remaining as T;
1556
+ }
1557
+ } else {
1558
+ this.selectedIds.add(id);
1559
+ this.selectedDataRow = item;
1560
+ }
1561
+ this.__selectionAnchorId = id;
1562
+ } else {
1563
+ this.selectedDataRow = item;
1564
+ this.selectedIds.clear();
1565
+ this.selectedIds.add(id);
1566
+ this.__selectionAnchorId = id;
1567
+ }
1568
+
1569
+ this.emitSelectionChange();
1570
+ this.requestUpdate();
1571
+ }
1572
+
1305
1573
  private setRowSelected(row: T, checked: boolean) {
1306
1574
  const id = this.getRowId(row);
1307
1575
  if (this.selectionMode === 'single') {
@@ -1365,43 +1633,216 @@ export class DeesTable<T> extends DeesElement {
1365
1633
  return actions;
1366
1634
  }
1367
1635
 
1368
- async handleCellEditing(event: Event, itemArg: T, key: string) {
1369
- await this.domtoolsPromise;
1370
- const target = event.target as HTMLElement;
1371
- const originalColor = target.style.color;
1372
- target.style.color = 'transparent';
1373
- const transformedItem = this.displayFunction(itemArg);
1374
- const initialValue = ((transformedItem as any)[key] ?? (itemArg as any)[key] ?? '') as string;
1375
- // Create an input element
1376
- const input = document.createElement('input');
1377
- input.type = 'text';
1378
- input.value = initialValue;
1379
-
1380
- const blurInput = async (blurArg = true, saveArg = false) => {
1381
- if (blurArg) {
1382
- input.blur();
1636
+ // ─── Cell editing ─────────────────────────────────────────────────────
1637
+
1638
+ /** True if the column has any in-cell editor configured. */
1639
+ private __isColumnEditable(col: Column<T>): boolean {
1640
+ return !!(col.editable || col.editor);
1641
+ }
1642
+
1643
+ /** Effective columns filtered to those that can be edited (visible only). */
1644
+ private __editableColumns(effectiveColumns: Column<T>[]): Column<T>[] {
1645
+ return effectiveColumns.filter((c) => !c.hidden && this.__isColumnEditable(c));
1646
+ }
1647
+
1648
+ /**
1649
+ * Opens the editor on the given cell. Sets focus + editing state and
1650
+ * focuses the freshly rendered editor on the next frame.
1651
+ */
1652
+ public startEditing(item: T, col: Column<T>) {
1653
+ if (!this.__isColumnEditable(col)) return;
1654
+ const rowId = this.getRowId(item);
1655
+ const colKey = String(col.key);
1656
+ this.__focusedCell = { rowId, colKey };
1657
+ this.__editingCell = { rowId, colKey };
1658
+ this.requestUpdate();
1659
+ this.updateComplete.then(() => {
1660
+ const el = this.shadowRoot?.querySelector(
1661
+ '.editingCell dees-input-text, .editingCell dees-input-checkbox, ' +
1662
+ '.editingCell dees-input-dropdown, .editingCell dees-input-datepicker, ' +
1663
+ '.editingCell dees-input-tags'
1664
+ ) as any;
1665
+ el?.focus?.();
1666
+ });
1667
+ }
1668
+
1669
+ /** Closes the editor without committing. */
1670
+ public cancelCellEdit() {
1671
+ this.__editingCell = undefined;
1672
+ this.requestUpdate();
1673
+ }
1674
+
1675
+ /**
1676
+ * Commits an editor value to the row. Runs `parse` then `validate`. On
1677
+ * validation failure, fires `cellEditError` and leaves the editor open.
1678
+ * On success, mutates `data` in place, fires `cellEdit`, and closes the
1679
+ * editor.
1680
+ */
1681
+ public commitCellEdit(item: T, col: Column<T>, editorValue: any) {
1682
+ const key = String(col.key);
1683
+ const oldValue = (item as any)[col.key];
1684
+ const parsed = col.parse ? col.parse(editorValue, item) : editorValue;
1685
+ if (col.validate) {
1686
+ const result = col.validate(parsed, item);
1687
+ if (typeof result === 'string') {
1688
+ this.dispatchEvent(
1689
+ new CustomEvent('cellEditError', {
1690
+ detail: { row: item, key, value: parsed, message: result },
1691
+ bubbles: true,
1692
+ composed: true,
1693
+ })
1694
+ );
1695
+ return;
1383
1696
  }
1384
- if (saveArg) {
1385
- (itemArg as any)[key] = input.value as any; // Convert string to T (you might need better type casting depending on your data structure)
1386
- this.changeSubject.next(this);
1697
+ }
1698
+ if (parsed !== oldValue) {
1699
+ (item as any)[col.key] = parsed;
1700
+ this.dispatchEvent(
1701
+ new CustomEvent('cellEdit', {
1702
+ detail: { row: item, key, oldValue, newValue: parsed },
1703
+ bubbles: true,
1704
+ composed: true,
1705
+ })
1706
+ );
1707
+ this.changeSubject.next(this);
1708
+ }
1709
+ this.__editingCell = undefined;
1710
+ this.requestUpdate();
1711
+ }
1712
+
1713
+ /** Renders the appropriate dees-input-* component for this column. */
1714
+ private renderCellEditor(item: T, col: Column<T>): TemplateResult {
1715
+ const raw = (item as any)[col.key];
1716
+ const value = col.format ? col.format(raw, item) : raw;
1717
+ const editorType: TCellEditorType = col.editor ?? 'text';
1718
+ const onTextCommit = (target: any) => this.commitCellEdit(item, col, target.value);
1719
+
1720
+ switch (editorType) {
1721
+ case 'checkbox':
1722
+ return html`<dees-input-checkbox
1723
+ .value=${!!value}
1724
+ @newValue=${(e: CustomEvent<boolean>) => {
1725
+ e.stopPropagation();
1726
+ this.commitCellEdit(item, col, e.detail);
1727
+ }}
1728
+ ></dees-input-checkbox>`;
1729
+
1730
+ case 'dropdown': {
1731
+ const options = (col.editorOptions?.options as any[]) ?? [];
1732
+ const selected =
1733
+ options.find((o: any) => (o?.option ?? o?.key ?? o) === value) ?? null;
1734
+ return html`<dees-input-dropdown
1735
+ .options=${options}
1736
+ .selectedOption=${selected}
1737
+ @selectedOption=${(e: CustomEvent<any>) => {
1738
+ e.stopPropagation();
1739
+ const detail = e.detail;
1740
+ const newRaw = detail?.option ?? detail?.key ?? detail;
1741
+ this.commitCellEdit(item, col, newRaw);
1742
+ }}
1743
+ ></dees-input-dropdown>`;
1387
1744
  }
1388
- input.remove();
1389
- target.style.color = originalColor;
1390
- this.requestUpdate();
1391
- };
1392
1745
 
1393
- // When the input loses focus or the Enter key is pressed, update the data
1394
- input.addEventListener('blur', () => {
1395
- blurInput(false, false);
1396
- });
1397
- input.addEventListener('keydown', (e: KeyboardEvent) => {
1398
- if (e.key === 'Enter') {
1399
- blurInput(true, true); // This will trigger the blur event handler above
1746
+ case 'date':
1747
+ return html`<dees-input-datepicker
1748
+ .value=${value}
1749
+ @focusout=${(e: any) => onTextCommit(e.target)}
1750
+ @keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
1751
+ ></dees-input-datepicker>`;
1752
+
1753
+ case 'tags':
1754
+ return html`<dees-input-tags
1755
+ .value=${(value as any) ?? []}
1756
+ @focusout=${(e: any) => onTextCommit(e.target)}
1757
+ @keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
1758
+ ></dees-input-tags>`;
1759
+
1760
+ case 'number':
1761
+ case 'text':
1762
+ default:
1763
+ return html`<dees-input-text
1764
+ .value=${value == null ? '' : String(value)}
1765
+ @focusout=${(e: any) => onTextCommit(e.target)}
1766
+ @keydown=${(e: KeyboardEvent) => this.__handleEditorKey(e, item, col)}
1767
+ ></dees-input-text>`;
1768
+ }
1769
+ }
1770
+
1771
+ /**
1772
+ * Centralized keydown handler for text-style editors. Handles Esc (cancel),
1773
+ * Enter (commit + move down) and Tab/Shift+Tab (commit + move horizontally).
1774
+ */
1775
+ private __handleEditorKey(eventArg: KeyboardEvent, item: T, col: Column<T>) {
1776
+ if (eventArg.key === 'Escape') {
1777
+ eventArg.preventDefault();
1778
+ eventArg.stopPropagation();
1779
+ this.cancelCellEdit();
1780
+ // Restore focus to the host so arrow-key navigation can resume.
1781
+ this.focus();
1782
+ } else if (eventArg.key === 'Enter') {
1783
+ eventArg.preventDefault();
1784
+ eventArg.stopPropagation();
1785
+ const target = eventArg.target as any;
1786
+ this.commitCellEdit(item, col, target.value);
1787
+ this.moveFocusedCell(0, +1, true);
1788
+ } else if (eventArg.key === 'Tab') {
1789
+ eventArg.preventDefault();
1790
+ eventArg.stopPropagation();
1791
+ const target = eventArg.target as any;
1792
+ this.commitCellEdit(item, col, target.value);
1793
+ this.moveFocusedCell(eventArg.shiftKey ? -1 : +1, 0, true);
1794
+ }
1795
+ }
1796
+
1797
+ /**
1798
+ * Moves the focused cell by `dx` columns and `dy` rows along the editable
1799
+ * grid. Wraps row-end → next row when moving horizontally. If
1800
+ * `andStartEditing` is true, opens the editor on the new cell.
1801
+ */
1802
+ public moveFocusedCell(dx: number, dy: number, andStartEditing: boolean) {
1803
+ const view: T[] = (this as any)._lastViewData ?? [];
1804
+ if (view.length === 0) return;
1805
+ // Recompute editable columns from the latest effective set.
1806
+ const allCols: Column<T>[] = Array.isArray(this.columns) && this.columns.length > 0
1807
+ ? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
1808
+ : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
1809
+ const editableCols = this.__editableColumns(allCols);
1810
+ if (editableCols.length === 0) return;
1811
+
1812
+ let rowIdx = 0;
1813
+ let colIdx = 0;
1814
+ if (this.__focusedCell) {
1815
+ rowIdx = view.findIndex((r) => this.getRowId(r) === this.__focusedCell!.rowId);
1816
+ colIdx = editableCols.findIndex((c) => String(c.key) === this.__focusedCell!.colKey);
1817
+ if (rowIdx < 0) rowIdx = 0;
1818
+ if (colIdx < 0) colIdx = 0;
1819
+ }
1820
+
1821
+ if (dx !== 0) {
1822
+ colIdx += dx;
1823
+ while (colIdx >= editableCols.length) {
1824
+ colIdx -= editableCols.length;
1825
+ rowIdx += 1;
1400
1826
  }
1401
- });
1827
+ while (colIdx < 0) {
1828
+ colIdx += editableCols.length;
1829
+ rowIdx -= 1;
1830
+ }
1831
+ }
1832
+ if (dy !== 0) rowIdx += dy;
1402
1833
 
1403
- // Replace the cell's content with the input
1404
- target.appendChild(input);
1405
- input.focus();
1834
+ // Clamp to grid bounds.
1835
+ if (rowIdx < 0 || rowIdx >= view.length) {
1836
+ this.cancelCellEdit();
1837
+ return;
1838
+ }
1839
+ const item = view[rowIdx];
1840
+ const col = editableCols[colIdx];
1841
+ this.__focusedCell = { rowId: this.getRowId(item), colKey: String(col.key) };
1842
+ if (andStartEditing) {
1843
+ this.startEditing(item, col);
1844
+ } else {
1845
+ this.requestUpdate();
1846
+ }
1406
1847
  }
1407
1848
  }