@dxos/react-ui-table 0.8.3 → 0.8.4-main.1068cf700f

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