@dxos/react-ui-table 0.8.4-main.ae835ea → 0.8.4-main.bc674ce

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