@dxos/react-ui-table 0.8.4-main.2e9d522 → 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 (144) 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-5XLSHLOG.mjs → chunk-V7A7ZA5E.mjs} +576 -544
  4. package/dist/lib/browser/chunk-V7A7ZA5E.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +694 -760
  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 +16 -27
  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-Y66YLSTC.mjs → chunk-P73ALX3N.mjs} +576 -544
  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 +694 -760
  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 +16 -27
  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 +2 -3
  22. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts.map +1 -1
  23. package/dist/types/src/components/Table/ColumnSettings.d.ts +3 -3
  24. package/dist/types/src/components/Table/ColumnSettings.d.ts.map +1 -1
  25. package/dist/types/src/components/Table/DynamicTable.d.ts +5 -6
  26. package/dist/types/src/components/Table/DynamicTable.d.ts.map +1 -1
  27. package/dist/types/src/components/Table/DynamicTable.stories.d.ts +37 -3
  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 +111 -0
  30. package/dist/types/src/components/Table/Relations.stories.d.ts.map +1 -0
  31. package/dist/types/src/components/Table/RowActionsMenu.d.ts +1 -1
  32. package/dist/types/src/components/Table/RowActionsMenu.d.ts.map +1 -1
  33. package/dist/types/src/components/Table/Table.d.ts +13 -9
  34. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  35. package/dist/types/src/components/Table/Table.stories.d.ts +108 -4
  36. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  37. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts +1 -1
  38. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts.map +1 -1
  39. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts +14 -9
  40. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts.map +1 -1
  41. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts +11 -18
  42. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts.map +1 -1
  43. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts +39 -4
  44. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts.map +1 -1
  45. package/dist/types/src/components/TableCellEditor/index.d.ts +1 -0
  46. package/dist/types/src/components/TableCellEditor/index.d.ts.map +1 -1
  47. package/dist/types/src/components/TableToolbar/TableToolbar.d.ts +2 -2
  48. package/dist/types/src/components/TableToolbar/TableToolbar.d.ts.map +1 -1
  49. package/dist/types/src/components/testing.d.ts +3 -5
  50. package/dist/types/src/components/testing.d.ts.map +1 -1
  51. package/dist/types/src/hooks/index.d.ts +2 -1
  52. package/dist/types/src/hooks/index.d.ts.map +1 -1
  53. package/dist/types/src/hooks/useAddRow.d.ts +3 -2
  54. package/dist/types/src/hooks/useAddRow.d.ts.map +1 -1
  55. package/dist/types/src/hooks/useProjectionModel.d.ts +5 -0
  56. package/dist/types/src/hooks/useProjectionModel.d.ts.map +1 -0
  57. package/dist/types/src/hooks/useTableModel.d.ts +5 -6
  58. package/dist/types/src/hooks/useTableModel.d.ts.map +1 -1
  59. package/dist/types/src/model/modal-controller.d.ts +0 -7
  60. package/dist/types/src/model/modal-controller.d.ts.map +1 -1
  61. package/dist/types/src/model/selection-model.d.ts.map +1 -1
  62. package/dist/types/src/model/table-model.d.ts +14 -14
  63. package/dist/types/src/model/table-model.d.ts.map +1 -1
  64. package/dist/types/src/model/table-presentation.d.ts +2 -1
  65. package/dist/types/src/model/table-presentation.d.ts.map +1 -1
  66. package/dist/types/src/model/table-sorting.d.ts +9 -6
  67. package/dist/types/src/model/table-sorting.d.ts.map +1 -1
  68. package/dist/types/src/playwright/TableManager.d.ts +1 -1
  69. package/dist/types/src/playwright/TableManager.d.ts.map +1 -1
  70. package/dist/types/src/testing/useTestTableModel.d.ts +19 -34
  71. package/dist/types/src/testing/useTestTableModel.d.ts.map +1 -1
  72. package/dist/types/src/translations.d.ts +2 -2
  73. package/dist/types/src/types/Table.d.ts +28 -0
  74. package/dist/types/src/types/Table.d.ts.map +1 -0
  75. package/dist/types/src/types/index.d.ts +1 -1
  76. package/dist/types/src/types/index.d.ts.map +1 -1
  77. package/dist/types/src/util/dynamic-table.d.ts +10 -11
  78. package/dist/types/src/util/dynamic-table.d.ts.map +1 -1
  79. package/dist/types/src/util/index.d.ts +0 -1
  80. package/dist/types/src/util/index.d.ts.map +1 -1
  81. package/dist/types/src/util/schema.d.ts +1 -1
  82. package/dist/types/src/util/schema.d.ts.map +1 -1
  83. package/dist/types/src/util/table-buttons.d.ts.map +1 -1
  84. package/dist/types/src/util/table-controls.d.ts.map +1 -1
  85. package/dist/types/tsconfig.tsbuildinfo +1 -1
  86. package/package.json +57 -51
  87. package/src/components/Table/ColumnActionsMenu.tsx +5 -2
  88. package/src/components/Table/ColumnSettings.tsx +3 -4
  89. package/src/components/Table/DynamicTable.stories.tsx +21 -19
  90. package/src/components/Table/DynamicTable.tsx +24 -18
  91. package/src/components/Table/Relations.stories.tsx +233 -0
  92. package/src/components/Table/RowActionsMenu.tsx +23 -15
  93. package/src/components/Table/Table.stories.tsx +104 -99
  94. package/src/components/Table/Table.tsx +338 -295
  95. package/src/components/TableCellEditor/CellValidationMessage.tsx +2 -2
  96. package/src/components/TableCellEditor/FormCellEditor.tsx +143 -39
  97. package/src/components/TableCellEditor/TableCellEditor.stories.tsx +30 -66
  98. package/src/components/TableCellEditor/TableCellEditor.tsx +86 -236
  99. package/src/components/TableCellEditor/index.ts +1 -0
  100. package/src/components/TableToolbar/TableToolbar.tsx +34 -24
  101. package/src/components/testing.ts +4 -4
  102. package/src/hooks/index.ts +2 -1
  103. package/src/hooks/useAddRow.ts +7 -5
  104. package/src/hooks/useProjectionModel.ts +19 -0
  105. package/src/hooks/useTableModel.ts +13 -15
  106. package/src/model/modal-controller.ts +1 -29
  107. package/src/model/selection-model.ts +3 -2
  108. package/src/model/table-model.test.ts +21 -12
  109. package/src/model/table-model.ts +64 -58
  110. package/src/model/table-presentation.test.ts +25 -23
  111. package/src/model/table-presentation.ts +82 -41
  112. package/src/model/table-sorting.ts +32 -14
  113. package/src/playwright/TableManager.ts +5 -5
  114. package/src/playwright/smoke.spec.ts +132 -38
  115. package/src/testing/useTestTableModel.ts +32 -24
  116. package/src/translations.ts +2 -2
  117. package/src/types/Table.ts +90 -0
  118. package/src/types/index.ts +1 -1
  119. package/src/util/dynamic-table.ts +21 -27
  120. package/src/util/index.ts +0 -1
  121. package/src/util/schema.ts +4 -4
  122. package/src/util/table-buttons.ts +4 -3
  123. package/src/util/table-controls.ts +4 -1
  124. package/dist/lib/browser/chunk-5XLSHLOG.mjs.map +0 -7
  125. package/dist/lib/browser/chunk-L2LSB6KV.mjs +0 -18
  126. package/dist/lib/browser/chunk-L2LSB6KV.mjs.map +0 -7
  127. package/dist/lib/node-esm/chunk-NXZ4CJYX.mjs +0 -20
  128. package/dist/lib/node-esm/chunk-NXZ4CJYX.mjs.map +0 -7
  129. package/dist/lib/node-esm/chunk-Y66YLSTC.mjs.map +0 -7
  130. package/dist/types/src/components/Table/CreateRefPanel.d.ts +0 -9
  131. package/dist/types/src/components/Table/CreateRefPanel.d.ts.map +0 -1
  132. package/dist/types/src/components/Table/relations.stories.d.ts +0 -9
  133. package/dist/types/src/components/Table/relations.stories.d.ts.map +0 -1
  134. package/dist/types/src/components/TableCellEditor/extension.d.ts +0 -13
  135. package/dist/types/src/components/TableCellEditor/extension.d.ts.map +0 -1
  136. package/dist/types/src/types/schema.d.ts +0 -8
  137. package/dist/types/src/types/schema.d.ts.map +0 -1
  138. package/dist/types/src/util/table.d.ts +0 -11
  139. package/dist/types/src/util/table.d.ts.map +0 -1
  140. package/src/components/Table/CreateRefPanel.tsx +0 -86
  141. package/src/components/Table/relations.stories.tsx +0 -116
  142. package/src/components/TableCellEditor/extension.ts +0 -61
  143. package/src/types/schema.ts +0 -16
  144. package/src/util/table.ts +0 -49
@@ -2,49 +2,47 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { type Schema } from 'effect/Schema';
5
+ import * as String from 'effect/String';
6
6
  import React, {
7
+ type JSX,
7
8
  type MouseEvent,
8
9
  type PropsWithChildren,
10
+ type Ref,
9
11
  type WheelEvent,
10
12
  forwardRef,
11
13
  useCallback,
14
+ useEffect,
12
15
  useImperativeHandle,
13
- useState,
14
16
  useMemo,
15
- useEffect,
17
+ useState,
16
18
  } from 'react';
17
19
 
18
- import { type Client } from '@dxos/client';
19
- import { Filter } from '@dxos/echo';
20
- import { getValue } from '@dxos/echo-schema';
21
- import { invariant } from '@dxos/invariant';
22
- // TODO(wittjosiah): Remove dependency on react-client.
23
- import { getSpace } from '@dxos/react-client/echo';
24
- import { useTranslation } from '@dxos/react-ui';
20
+ import { type Type } from '@dxos/echo';
25
21
  import { useAttention } from '@dxos/react-ui-attention';
26
22
  import {
27
- closestCell,
28
23
  type DxGridElement,
29
- type DxGridPosition,
30
- type GridContentProps,
31
- Grid,
32
24
  type DxGridPlane,
33
25
  type DxGridPlaneRange,
34
- gridSeparatorInlineEnd,
26
+ type DxGridPosition,
27
+ Grid,
28
+ type GridContentProps,
29
+ closestCell,
35
30
  gridSeparatorBlockEnd,
31
+ gridSeparatorInlineEnd,
36
32
  } from '@dxos/react-ui-grid';
33
+ import { DxEditRequest } from '@dxos/react-ui-grid';
37
34
  import { mx } from '@dxos/react-ui-theme';
38
- import { isNotFalsy, safeParseInt } from '@dxos/util';
35
+
36
+ import { type InsertRowResult, ModalController, type TableModel, type TablePresentation } from '../../model';
37
+ import { tableButtons, tableControls } from '../../util';
38
+ import { type OnCreateHandler, type TableCellEditorProps, TableValueEditor } from '../TableCellEditor';
39
39
 
40
40
  import { ColumnActionsMenu } from './ColumnActionsMenu';
41
41
  import { ColumnSettings } from './ColumnSettings';
42
- import { CreateRefPanel } from './CreateRefPanel';
43
42
  import { RowActionsMenu } from './RowActionsMenu';
44
- import { ModalController, type TableModel, type TablePresentation } from '../../model';
45
- import { translationKey } from '../../translations';
46
- import { tableButtons, tableControls } from '../../util';
47
- import { createOption, TableValueEditor, type TableCellEditorProps } from '../TableCellEditor';
43
+
44
+ const columnDefault = { grid: { minSize: 80, maxSize: 640 } };
45
+ const rowDefault = { frozenRowsStart: { readonly: true, focusUnfurl: false } };
48
46
 
49
47
  //
50
48
  // Table.Root
@@ -76,333 +74,378 @@ const TableRoot = ({ children, role = 'article' }: TableRootProps) => {
76
74
 
77
75
  export type TableController = {
78
76
  update?: (cell?: DxGridPosition) => void;
77
+ handleInsertRowResult?: (insertRowResult?: InsertRowResult) => void;
79
78
  };
80
79
 
81
- export type TableMainProps = {
80
+ export type TableMainProps<T extends Type.Entity.Any = Type.Entity.Any> = {
81
+ schema?: T;
82
82
  model?: TableModel;
83
83
  presentation?: TablePresentation;
84
- schema?: Schema.AnyNoContext;
85
- client?: Client;
86
84
  // TODO(burdon): Rename since attention isn't a useful concept here? Standardize across other components. Pass property into useAttention.
87
85
  ignoreAttention?: boolean;
86
+ onCreate?: OnCreateHandler;
88
87
  onRowClick?: (row: any) => void;
88
+ testId?: string;
89
89
  };
90
90
 
91
- const TableMain = forwardRef<TableController, TableMainProps>(
92
- ({ model, presentation, ignoreAttention, schema, client, onRowClick }, forwardedRef) => {
93
- const [dxGrid, setDxGrid] = useState<DxGridElement | null>(null);
94
- const { hasAttention } = useAttention(model?.id ?? 'table');
95
- const { t } = useTranslation(translationKey);
96
- const modals = useMemo(() => new ModalController(), []);
97
-
98
- const draftRowCount = model?.getDraftRowCount() ?? 0;
99
-
100
- const frozen = useMemo(() => {
101
- const noActionColumn =
102
- model?.features.dataEditable === false &&
103
- model?.features.schemaEditable === false &&
104
- model.rowActions.length === 0;
105
-
106
- return {
107
- frozenRowsStart: 1,
108
- frozenRowsEnd: draftRowCount,
109
- frozenColsStart: model?.features.selection.enabled ? 1 : 0,
110
- frozenColsEnd: noActionColumn ? 0 : 1,
111
- };
112
- }, [model, draftRowCount]);
113
-
114
- const getCells = useCallback<NonNullable<GridContentProps['getCells']>>(
115
- (range: DxGridPlaneRange, plane: DxGridPlane) => presentation?.getCells(range, plane) ?? {},
116
- [presentation],
117
- );
118
-
119
- useEffect(() => {
120
- if (!presentation || !dxGrid) {
121
- 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
+ });
122
149
  }
123
- dxGrid.getCells = getCells;
124
- }, [presentation, dxGrid, getCells]);
125
-
126
- /**
127
- * Provides an external controller that can be called to repaint the table.
128
- */
129
- useImperativeHandle<TableController, TableController>(forwardedRef, () => {
130
- if (!presentation || !dxGrid) {
131
- return {};
150
+ },
151
+ [model, dxGrid],
152
+ );
153
+
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) {
164
+ requestAnimationFrame(() => {
165
+ dxGrid.scrollToEndRow();
166
+ });
167
+ }
132
168
  }
169
+ },
170
+ [model, dxGrid],
171
+ );
133
172
 
134
- return {
135
- update: (cell) => {
136
- if (cell) {
137
- dxGrid.updateIfWithinBounds(cell, true);
138
- } else {
139
- dxGrid.updateCells(true);
140
- dxGrid.requestUpdate();
141
- }
142
- },
143
- };
144
- }, [presentation, dxGrid]);
145
-
146
- const handleGridClick = useCallback(
147
- (event: MouseEvent) => {
148
- const rowIndex = safeParseInt((event.target as HTMLElement).closest('[aria-rowindex]')?.ariaRowIndex ?? '');
149
- if (rowIndex != null) {
150
- if (onRowClick) {
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') {
151
180
  const row = model?.getRowAt(rowIndex);
152
181
  row && onRowClick(row);
153
- }
154
-
155
- if (model?.features.selection.enabled && model?.selection.selectionMode === 'single') {
156
- model.selection.toggleSelectionForRowIndex(rowIndex);
182
+ } else {
183
+ onRowClick(cell);
157
184
  }
158
185
  }
159
186
 
160
- if (!modals) {
161
- return;
187
+ if (model?.features.selection.enabled && model?.selection.selectionMode === 'single') {
188
+ model.selection.toggleSelectionForRowIndex(rowIndex);
162
189
  }
190
+ }
163
191
 
164
- const buttonSelector = Object.values(tableButtons)
165
- .map((button) => `button[${button.attr}]`)
166
- .join(',');
192
+ if (!modals) {
193
+ return;
194
+ }
167
195
 
168
- const tableButtonElement = (event.target as HTMLElement).closest(buttonSelector) as HTMLElement;
169
- if (tableButtonElement) {
170
- const button = Object.values(tableButtons).find((btn) => tableButtonElement.hasAttribute(btn.attr));
171
- if (!button) {
172
- return;
173
- }
196
+ const buttonSelector = Object.values(tableButtons)
197
+ .map((button) => `button[${button.attr}]`)
198
+ .join(',');
174
199
 
175
- modals.setTrigger(tableButtonElement);
176
- const data = button.getData(tableButtonElement);
177
- switch (data.type) {
178
- case 'rowMenu': {
179
- modals.showRowMenu(data.rowIndex);
180
- break;
181
- }
182
- case 'columnSettings': {
183
- modals.showColumnMenu(data.fieldId);
184
- break;
185
- }
186
- case 'newColumn': {
187
- modals.showColumnCreator();
188
- break;
189
- }
190
- case 'sort': {
191
- model?.sorting?.toggleSort(data.fieldId);
192
- break;
193
- }
194
- case 'saveDraftRow': {
195
- if (model) {
196
- const didCommitSuccessfully = model.commitDraftRow(data.rowIndex);
197
- if (dxGrid && didCommitSuccessfully) {
198
- requestAnimationFrame(() => {
199
- dxGrid.scrollToEndRow();
200
- });
201
- }
202
- }
203
- break;
204
- }
205
- }
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) {
206
204
  return;
207
205
  }
208
206
 
209
- const controlSelector = Object.values(tableControls)
210
- .map((control) => `input[${control.attr}]`)
211
- .join(',');
212
-
213
- const controlElement = (event.target as HTMLElement).closest(controlSelector) as HTMLElement;
214
- if (controlElement) {
215
- const control = Object.values(tableControls).find((ctrl) => controlElement.hasAttribute(ctrl.attr));
216
- if (!control) {
217
- 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;
218
213
  }
219
-
220
- const data = control.getData(controlElement);
221
- switch (data.type) {
222
- case 'checkbox': {
223
- if (data.header) {
224
- model?.selection.setSelection(model.selection.allRowsSeleted.value ? 'none' : 'all');
225
- } else {
226
- model?.selection.toggleSelectionForRowIndex(data.rowIndex);
227
- }
228
- break;
229
- }
230
- case 'switch': {
231
- if (model) {
232
- model.updateCellData({ row: data.rowIndex, col: data.colIndex }, (value) => !value);
233
- }
234
- break;
235
- }
214
+ case 'columnSettings': {
215
+ modals.showColumnMenu(data.fieldId);
216
+ break;
217
+ }
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;
236
229
  }
237
230
  }
238
- },
239
- [model, modals, dxGrid],
240
- );
231
+ return;
232
+ }
241
233
 
242
- const handleFocus = useCallback<NonNullable<TableCellEditorProps['onFocus']>>(
243
- (increment, delta) => {
244
- if (dxGrid) {
245
- dxGrid.refocus(increment, delta);
246
- }
247
- },
248
- [dxGrid],
249
- );
234
+ const controlSelector = Object.values(tableControls)
235
+ .map((control) => `input[${control.attr}]`)
236
+ .join(',');
250
237
 
251
- const handleEnter = useCallback<NonNullable<TableCellEditorProps['onEnter']>>(
252
- (cell) => {
253
- if (!model?.features.dataEditable) {
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) {
254
242
  return;
255
243
  }
256
244
 
257
- // TODO(burdon): Insert row only if bottom row isn't completely blank already.
258
- if (model && cell.row === model.getRowCount() - 1) {
259
- model.insertRow();
260
- if (dxGrid) {
261
- requestAnimationFrame(() => {
262
- dxGrid?.scrollToRow(cell.row + 1);
263
- dxGrid?.refocus('row', 1);
264
- });
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);
252
+ }
253
+ break;
254
+ }
255
+ case 'switch': {
256
+ if (model) {
257
+ model.updateCellData({ row: data.rowIndex, col: data.colIndex }, (value) => !value);
258
+ }
259
+ break;
265
260
  }
266
261
  }
267
- },
268
- [model, dxGrid],
269
- );
262
+ }
263
+ },
264
+ [model, modals, dxGrid],
265
+ );
270
266
 
271
- const handleKeyDown = useCallback<NonNullable<GridContentProps['onKeyDown']>>(
272
- (event) => {
273
- if (!model?.features.dataEditable) {
274
- return;
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);
275
277
  }
278
+ }
279
+ },
280
+ [dxGrid, model],
281
+ );
276
282
 
277
- const cell = closestCell(event.target);
278
- if (!model || !cell) {
279
- return;
280
- }
283
+ const handleKeyDown = useCallback<NonNullable<GridContentProps['onKeyDown']>>(
284
+ (event) => {
285
+ if (!model?.features.dataEditable) {
286
+ return;
287
+ }
281
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) {
282
296
  switch (event.key) {
283
- case 'Backspace':
284
- case 'Delete': {
285
- model.setCellData(cell, undefined);
297
+ case 'c': {
298
+ // Copy focused cell's text content to clipboard
299
+ try {
300
+ const cellData = model.getCellData(cell);
301
+ const textContent = cellData?.toString() ?? '';
302
+ void navigator.clipboard.writeText(textContent);
303
+ event.preventDefault();
304
+ } catch (error) {
305
+ console.warn('Failed to copy cell content:', error);
306
+ }
307
+ break;
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
+ });
286
340
  break;
287
341
  }
288
342
  }
289
- },
290
- [model],
291
- );
292
-
293
- const handleAxisResize = useCallback<NonNullable<GridContentProps['onAxisResize']>>(
294
- (event) => {
295
- if (event.axis === 'col') {
296
- const columnIndex = parseInt(event.index, 10);
297
- model?.setColumnWidth(columnIndex, event.size);
298
- }
299
- },
300
- [model],
301
- );
343
+ }
302
344
 
303
- const handleWheel = useCallback(
304
- (event: WheelEvent) => {
305
- if (!ignoreAttention && !hasAttention) {
306
- 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;
307
356
  }
308
- },
309
- [hasAttention, ignoreAttention],
310
- );
357
+ }
358
+ },
359
+ [model, dxGrid, handleSave],
360
+ );
311
361
 
312
- const handleNewColumn = useCallback(() => {
313
- const columns = model?.getColumnCount();
314
- if (dxGrid && columns) {
315
- dxGrid.scrollToColumn(columns - 1);
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);
316
367
  }
317
- }, [model, dxGrid]);
318
-
319
- // TODO(burdon): Factor out?
320
- // TODO(burdon): Generalize to handle other value types (e.g., enums).
321
- const handleQuery = useCallback<NonNullable<TableCellEditorProps['onQuery']>>(
322
- async ({ field, props }, text) => {
323
- if (model && props.referenceSchema && field.referencePath) {
324
- const space = getSpace(model.view);
325
- invariant(space);
326
-
327
- let schema;
328
- if (client) {
329
- schema = client.graph.schemaRegistry.getSchema(props.referenceSchema);
330
- }
331
- if (!schema) {
332
- schema = space.db.schemaRegistry.getSchema(props.referenceSchema);
333
- }
368
+ },
369
+ [model],
370
+ );
334
371
 
335
- if (schema) {
336
- const { objects } = await space.db.query(Filter.type(schema)).run();
337
- const options = objects
338
- .map((obj) => {
339
- const value = getValue(obj, field.referencePath!);
340
- if (!value || typeof value !== 'string') {
341
- return undefined;
342
- }
372
+ const handleWheel = useCallback(
373
+ (event: WheelEvent) => {
374
+ if (!ignoreAttention && !hasAttention) {
375
+ event.stopPropagation();
376
+ }
377
+ },
378
+ [hasAttention, ignoreAttention],
379
+ );
343
380
 
344
- return {
345
- label: value,
346
- data: obj,
347
- };
348
- })
349
- .filter(isNotFalsy);
350
-
351
- return [
352
- ...options,
353
- {
354
- label: t('create new object label', { text }),
355
- data: createOption(text),
356
- },
357
- ];
358
- }
359
- }
381
+ const handleNewColumn = useCallback(() => {
382
+ const columns = model?.getColumnCount();
383
+ if (dxGrid && columns) {
384
+ dxGrid.scrollToColumn(columns - 1);
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
+ }
360
395
 
361
- return [];
396
+ return {
397
+ update: (cell) => {
398
+ if (cell) {
399
+ dxGrid.updateIfWithinBounds(cell, true);
400
+ } else {
401
+ dxGrid.updateCells(true);
402
+ dxGrid.requestUpdate();
403
+ }
362
404
  },
363
- [model, client, t],
364
- );
405
+ handleInsertRowResult,
406
+ };
407
+ }, [dxGrid, model, presentation]);
365
408
 
366
- const handleSave = useCallback(() => {
367
- dxGrid?.updateCells(true);
368
- }, [dxGrid]);
409
+ if (!model || !modals) {
410
+ return <span role='none' className='attention-surface' />;
411
+ }
369
412
 
370
- if (!model || !modals) {
371
- return <span role='none' className='attention-surface' />;
372
- }
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
+ };
373
445
 
374
- return (
375
- <Grid.Root id={model.id ?? 'table-grid'}>
376
- <TableValueEditor
377
- model={model}
378
- modals={modals}
379
- schema={schema}
380
- onEnter={handleEnter}
381
- onFocus={handleFocus}
382
- onQuery={handleQuery}
383
- onSave={handleSave}
384
- />
385
- <Grid.Content
386
- className={mx('[--dx-grid-base:var(--baseSurface)]', gridSeparatorInlineEnd, gridSeparatorBlockEnd)}
387
- frozen={frozen}
388
- columns={model.columnMeta.value}
389
- limitRows={model.getRowCount() ?? 0}
390
- limitColumns={model.projection.fields.length}
391
- overscroll='trap'
392
- onAxisResize={handleAxisResize}
393
- onClick={handleGridClick}
394
- onKeyDown={handleKeyDown}
395
- onWheelCapture={handleWheel}
396
- ref={setDxGrid}
397
- />
398
- <RowActionsMenu model={model} modals={modals} />
399
- <ColumnActionsMenu model={model} modals={modals} />
400
- <ColumnSettings model={model} modals={modals} onNewColumn={handleNewColumn} />
401
- <CreateRefPanel model={model} modals={modals} />
402
- </Grid.Root>
403
- );
404
- },
405
- );
446
+ const TableMain = forwardRef(TableMainInner) as <T extends Type.Entity.Any = Type.Entity.Any>(
447
+ props: TableMainProps<T> & { ref?: Ref<TableController> },
448
+ ) => JSX.Element;
406
449
 
407
450
  export const Table = {
408
451
  Root: TableRoot,