@dxos/react-ui-table 0.8.4-main.2c6827d → 0.8.4-main.3c1ae3b

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.
Files changed (103) hide show
  1. package/dist/lib/browser/chunk-CJQ6T4LL.mjs +77 -0
  2. package/dist/lib/browser/chunk-CJQ6T4LL.mjs.map +7 -0
  3. package/dist/lib/browser/{chunk-GS2UNGJK.mjs → chunk-V7A7ZA5E.mjs} +119 -111
  4. package/dist/lib/browser/chunk-V7A7ZA5E.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +162 -147
  6. package/dist/lib/browser/index.mjs.map +3 -3
  7. package/dist/lib/browser/meta.json +1 -1
  8. package/dist/lib/browser/testing/index.mjs +15 -31
  9. package/dist/lib/browser/testing/index.mjs.map +3 -3
  10. package/dist/lib/browser/types/index.mjs +3 -3
  11. package/dist/lib/node-esm/{chunk-TRR3C5NX.mjs → chunk-P73ALX3N.mjs} +119 -111
  12. package/dist/lib/node-esm/chunk-P73ALX3N.mjs.map +7 -0
  13. package/dist/lib/node-esm/chunk-T6PXZQ6W.mjs +78 -0
  14. package/dist/lib/node-esm/chunk-T6PXZQ6W.mjs.map +7 -0
  15. package/dist/lib/node-esm/index.mjs +162 -147
  16. package/dist/lib/node-esm/index.mjs.map +3 -3
  17. package/dist/lib/node-esm/meta.json +1 -1
  18. package/dist/lib/node-esm/testing/index.mjs +15 -31
  19. package/dist/lib/node-esm/testing/index.mjs.map +3 -3
  20. package/dist/lib/node-esm/types/index.mjs +3 -3
  21. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts +1 -2
  22. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts.map +1 -1
  23. package/dist/types/src/components/Table/ColumnSettings.d.ts +2 -2
  24. package/dist/types/src/components/Table/ColumnSettings.d.ts.map +1 -1
  25. package/dist/types/src/components/Table/DynamicTable.d.ts +4 -5
  26. package/dist/types/src/components/Table/DynamicTable.d.ts.map +1 -1
  27. package/dist/types/src/components/Table/DynamicTable.stories.d.ts +3 -17
  28. package/dist/types/src/components/Table/DynamicTable.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/Table/Relations.stories.d.ts +10 -6
  30. package/dist/types/src/components/Table/Relations.stories.d.ts.map +1 -1
  31. package/dist/types/src/components/Table/Table.d.ts +8 -8
  32. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  33. package/dist/types/src/components/Table/Table.stories.d.ts +70 -4
  34. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  35. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts +1 -1
  36. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts +10 -11
  37. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts.map +1 -1
  38. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts +5 -7
  39. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts.map +1 -1
  40. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts +3 -3
  41. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts.map +1 -1
  42. package/dist/types/src/components/testing.d.ts +0 -2
  43. package/dist/types/src/components/testing.d.ts.map +1 -1
  44. package/dist/types/src/hooks/index.d.ts +2 -1
  45. package/dist/types/src/hooks/index.d.ts.map +1 -1
  46. package/dist/types/src/hooks/useProjectionModel.d.ts +5 -0
  47. package/dist/types/src/hooks/useProjectionModel.d.ts.map +1 -0
  48. package/dist/types/src/hooks/useTableModel.d.ts +4 -5
  49. package/dist/types/src/hooks/useTableModel.d.ts.map +1 -1
  50. package/dist/types/src/model/table-model.d.ts +6 -8
  51. package/dist/types/src/model/table-model.d.ts.map +1 -1
  52. package/dist/types/src/model/table-sorting.d.ts +7 -3
  53. package/dist/types/src/model/table-sorting.d.ts.map +1 -1
  54. package/dist/types/src/playwright/TableManager.d.ts +1 -1
  55. package/dist/types/src/playwright/TableManager.d.ts.map +1 -1
  56. package/dist/types/src/testing/useTestTableModel.d.ts +19 -15
  57. package/dist/types/src/testing/useTestTableModel.d.ts.map +1 -1
  58. package/dist/types/src/translations.d.ts +2 -2
  59. package/dist/types/src/types/Table.d.ts +28 -0
  60. package/dist/types/src/types/Table.d.ts.map +1 -0
  61. package/dist/types/src/types/index.d.ts +1 -1
  62. package/dist/types/src/types/index.d.ts.map +1 -1
  63. package/dist/types/src/util/dynamic-table.d.ts +9 -9
  64. package/dist/types/src/util/dynamic-table.d.ts.map +1 -1
  65. package/dist/types/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +38 -38
  67. package/src/components/Table/ColumnActionsMenu.tsx +4 -1
  68. package/src/components/Table/ColumnSettings.tsx +2 -3
  69. package/src/components/Table/DynamicTable.stories.tsx +7 -9
  70. package/src/components/Table/DynamicTable.tsx +18 -10
  71. package/src/components/Table/Relations.stories.tsx +9 -18
  72. package/src/components/Table/Table.stories.tsx +52 -59
  73. package/src/components/Table/Table.tsx +317 -304
  74. package/src/components/TableCellEditor/CellValidationMessage.tsx +1 -1
  75. package/src/components/TableCellEditor/FormCellEditor.tsx +101 -108
  76. package/src/components/TableCellEditor/TableCellEditor.stories.tsx +11 -8
  77. package/src/components/TableCellEditor/TableCellEditor.tsx +29 -29
  78. package/src/components/TableToolbar/TableToolbar.tsx +1 -1
  79. package/src/components/testing.ts +3 -3
  80. package/src/hooks/index.ts +2 -1
  81. package/src/hooks/useProjectionModel.ts +19 -0
  82. package/src/hooks/useTableModel.ts +11 -13
  83. package/src/model/table-model.test.ts +4 -4
  84. package/src/model/table-model.ts +29 -38
  85. package/src/model/table-presentation.test.ts +4 -4
  86. package/src/model/table-presentation.ts +9 -9
  87. package/src/model/table-sorting.ts +21 -12
  88. package/src/playwright/TableManager.ts +5 -5
  89. package/src/playwright/smoke.spec.ts +1 -1
  90. package/src/testing/useTestTableModel.ts +31 -27
  91. package/src/translations.ts +2 -2
  92. package/src/types/Table.ts +90 -0
  93. package/src/types/index.ts +1 -1
  94. package/src/util/dynamic-table.ts +11 -16
  95. package/dist/lib/browser/chunk-2PFAPQL4.mjs +0 -66
  96. package/dist/lib/browser/chunk-2PFAPQL4.mjs.map +0 -7
  97. package/dist/lib/browser/chunk-GS2UNGJK.mjs.map +0 -7
  98. package/dist/lib/node-esm/chunk-6POOFFYF.mjs +0 -67
  99. package/dist/lib/node-esm/chunk-6POOFFYF.mjs.map +0 -7
  100. package/dist/lib/node-esm/chunk-TRR3C5NX.mjs.map +0 -7
  101. package/dist/types/src/types/schema.d.ts +0 -28
  102. package/dist/types/src/types/schema.d.ts.map +0 -1
  103. package/src/types/schema.ts +0 -73
@@ -2,11 +2,12 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import type * as Schema from 'effect/Schema';
6
5
  import * as String from 'effect/String';
7
6
  import React, {
7
+ type JSX,
8
8
  type MouseEvent,
9
9
  type PropsWithChildren,
10
+ type Ref,
10
11
  type WheelEvent,
11
12
  forwardRef,
12
13
  useCallback,
@@ -16,8 +17,7 @@ import React, {
16
17
  useState,
17
18
  } from 'react';
18
19
 
19
- // TODO(wittjosiah): Remove dependency on react-client.
20
- import { type Client } from '@dxos/react-client';
20
+ import { type Type } from '@dxos/echo';
21
21
  import { useAttention } from '@dxos/react-ui-attention';
22
22
  import {
23
23
  type DxGridElement,
@@ -77,11 +77,10 @@ export type TableController = {
77
77
  handleInsertRowResult?: (insertRowResult?: InsertRowResult) => void;
78
78
  };
79
79
 
80
- export type TableMainProps = {
80
+ export type TableMainProps<T extends Type.Entity.Any = Type.Entity.Any> = {
81
+ schema?: T;
81
82
  model?: TableModel;
82
83
  presentation?: TablePresentation;
83
- schema?: Schema.Schema.AnyNoContext;
84
- client?: Client;
85
84
  // TODO(burdon): Rename since attention isn't a useful concept here? Standardize across other components. Pass property into useAttention.
86
85
  ignoreAttention?: boolean;
87
86
  onCreate?: OnCreateHandler;
@@ -89,350 +88,364 @@ export type TableMainProps = {
89
88
  testId?: string;
90
89
  };
91
90
 
92
- const TableMain = forwardRef<TableController, TableMainProps>(
93
- ({ model, presentation, ignoreAttention, schema, client, onCreate, onRowClick, testId }, forwardedRef) => {
94
- const [dxGrid, setDxGrid] = useState<DxGridElement | null>(null);
95
- const { hasAttention } = useAttention(model?.id ?? 'table');
96
- const modals = useMemo(() => new ModalController(), []);
97
-
98
- const draftRowCount = model?.getDraftRowCount() ?? 0;
99
-
100
- const handleSave = useCallback(() => {
101
- dxGrid?.updateCells(true);
102
- dxGrid?.requestUpdate();
103
- }, [dxGrid]);
104
-
105
- const frozen = useMemo(() => {
106
- const noActionColumn =
107
- model?.features.dataEditable === false &&
108
- model?.features.schemaEditable === false &&
109
- model.rowActions.length === 0;
110
-
111
- return {
112
- frozenRowsStart: 1,
113
- frozenRowsEnd: Math.max(1, draftRowCount),
114
- frozenColsStart: model?.features.selection.enabled ? 1 : 0,
115
- frozenColsEnd: noActionColumn ? 0 : 1,
116
- };
117
- }, [model, draftRowCount]);
118
-
119
- const getCells = useCallback<NonNullable<GridContentProps['getCells']>>(
120
- (range: DxGridPlaneRange, plane: DxGridPlane) => presentation?.getCells(range, plane) ?? {},
121
- [presentation],
122
- );
123
-
124
- useEffect(() => {
125
- if (!presentation || !dxGrid) {
126
- return;
91
+ const TableMainInner = <T extends Type.Entity.Any = Type.Entity.Any>(
92
+ { schema, model, presentation, ignoreAttention, onCreate, onRowClick, testId }: TableMainProps<T>,
93
+ forwardedRef: Ref<TableController>,
94
+ ) => {
95
+ const [dxGrid, setDxGrid] = useState<DxGridElement | null>(null);
96
+ const { hasAttention } = useAttention(model?.id ?? 'table');
97
+ const modals = useMemo(() => new ModalController(), []);
98
+
99
+ const draftRowCount = model?.getDraftRowCount() ?? 0;
100
+
101
+ const frozen = useMemo(() => {
102
+ const noActionColumn =
103
+ model?.features.dataEditable === false &&
104
+ model?.features.schemaEditable === false &&
105
+ model.rowActions.length === 0;
106
+
107
+ return {
108
+ frozenRowsStart: 1,
109
+ frozenRowsEnd: Math.max(1, draftRowCount),
110
+ frozenColsStart: model?.features.selection.enabled ? 1 : 0,
111
+ frozenColsEnd: noActionColumn ? 0 : 1,
112
+ };
113
+ }, [model, draftRowCount]);
114
+
115
+ const getCells = useCallback<NonNullable<GridContentProps['getCells']>>(
116
+ (range: DxGridPlaneRange, plane: DxGridPlane) => presentation?.getCells(range, plane) ?? {},
117
+ [presentation],
118
+ );
119
+
120
+ // Set initial sort order.
121
+ // TODO(burdon): Save sort order to local storage.
122
+ useEffect(() => {
123
+ const fieldId = model?.projection?.fields?.[0]?.id;
124
+ if (fieldId) {
125
+ model.sorting.setSort(fieldId, 'asc');
126
+ }
127
+ }, [model]);
128
+
129
+ useEffect(() => {
130
+ if (!dxGrid || !presentation) {
131
+ return;
132
+ }
133
+
134
+ dxGrid.getCells = getCells;
135
+ }, [dxGrid, presentation, getCells]);
136
+
137
+ const handleInsertRowResult = useCallback(
138
+ (insertResult?: InsertRowResult) => {
139
+ if (insertResult === 'draft') {
140
+ requestAnimationFrame(() => {
141
+ dxGrid?.setFocus({ plane: 'frozenRowsEnd', col: 0, row: 0 });
142
+ dxGrid?.refocus();
143
+ });
144
+ } else {
145
+ requestAnimationFrame(() => {
146
+ dxGrid?.setFocus({ plane: 'grid', col: 0, row: model ? model.getRowCount() - 1 : 0 });
147
+ dxGrid?.refocus();
148
+ });
127
149
  }
128
- dxGrid.getCells = getCells;
129
- }, [presentation, dxGrid, getCells]);
150
+ },
151
+ [model, dxGrid],
152
+ );
130
153
 
131
- const handleInsertRowResult = useCallback(
132
- (insertResult?: InsertRowResult) => {
133
- if (insertResult === 'draft') {
134
- requestAnimationFrame(() => {
135
- dxGrid?.setFocus({ plane: 'frozenRowsEnd', col: 0, row: 0 });
136
- dxGrid?.refocus();
137
- });
138
- } else {
154
+ const handleSave = useCallback(() => {
155
+ dxGrid?.updateCells(true);
156
+ dxGrid?.requestUpdate();
157
+ }, [dxGrid]);
158
+
159
+ const handleSaveDraftRow = useCallback(
160
+ (rowIndex = 0) => {
161
+ if (model && dxGrid) {
162
+ const didCommitSuccessfully = model.commitDraftRow(rowIndex);
163
+ if (didCommitSuccessfully) {
139
164
  requestAnimationFrame(() => {
140
- dxGrid?.setFocus({ plane: 'grid', col: 0, row: model ? model.getRowCount() - 1 : 0 });
141
- dxGrid?.refocus();
165
+ dxGrid.scrollToEndRow();
142
166
  });
143
167
  }
144
- },
145
- [model, dxGrid],
146
- );
147
-
148
- /**
149
- * Provides an external controller that can be called to repaint the table.
150
- */
151
- useImperativeHandle<TableController, TableController>(forwardedRef, () => {
152
- if (!presentation || !dxGrid) {
153
- return {};
154
168
  }
169
+ },
170
+ [model, dxGrid],
171
+ );
155
172
 
156
- return {
157
- update: (cell) => {
158
- if (cell) {
159
- dxGrid.updateIfWithinBounds(cell, true);
173
+ const handleGridClick = useCallback(
174
+ (event: MouseEvent) => {
175
+ const cell = closestCell(event.target as HTMLElement);
176
+ if (cell) {
177
+ const { row: rowIndex, plane } = cell;
178
+ if (onRowClick) {
179
+ if (plane === 'grid') {
180
+ const row = model?.getRowAt(rowIndex);
181
+ row && onRowClick(row);
160
182
  } else {
161
- dxGrid.updateCells(true);
162
- dxGrid.requestUpdate();
163
- }
164
- },
165
- handleInsertRowResult,
166
- };
167
- }, [presentation, dxGrid, model]);
168
-
169
- const handleSaveDraftRow = useCallback(
170
- (rowIndex = 0) => {
171
- if (model && dxGrid) {
172
- const didCommitSuccessfully = model.commitDraftRow(rowIndex);
173
- if (didCommitSuccessfully) {
174
- requestAnimationFrame(() => {
175
- dxGrid.scrollToEndRow();
176
- });
183
+ onRowClick(cell);
177
184
  }
178
185
  }
179
- },
180
- [model, dxGrid],
181
- );
182
186
 
183
- const handleGridClick = useCallback(
184
- (event: MouseEvent) => {
185
- const cell = closestCell(event.target as HTMLElement);
186
- if (cell) {
187
- const { row: rowIndex, plane } = cell;
188
- if (onRowClick) {
189
- if (plane === 'grid') {
190
- const row = model?.getRowAt(rowIndex);
191
- row && onRowClick(row);
192
- } else {
193
- onRowClick(cell);
194
- }
195
- }
196
-
197
- if (model?.features.selection.enabled && model?.selection.selectionMode === 'single') {
198
- model.selection.toggleSelectionForRowIndex(rowIndex);
199
- }
200
- }
201
-
202
- if (!modals) {
203
- return;
187
+ if (model?.features.selection.enabled && model?.selection.selectionMode === 'single') {
188
+ model.selection.toggleSelectionForRowIndex(rowIndex);
204
189
  }
190
+ }
205
191
 
206
- const buttonSelector = Object.values(tableButtons)
207
- .map((button) => `button[${button.attr}]`)
208
- .join(',');
192
+ if (!modals) {
193
+ return;
194
+ }
209
195
 
210
- const tableButtonElement = (event.target as HTMLElement).closest(buttonSelector) as HTMLElement;
211
- if (tableButtonElement) {
212
- const button = Object.values(tableButtons).find((btn) => tableButtonElement.hasAttribute(btn.attr));
213
- if (!button) {
214
- return;
215
- }
196
+ const buttonSelector = Object.values(tableButtons)
197
+ .map((button) => `button[${button.attr}]`)
198
+ .join(',');
216
199
 
217
- modals.setTrigger(tableButtonElement);
218
- const data = button.getData(tableButtonElement);
219
- switch (data.type) {
220
- case 'rowMenu': {
221
- modals.showRowMenu(data.rowIndex);
222
- break;
223
- }
224
- case 'columnSettings': {
225
- modals.showColumnMenu(data.fieldId);
226
- break;
227
- }
228
- case 'newColumn': {
229
- modals.showColumnCreator();
230
- break;
231
- }
232
- case 'sort': {
233
- model?.sorting?.toggleSort(data.fieldId);
234
- break;
235
- }
236
- case 'saveDraftRow': {
237
- handleSaveDraftRow();
238
- break;
239
- }
240
- }
200
+ const tableButtonElement = (event.target as HTMLElement).closest(buttonSelector) as HTMLElement;
201
+ if (tableButtonElement) {
202
+ const button = Object.values(tableButtons).find((btn) => tableButtonElement.hasAttribute(btn.attr));
203
+ if (!button) {
241
204
  return;
242
205
  }
243
206
 
244
- const controlSelector = Object.values(tableControls)
245
- .map((control) => `input[${control.attr}]`)
246
- .join(',');
247
-
248
- const controlElement = (event.target as HTMLElement).closest(controlSelector) as HTMLElement;
249
- if (controlElement) {
250
- const control = Object.values(tableControls).find((ctrl) => controlElement.hasAttribute(ctrl.attr));
251
- if (!control) {
252
- return;
207
+ modals.setTrigger(tableButtonElement);
208
+ const data = button.getData(tableButtonElement);
209
+ switch (data.type) {
210
+ case 'rowMenu': {
211
+ modals.showRowMenu(data.rowIndex);
212
+ break;
253
213
  }
254
-
255
- const data = control.getData(controlElement);
256
- switch (data.type) {
257
- case 'checkbox': {
258
- if (data.header) {
259
- model?.selection.setSelection(model.selection.allRowsSeleted.value ? 'none' : 'all');
260
- } else {
261
- model?.selection.toggleSelectionForRowIndex(data.rowIndex);
262
- }
263
- break;
264
- }
265
- case 'switch': {
266
- if (model) {
267
- model.updateCellData({ row: data.rowIndex, col: data.colIndex }, (value) => !value);
268
- }
269
- break;
270
- }
214
+ case 'columnSettings': {
215
+ modals.showColumnMenu(data.fieldId);
216
+ break;
271
217
  }
272
- }
273
- },
274
- [model, modals, dxGrid],
275
- );
276
-
277
- const handleFocus = useCallback<NonNullable<TableCellEditorProps['onFocus']>>(
278
- (increment, delta, cell) => {
279
- if (dxGrid && model) {
280
- if (cell?.plane === 'grid' && cell?.row >= model.getRowCount() - 1 && increment !== 'col') {
281
- handleInsertRowResult(draftRowCount < 1 ? model.insertRow() : 'final');
282
- } else if (cell?.plane === 'frozenRowsEnd' && increment === 'row') {
283
- handleSaveDraftRow(cell.row);
284
- handleInsertRowResult(model.insertRow());
285
- } else {
286
- dxGrid.refocus(increment, delta);
218
+ case 'newColumn': {
219
+ modals.showColumnCreator();
220
+ break;
221
+ }
222
+ case 'sort': {
223
+ model?.sorting?.toggleSort(data.fieldId);
224
+ break;
225
+ }
226
+ case 'saveDraftRow': {
227
+ handleSaveDraftRow();
228
+ break;
287
229
  }
288
230
  }
289
- },
290
- [dxGrid, model],
291
- );
231
+ return;
232
+ }
292
233
 
293
- const handleKeyDown = useCallback<NonNullable<GridContentProps['onKeyDown']>>(
294
- (event) => {
295
- if (!model?.features.dataEditable) {
296
- return;
297
- }
234
+ const controlSelector = Object.values(tableControls)
235
+ .map((control) => `input[${control.attr}]`)
236
+ .join(',');
298
237
 
299
- const cell = closestCell(event.target);
300
- if (!model || !cell) {
238
+ const controlElement = (event.target as HTMLElement).closest(controlSelector) as HTMLElement;
239
+ if (controlElement) {
240
+ const control = Object.values(tableControls).find((ctrl) => controlElement.hasAttribute(ctrl.attr));
241
+ if (!control) {
301
242
  return;
302
243
  }
303
244
 
304
- // Handle Meta+C (Copy) and Meta+V (Paste) commands
305
- if (event.metaKey || event.ctrlKey) {
306
- switch (event.key) {
307
- case 'c': {
308
- // Copy focused cell's text content to clipboard
309
- try {
310
- const cellData = model.getCellData(cell);
311
- const textContent = cellData?.toString() ?? '';
312
- void navigator.clipboard.writeText(textContent);
313
- event.preventDefault();
314
- } catch (error) {
315
- console.warn('Failed to copy cell content:', error);
316
- }
317
- break;
245
+ const data = control.getData(controlElement);
246
+ switch (data.type) {
247
+ case 'checkbox': {
248
+ if (data.header) {
249
+ model?.selection.setSelection(model.selection.allRowsSeleted.value ? 'none' : 'all');
250
+ } else {
251
+ model?.selection.toggleSelectionForRowIndex(data.rowIndex);
318
252
  }
319
- case 'v': {
320
- // Paste clipboard content to focused cell
321
- event.preventDefault();
322
- void navigator.clipboard.readText().then((clipboardText) => {
323
- try {
324
- // Attempt to set the cell's content to clipboard content
325
- model.setCellData(cell, String.trim(clipboardText).replace(/[\n\r]+/, ' '));
326
- handleSave();
327
- } catch {
328
- // If validation fails, emit a DxEditRequest event with initialContent from clipboard
329
- // TODO(thure): Should `dx-grid` expose a method like this?
330
- const cellElement = (event.target as HTMLElement).closest(
331
- '[data-dx-grid-action="cell"]',
332
- ) as HTMLElement;
333
- if (cellElement) {
334
- const rect = cellElement.getBoundingClientRect();
335
- const editRequest = new DxEditRequest({
336
- cellIndex: `${cell.plane},${cell.col},${cell.row}`,
337
- cellBox: {
338
- insetInlineStart: rect.left,
339
- insetBlockStart: rect.top,
340
- inlineSize: rect.width,
341
- blockSize: rect.height,
342
- },
343
- cellElement,
344
- initialContent: clipboardText,
345
- });
346
- cellElement.dispatchEvent(editRequest);
347
- }
348
- }
349
- });
350
- break;
253
+ break;
254
+ }
255
+ case 'switch': {
256
+ if (model) {
257
+ model.updateCellData({ row: data.rowIndex, col: data.colIndex }, (value) => !value);
351
258
  }
259
+ break;
352
260
  }
353
261
  }
262
+ }
263
+ },
264
+ [model, modals, dxGrid],
265
+ );
354
266
 
267
+ const handleFocus = useCallback<NonNullable<TableCellEditorProps['onFocus']>>(
268
+ (increment, delta, cell) => {
269
+ if (dxGrid && model) {
270
+ if (cell?.plane === 'grid' && cell?.row >= model.getRowCount() - 1 && increment !== 'col') {
271
+ handleInsertRowResult(draftRowCount < 1 ? model.insertRow() : 'final');
272
+ } else if (cell?.plane === 'frozenRowsEnd' && increment === 'row') {
273
+ handleSaveDraftRow(cell.row);
274
+ handleInsertRowResult(model.insertRow());
275
+ } else {
276
+ dxGrid.refocus(increment, delta);
277
+ }
278
+ }
279
+ },
280
+ [dxGrid, model],
281
+ );
282
+
283
+ const handleKeyDown = useCallback<NonNullable<GridContentProps['onKeyDown']>>(
284
+ (event) => {
285
+ if (!model?.features.dataEditable) {
286
+ return;
287
+ }
288
+
289
+ const cell = closestCell(event.target);
290
+ if (!model || !cell) {
291
+ return;
292
+ }
293
+
294
+ // Handle Meta+C (Copy) and Meta+V (Paste) commands
295
+ if (event.metaKey || event.ctrlKey) {
355
296
  switch (event.key) {
356
- case 'Backspace':
357
- case 'Delete': {
297
+ case 'c': {
298
+ // Copy focused cell's text content to clipboard
358
299
  try {
359
- model.setCellData(cell, undefined);
300
+ const cellData = model.getCellData(cell);
301
+ const textContent = cellData?.toString() ?? '';
302
+ void navigator.clipboard.writeText(textContent);
360
303
  event.preventDefault();
361
- handleSave();
362
- } catch {
363
- // Delete results in a validation error; don’t prevent default so dx-grid can emit an edit request.
304
+ } catch (error) {
305
+ console.warn('Failed to copy cell content:', error);
364
306
  }
365
307
  break;
366
308
  }
309
+ case 'v': {
310
+ // Paste clipboard content to focused cell
311
+ event.preventDefault();
312
+ void navigator.clipboard.readText().then((clipboardText) => {
313
+ try {
314
+ // Attempt to set the cell's content to clipboard content
315
+ model.setCellData(cell, String.trim(clipboardText).replace(/[\n\r]+/, ' '));
316
+ handleSave();
317
+ } catch {
318
+ // If validation fails, emit a DxEditRequest event with initialContent from clipboard
319
+ // TODO(thure): Should `dx-grid` expose a method like this?
320
+ const cellElement = (event.target as HTMLElement).closest(
321
+ '[data-dx-grid-action="cell"]',
322
+ ) as HTMLElement;
323
+ if (cellElement) {
324
+ const rect = cellElement.getBoundingClientRect();
325
+ const editRequest = new DxEditRequest({
326
+ cellIndex: `${cell.plane},${cell.col},${cell.row}`,
327
+ cellBox: {
328
+ insetInlineStart: rect.left,
329
+ insetBlockStart: rect.top,
330
+ inlineSize: rect.width,
331
+ blockSize: rect.height,
332
+ },
333
+ cellElement,
334
+ initialContent: clipboardText,
335
+ });
336
+ cellElement.dispatchEvent(editRequest);
337
+ }
338
+ }
339
+ });
340
+ break;
341
+ }
367
342
  }
368
- },
369
- [model, dxGrid, handleSave],
370
- );
371
-
372
- const handleAxisResize = useCallback<NonNullable<GridContentProps['onAxisResize']>>(
373
- (event) => {
374
- if (event.axis === 'col') {
375
- const columnIndex = parseInt(event.index, 10);
376
- model?.setColumnWidth(columnIndex, event.size);
377
- }
378
- },
379
- [model],
380
- );
343
+ }
381
344
 
382
- const handleWheel = useCallback(
383
- (event: WheelEvent) => {
384
- if (!ignoreAttention && !hasAttention) {
385
- event.stopPropagation();
345
+ switch (event.key) {
346
+ case 'Backspace':
347
+ case 'Delete': {
348
+ try {
349
+ model.setCellData(cell, undefined);
350
+ event.preventDefault();
351
+ handleSave();
352
+ } catch {
353
+ // Delete results in a validation error; don’t prevent default so dx-grid can emit an edit request.
354
+ }
355
+ break;
386
356
  }
387
- },
388
- [hasAttention, ignoreAttention],
389
- );
357
+ }
358
+ },
359
+ [model, dxGrid, handleSave],
360
+ );
361
+
362
+ const handleAxisResize = useCallback<NonNullable<GridContentProps['onAxisResize']>>(
363
+ (event) => {
364
+ if (event.axis === 'col') {
365
+ const columnIndex = parseInt(event.index, 10);
366
+ model?.setColumnWidth(columnIndex, event.size);
367
+ }
368
+ },
369
+ [model],
370
+ );
390
371
 
391
- const handleNewColumn = useCallback(() => {
392
- const columns = model?.getColumnCount();
393
- if (dxGrid && columns) {
394
- dxGrid.scrollToColumn(columns - 1);
372
+ const handleWheel = useCallback(
373
+ (event: WheelEvent) => {
374
+ if (!ignoreAttention && !hasAttention) {
375
+ event.stopPropagation();
395
376
  }
396
- }, [model, dxGrid]);
377
+ },
378
+ [hasAttention, ignoreAttention],
379
+ );
397
380
 
398
- if (!model || !modals) {
399
- return <span role='none' className='attention-surface' />;
381
+ const handleNewColumn = useCallback(() => {
382
+ const columns = model?.getColumnCount();
383
+ if (dxGrid && columns) {
384
+ dxGrid.scrollToColumn(columns - 1);
400
385
  }
386
+ }, [model, dxGrid]);
387
+
388
+ /**
389
+ * Provides an external controller that can be called to repaint the table.
390
+ */
391
+ useImperativeHandle<TableController, TableController>(forwardedRef, () => {
392
+ if (!dxGrid || !presentation) {
393
+ return {};
394
+ }
395
+
396
+ return {
397
+ update: (cell) => {
398
+ if (cell) {
399
+ dxGrid.updateIfWithinBounds(cell, true);
400
+ } else {
401
+ dxGrid.updateCells(true);
402
+ dxGrid.requestUpdate();
403
+ }
404
+ },
405
+ handleInsertRowResult,
406
+ };
407
+ }, [dxGrid, model, presentation]);
408
+
409
+ if (!model || !modals) {
410
+ return <span role='none' className='attention-surface' />;
411
+ }
412
+
413
+ return (
414
+ <Grid.Root id={model.id ?? 'table-grid'}>
415
+ <TableValueEditor<T>
416
+ model={model}
417
+ modals={modals}
418
+ schema={schema}
419
+ onFocus={handleFocus}
420
+ onSave={handleSave}
421
+ onCreate={onCreate}
422
+ />
423
+ <Grid.Content
424
+ className={mx('[--dx-grid-base:var(--baseSurface)]', gridSeparatorInlineEnd, gridSeparatorBlockEnd)}
425
+ frozen={frozen}
426
+ columns={model.columnMeta.value}
427
+ columnDefault={columnDefault}
428
+ rowDefault={rowDefault}
429
+ limitRows={model.getRowCount() ?? 0}
430
+ limitColumns={model.projection.fields.length}
431
+ overscroll='trap'
432
+ onAxisResize={handleAxisResize}
433
+ onClick={handleGridClick}
434
+ onKeyDownCapture={handleKeyDown}
435
+ onWheelCapture={handleWheel}
436
+ {...(testId && { 'data-testid': testId })}
437
+ ref={setDxGrid}
438
+ />
439
+ <RowActionsMenu model={model} modals={modals} />
440
+ <ColumnActionsMenu model={model} modals={modals} />
441
+ <ColumnSettings model={model} modals={modals} onNewColumn={handleNewColumn} />
442
+ </Grid.Root>
443
+ );
444
+ };
401
445
 
402
- return (
403
- <Grid.Root id={model.id ?? 'table-grid'}>
404
- <TableValueEditor
405
- model={model}
406
- modals={modals}
407
- schema={schema}
408
- onFocus={handleFocus}
409
- onSave={handleSave}
410
- onCreate={onCreate}
411
- client={client}
412
- />
413
- <Grid.Content
414
- className={mx('[--dx-grid-base:var(--baseSurface)]', gridSeparatorInlineEnd, gridSeparatorBlockEnd)}
415
- frozen={frozen}
416
- columns={model.columnMeta.value}
417
- columnDefault={columnDefault}
418
- rowDefault={rowDefault}
419
- limitRows={model.getRowCount() ?? 0}
420
- limitColumns={model.projection.fields.length}
421
- overscroll='trap'
422
- onAxisResize={handleAxisResize}
423
- onClick={handleGridClick}
424
- onKeyDownCapture={handleKeyDown}
425
- onWheelCapture={handleWheel}
426
- {...(testId && { 'data-testid': testId })}
427
- ref={setDxGrid}
428
- />
429
- <RowActionsMenu model={model} modals={modals} />
430
- <ColumnActionsMenu model={model} modals={modals} />
431
- <ColumnSettings model={model} modals={modals} onNewColumn={handleNewColumn} />
432
- </Grid.Root>
433
- );
434
- },
435
- );
446
+ const TableMain = forwardRef(TableMainInner) as <T extends Type.Entity.Any = Type.Entity.Any>(
447
+ props: TableMainProps<T> & { ref?: Ref<TableController> },
448
+ ) => JSX.Element;
436
449
 
437
450
  export const Table = {
438
451
  Root: TableRoot,