@lotics/ui 2.6.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -15
- package/src/react_native.d.ts +2 -2
- package/src/cell_date.tsx +0 -30
- package/src/cell_date_format.test.ts +0 -32
- package/src/cell_date_format.ts +0 -73
- package/src/cell_number.test.ts +0 -42
- package/src/cell_number.tsx +0 -25
- package/src/cell_number_format.ts +0 -42
- package/src/cell_select.tsx +0 -68
- package/src/cell_text.tsx +0 -45
- package/src/grid/data_grid.tsx +0 -2003
- package/src/grid/data_grid_columns.test.ts +0 -72
- package/src/grid/data_grid_columns.ts +0 -30
- package/src/grid/data_grid_context.ts +0 -119
- package/src/grid/dispatch_safely.ts +0 -39
- package/src/grid/engine.module.css +0 -114
- package/src/grid/engine.tsx +0 -1042
- package/src/grid/helpers.ts +0 -205
- package/src/grid/layout.test.ts +0 -515
- package/src/grid/layout.ts +0 -425
- package/src/grid/recycling.test.ts +0 -236
- package/src/grid/recycling.ts +0 -172
- package/src/grid/row_cell.module.css +0 -105
- package/src/grid/row_cell.tsx +0 -313
- package/src/grid/search_highlight.ts +0 -71
- package/src/grid/select_cell.tsx +0 -58
- package/src/grid/select_group_summary_cell.tsx +0 -76
- package/src/grid/select_header_cell.tsx +0 -32
- package/src/grid/skeleton_row.module.css +0 -34
- package/src/grid/skeleton_row.tsx +0 -20
- package/src/grid/use_grid_groups.ts +0 -311
- package/src/grid/use_scroll_to_cell.ts +0 -135
- package/src/grid/use_virtual_grid.ts +0 -383
- package/src/grid/visibility.test.ts +0 -208
- package/src/grid/visibility.ts +0 -77
- package/src/kanban/constants.ts +0 -18
- package/src/kanban/default_renderers.tsx +0 -160
- package/src/kanban/drag_preview.tsx +0 -157
- package/src/kanban/index.ts +0 -13
- package/src/kanban/insert_card_zone.tsx +0 -135
- package/src/kanban/kanban_board.tsx +0 -635
- package/src/kanban/kanban_card.tsx +0 -321
- package/src/kanban/kanban_column.tsx +0 -499
- package/src/kanban/placeholders.tsx +0 -54
- package/src/kanban/types.ts +0 -116
package/src/grid/data_grid.tsx
DELETED
|
@@ -1,2003 +0,0 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
useCallback,
|
|
3
|
-
useEffect,
|
|
4
|
-
useImperativeHandle,
|
|
5
|
-
useLayoutEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
} from "react";
|
|
10
|
-
import {
|
|
11
|
-
Grid,
|
|
12
|
-
GridRenderFooterCellProps,
|
|
13
|
-
GridRenderFooterProps,
|
|
14
|
-
GridRenderGroupHeadingProps,
|
|
15
|
-
GridRenderGroupSummaryCellProps,
|
|
16
|
-
GridRenderGroupSummaryRowProps,
|
|
17
|
-
GridRenderHeaderProps,
|
|
18
|
-
GridRenderRowProps,
|
|
19
|
-
GridRenderHeaderCellProps,
|
|
20
|
-
GridRenderRowCellProps,
|
|
21
|
-
} from "@lotics/ui/grid/engine";
|
|
22
|
-
import { type RowPathKey, type GroupPathKey, type RowPath } from "@lotics/ui/grid/layout";
|
|
23
|
-
import { AutoSizer } from "@lotics/ui/auto_sizer";
|
|
24
|
-
import { SelectCell } from "./select_cell";
|
|
25
|
-
import { SelectHeaderCell } from "./select_header_cell";
|
|
26
|
-
import { SelectGroupSummaryCell } from "./select_group_summary_cell";
|
|
27
|
-
import { buildGridColumns, deriveHasSelectColumn } from "./data_grid_columns";
|
|
28
|
-
import { IconButton } from "@lotics/ui/icon_button";
|
|
29
|
-
import {
|
|
30
|
-
CellAnimationContext,
|
|
31
|
-
RowSelectionContext,
|
|
32
|
-
noopUseCellAnimation,
|
|
33
|
-
type RowSelectionContextValue,
|
|
34
|
-
type UseCellAnimationFn,
|
|
35
|
-
RowId,
|
|
36
|
-
} from "./data_grid_context";
|
|
37
|
-
import { useGridGroups } from "./use_grid_groups";
|
|
38
|
-
import { GridRef } from "@lotics/ui/grid/use_virtual_grid";
|
|
39
|
-
import { RowCell, NavigationDirection } from "./row_cell";
|
|
40
|
-
import { View, StyleSheet } from "react-native";
|
|
41
|
-
import { colors } from "@lotics/ui/colors";
|
|
42
|
-
// i18n is the consumer's responsibility. DataGrid exposes the few
|
|
43
|
-
// user-facing strings it renders via props (e.g. `expandGroupLabel`,
|
|
44
|
-
// `collapseGroupLabel`); records-side wires them through lingui, iframe
|
|
45
|
-
// apps default to English. Keeping @lotics/ui free of @lingui is what
|
|
46
|
-
// lets the iframe bundle (esbuild) compile this file without pulling in
|
|
47
|
-
// node-side @lingui internals.
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Hierarchical group shape passed to DataGrid. Either a leaf group with `rows`,
|
|
51
|
-
* a parent group with `children`, or both for grouped-with-summaries layouts.
|
|
52
|
-
* Generic over the row payload — domain-agnostic. (Records page's
|
|
53
|
-
* `RecordGroup<TableRecord>` from @lotics/shared is structurally compatible.)
|
|
54
|
-
*/
|
|
55
|
-
export interface DataGridGroup<TRow> {
|
|
56
|
-
/** Grouped field value used for display in group headings */
|
|
57
|
-
value: unknown;
|
|
58
|
-
/** Field key this group is grouped by */
|
|
59
|
-
columnKey: string;
|
|
60
|
-
/** Nested groups (mutually exclusive with rows) */
|
|
61
|
-
children?: DataGridGroup<TRow>[];
|
|
62
|
-
/** Leaf rows (mutually exclusive with children) */
|
|
63
|
-
rows?: TRow[];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface CellPosition {
|
|
67
|
-
rowKey: RowPathKey;
|
|
68
|
-
column: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface SelectionRange {
|
|
72
|
-
/** The anchor cell where selection started (stays fixed during shift+arrow) */
|
|
73
|
-
anchor: CellPosition;
|
|
74
|
-
/** The focus cell where selection ends (moves during shift+arrow) */
|
|
75
|
-
focus: CellPosition;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Selection bounds as primitives for efficient prop comparison.
|
|
80
|
-
* Row indices are positions in the flat row list (not rowKey).
|
|
81
|
-
*/
|
|
82
|
-
export interface SelectionBounds {
|
|
83
|
-
minRowIndex: number;
|
|
84
|
-
maxRowIndex: number;
|
|
85
|
-
minColumn: number;
|
|
86
|
-
maxColumn: number;
|
|
87
|
-
/** The active cell (focus) row index */
|
|
88
|
-
activeRowIndex: number;
|
|
89
|
-
/** The active cell (focus) column */
|
|
90
|
-
activeColumn: number;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Fill drag state for tracking fill handle interactions */
|
|
94
|
-
export interface FillDragState {
|
|
95
|
-
/** Source row index where fill started */
|
|
96
|
-
sourceRowIndex: number;
|
|
97
|
-
/** Target row index where fill ends (updated during drag) */
|
|
98
|
-
targetRowIndex: number;
|
|
99
|
-
/** Column being filled */
|
|
100
|
-
column: number;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Data for a selected cell, used for cell selection change callback */
|
|
104
|
-
export interface SelectedCellData {
|
|
105
|
-
rowId: string;
|
|
106
|
-
columnKey: string;
|
|
107
|
-
value: unknown;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export type { RowPathKey, GroupPathKey } from "@lotics/ui/grid/layout";
|
|
111
|
-
export { useRowSelection, useHeaderRowSelection } from "./data_grid_context";
|
|
112
|
-
|
|
113
|
-
/** Convert RowPathKey string back to RowPath array */
|
|
114
|
-
function keyToRowPath(key: RowPathKey): RowPath {
|
|
115
|
-
return key.split(",").map(Number);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Format a cell value for clipboard (Excel-compatible) */
|
|
119
|
-
function formatValueForClipboard(value: unknown): string {
|
|
120
|
-
if (value === null || value === undefined) {
|
|
121
|
-
return "";
|
|
122
|
-
}
|
|
123
|
-
if (typeof value === "boolean") {
|
|
124
|
-
return value ? "TRUE" : "FALSE";
|
|
125
|
-
}
|
|
126
|
-
if (typeof value === "number") {
|
|
127
|
-
return String(value);
|
|
128
|
-
}
|
|
129
|
-
if (typeof value === "string") {
|
|
130
|
-
// Escape tabs and newlines for TSV format
|
|
131
|
-
return value.replace(/\t/g, " ").replace(/\n/g, " ");
|
|
132
|
-
}
|
|
133
|
-
if (Array.isArray(value)) {
|
|
134
|
-
// For arrays (like multi-select), join with comma
|
|
135
|
-
return value.map((v) => formatValueForClipboard(v)).join(", ");
|
|
136
|
-
}
|
|
137
|
-
if (typeof value === "object") {
|
|
138
|
-
// For objects (like formula results), try to get a displayable value
|
|
139
|
-
if ("value" in value && value.value !== undefined) {
|
|
140
|
-
return formatValueForClipboard(value.value);
|
|
141
|
-
}
|
|
142
|
-
// Fallback to JSON
|
|
143
|
-
return JSON.stringify(value);
|
|
144
|
-
}
|
|
145
|
-
return String(value);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Module-level constant to avoid allocation on every keypress */
|
|
149
|
-
const ARROW_KEY_TO_DIRECTION: Readonly<Record<string, NavigationDirection>> = {
|
|
150
|
-
ArrowUp: "up",
|
|
151
|
-
ArrowDown: "down",
|
|
152
|
-
ArrowLeft: "left",
|
|
153
|
-
ArrowRight: "right",
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
export interface RenderCellProps<TRow = unknown, TValue = unknown> {
|
|
157
|
-
rowKey: RowPathKey;
|
|
158
|
-
rowId: RowId;
|
|
159
|
-
columnKey: string;
|
|
160
|
-
value: TValue;
|
|
161
|
-
editable: boolean;
|
|
162
|
-
row: TRow;
|
|
163
|
-
active: boolean;
|
|
164
|
-
onValueChange: (nextValue: TValue) => void;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export interface RenderEditCellProps<TRow = unknown, TValue = unknown> {
|
|
168
|
-
rowKey: RowPathKey;
|
|
169
|
-
rowId: RowId;
|
|
170
|
-
columnKey: string;
|
|
171
|
-
value: TValue;
|
|
172
|
-
row: TRow;
|
|
173
|
-
onValueChange: (nextValue: TValue) => void;
|
|
174
|
-
onClose: () => void;
|
|
175
|
-
onCancel: () => void;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export interface RenderSummaryCellProps<TRow = unknown> {
|
|
179
|
-
groupKey: GroupPathKey;
|
|
180
|
-
rows: TRow[];
|
|
181
|
-
columnKey: string;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export interface RenderGroupHeadingProps {
|
|
185
|
-
/** Grouped field value. Callers narrow at the render site. */
|
|
186
|
-
value: unknown;
|
|
187
|
-
columnKey: string;
|
|
188
|
-
level: number;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export interface RenderHeaderCellProps {
|
|
192
|
-
columnKey: string;
|
|
193
|
-
/** Callback to resize this column. Optional - resize is also handled by the grid's resize handle. */
|
|
194
|
-
onResize?: (width: number) => void;
|
|
195
|
-
/** Callback to select all cells in this column. */
|
|
196
|
-
onSelectColumn?: () => void;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export interface DataGridColumn<TRow = unknown> {
|
|
200
|
-
key: string;
|
|
201
|
-
name: string | React.ReactElement;
|
|
202
|
-
width?: number;
|
|
203
|
-
|
|
204
|
-
isCellEditable?: (row: TRow, columnKey: string) => boolean;
|
|
205
|
-
/** Called when Enter/Space is pressed on an active, editable cell without renderEditCell (e.g., boolean toggle).
|
|
206
|
-
* Returns the new cell value to commit. */
|
|
207
|
-
onActivate?: (row: TRow, columnKey: string) => unknown;
|
|
208
|
-
renderHeaderCell?: (props: RenderHeaderCellProps) => React.ReactNode;
|
|
209
|
-
renderCell?: <TValue = unknown>(props: RenderCellProps<TRow, TValue>) => React.ReactNode;
|
|
210
|
-
renderEditCell?: <TValue = unknown>(props: RenderEditCellProps<TRow, TValue>) => React.ReactNode;
|
|
211
|
-
renderGroupHeading?: (props: RenderGroupHeadingProps) => React.ReactNode;
|
|
212
|
-
renderSummaryCell?: (props: RenderSummaryCellProps<TRow>) => React.ReactNode;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export interface RowsChangeData {
|
|
216
|
-
indexes: number[];
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export interface DataGridHandle {
|
|
220
|
-
selectCell: (params: { rowPathKey: RowPathKey; columnKey: string }) => void;
|
|
221
|
-
selectCells: (cells: SelectedCellData[]) => void;
|
|
222
|
-
clearSelection: () => void;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export interface FillEvent<TRow = unknown> {
|
|
226
|
-
columnKey: string;
|
|
227
|
-
sourceRow: TRow;
|
|
228
|
-
targetRow: TRow;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export interface DataGridProps<TRow = unknown> {
|
|
232
|
-
columns: DataGridColumn<TRow>[];
|
|
233
|
-
/**
|
|
234
|
-
* Returns the unique data identifier for a row.
|
|
235
|
-
* This is the business key (e.g., "rec_xyz123"), NOT a grid position.
|
|
236
|
-
*/
|
|
237
|
-
rowIdGetter: (row: TRow) => RowId;
|
|
238
|
-
/**
|
|
239
|
-
* Maximum width for frozen columns. If frozen columns exceed this, they will be scaled down proportionally.
|
|
240
|
-
* Use this to prevent frozen columns from taking up too much screen space on small devices.
|
|
241
|
-
* Can be specified as:
|
|
242
|
-
* - A pixel value (e.g., 300) for absolute max width
|
|
243
|
-
* - A ratio between 0 and 1 (e.g., 0.5) for a percentage of the container width
|
|
244
|
-
*/
|
|
245
|
-
maxFrozenColumnsWidth?: number;
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Optional function to extract a background color for a row.
|
|
249
|
-
* Returns a CSS color string (e.g., "rgba(254, 242, 242, 1)") or undefined for no color.
|
|
250
|
-
* Used for conditional row coloring.
|
|
251
|
-
*/
|
|
252
|
-
rowColorGetter?: (row: TRow) => string | undefined;
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Pre-grouped data structure containing all rows.
|
|
256
|
-
* For flat (ungrouped) data, use a single group with all rows.
|
|
257
|
-
* Each group contains nested children or leaf rows with sorting already applied.
|
|
258
|
-
*/
|
|
259
|
-
groups: DataGridGroup<TRow>[];
|
|
260
|
-
collapsedGroups?: GroupPathKey[];
|
|
261
|
-
onCollapsedGroupsChange?: (collapsedGroups: GroupPathKey[]) => void;
|
|
262
|
-
frozenColumnCount?: number;
|
|
263
|
-
ref?: React.Ref<DataGridHandle>;
|
|
264
|
-
rowHeight?: number;
|
|
265
|
-
groupHeadingHeight?: number;
|
|
266
|
-
groupSummaryHeight?: number;
|
|
267
|
-
/**
|
|
268
|
-
* Height of the spacer row rendered after each top-level group.
|
|
269
|
-
* Set to `0` to omit the spacer entirely — no row is added to the layout.
|
|
270
|
-
* @default 56
|
|
271
|
-
*/
|
|
272
|
-
spacerHeight?: number;
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Set of selected row path keys (RowPathKey format: "0,1").
|
|
276
|
-
* Used for checkbox row selection.
|
|
277
|
-
*/
|
|
278
|
-
selectedRows?: Set<RowPathKey>;
|
|
279
|
-
onSelectedRowsChange?: (selectedRows: Set<RowPathKey>) => void;
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Callback when cell selection (blue range highlight) changes.
|
|
283
|
-
* Called with array of selected cell data, or null when selection is cleared.
|
|
284
|
-
*/
|
|
285
|
-
onCellSelectionChange?: (cells: SelectedCellData[] | null) => void;
|
|
286
|
-
|
|
287
|
-
// Fill handle callback
|
|
288
|
-
onFill?: (event: FillEvent<TRow>) => TRow;
|
|
289
|
-
|
|
290
|
-
// Row change callback (for editing)
|
|
291
|
-
onRowsChange?: (rows: TRow[], data: RowsChangeData) => void;
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Callback to format a cell value for copying.
|
|
295
|
-
* Should return a string representation suitable for clipboard/Excel.
|
|
296
|
-
* If not provided, values are converted using String().
|
|
297
|
-
*/
|
|
298
|
-
onCellCopy?: (params: { row: TRow; columnKey: string }) => string;
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Callback to parse a pasted string value for a cell.
|
|
302
|
-
* Should return the parsed value to set on the cell, or undefined to skip.
|
|
303
|
-
* If not provided, the raw string is used.
|
|
304
|
-
*/
|
|
305
|
-
onCellPaste?: (params: { row: TRow; columnKey: string; pastedText: string }) => unknown;
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Callback to handle pasted files (images) for a cell.
|
|
309
|
-
* Called when clipboard contains image data instead of text.
|
|
310
|
-
* Typically used for file/attachment fields.
|
|
311
|
-
*/
|
|
312
|
-
onFilePaste?: (params: { row: TRow; columnKey: string; files: File[] }) => void;
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Predicate filtering pasted files before they're handed to `onFilePaste`.
|
|
316
|
-
* Defaults to accepting every file; records page narrows to image MIME
|
|
317
|
-
* types via `isImageMimeType`.
|
|
318
|
-
*/
|
|
319
|
-
isPasteableFile?: (file: File) => boolean;
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Optional hook called per cell render to decide whether to flash the cell.
|
|
323
|
-
* Records page wires this to its realtime-change-animation hook bound to
|
|
324
|
-
* the current `tableId`. Iframe apps omit it; cells never animate.
|
|
325
|
-
*/
|
|
326
|
-
useCellAnimation?: UseCellAnimationFn;
|
|
327
|
-
|
|
328
|
-
/** Tooltip for the group-collapse chevron when the group is collapsed.
|
|
329
|
-
* i18n is the consumer's responsibility — records page passes `t`Expand``,
|
|
330
|
-
* iframe apps default to "Expand". */
|
|
331
|
-
expandGroupLabel?: string;
|
|
332
|
-
/** Tooltip for the group-collapse chevron when the group is expanded. */
|
|
333
|
-
collapseGroupLabel?: string;
|
|
334
|
-
/**
|
|
335
|
-
* Callback when a column is resized.
|
|
336
|
-
*/
|
|
337
|
-
onColumnResize?: (columnKey: string, width: number) => void;
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Callback when a column is reordered.
|
|
341
|
-
* @param columnKey - The key of the column being moved
|
|
342
|
-
* @param targetColumnKey - The key of the column to insert before, or null to insert at end
|
|
343
|
-
*/
|
|
344
|
-
onColumnReorder?: (columnKey: string, targetColumnKey: string | null) => void;
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Whether column reordering is enabled.
|
|
348
|
-
* When false, columns cannot be dragged to reorder even if onColumnReorder is provided.
|
|
349
|
-
* @default true
|
|
350
|
-
*/
|
|
351
|
-
enableReordering?: boolean;
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Whether column resizing is enabled.
|
|
355
|
-
* When false, columns cannot be resized even if onColumnResize is provided.
|
|
356
|
-
* @default true
|
|
357
|
-
*/
|
|
358
|
-
enableResizing?: boolean;
|
|
359
|
-
|
|
360
|
-
/** Fires when scroll nears the bottom. Used for infinite scroll pagination. */
|
|
361
|
-
onEndReached?: () => void;
|
|
362
|
-
/** Fires when the visible row range changes. Used for viewport-driven page loading. */
|
|
363
|
-
onVisibleRangeChange?: (rowRange: [number, number]) => void;
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Declarative scroll target. When set, the grid scrolls to this row when its
|
|
367
|
-
* layout is ready (the row exists in the grid's internal layout cache).
|
|
368
|
-
* After scrolling, `onScrollTargetReached` is called so the parent can clear the target.
|
|
369
|
-
* This is the proper way to scroll to a record — the grid owns the timing.
|
|
370
|
-
*/
|
|
371
|
-
scrollTarget?: { rowPathKey: RowPathKey; columnKey: string; revealId: number } | null;
|
|
372
|
-
onScrollTargetReached?: (revealId: number) => void;
|
|
373
|
-
|
|
374
|
-
/** Callback when a non-editable cell is double-clicked (e.g., locked cell). */
|
|
375
|
-
onRequestLockedFieldChange?: (row: TRow, columnKey: string) => void;
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Custom renderer for the select column cell.
|
|
379
|
-
* When provided, called for each row. Return a ReactNode to replace the default SelectCell,
|
|
380
|
-
* or undefined to use the default SelectCell.
|
|
381
|
-
*/
|
|
382
|
-
renderSelectCell?: (props: { rowKey: RowPathKey; rowId: RowId; row: TRow }) => React.ReactNode | undefined;
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Custom renderer for the select column header. Pass `() => null` to suppress
|
|
386
|
-
* the default "select all" checkbox (e.g., when the column is being used for
|
|
387
|
-
* per-row actions rather than row selection).
|
|
388
|
-
*/
|
|
389
|
-
renderSelectHeaderCell?: () => React.ReactNode;
|
|
390
|
-
|
|
391
|
-
// Placeholder props for API compatibility (not yet implemented)
|
|
392
|
-
onCellClick?: unknown;
|
|
393
|
-
onCellKeyDown?: unknown;
|
|
394
|
-
onSelectedCellChange?: unknown;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
export function DataGrid<TRow extends Record<string, unknown>>(props: DataGridProps<TRow>) {
|
|
398
|
-
const {
|
|
399
|
-
columns: userColumns,
|
|
400
|
-
groups,
|
|
401
|
-
rowHeight = 56,
|
|
402
|
-
groupHeadingHeight = 40,
|
|
403
|
-
groupSummaryHeight = 40,
|
|
404
|
-
spacerHeight = 56,
|
|
405
|
-
ref,
|
|
406
|
-
collapsedGroups,
|
|
407
|
-
onCollapsedGroupsChange,
|
|
408
|
-
frozenColumnCount = 1,
|
|
409
|
-
maxFrozenColumnsWidth,
|
|
410
|
-
selectedRows,
|
|
411
|
-
onSelectedRowsChange,
|
|
412
|
-
onCellSelectionChange,
|
|
413
|
-
onRowsChange,
|
|
414
|
-
rowIdGetter,
|
|
415
|
-
rowColorGetter,
|
|
416
|
-
onFill,
|
|
417
|
-
onCellCopy,
|
|
418
|
-
onCellPaste,
|
|
419
|
-
onFilePaste,
|
|
420
|
-
onColumnResize,
|
|
421
|
-
onColumnReorder,
|
|
422
|
-
enableReordering = true,
|
|
423
|
-
enableResizing = true,
|
|
424
|
-
onRequestLockedFieldChange,
|
|
425
|
-
renderSelectCell: renderSelectCellProp,
|
|
426
|
-
renderSelectHeaderCell: renderSelectHeaderCellProp,
|
|
427
|
-
isPasteableFile,
|
|
428
|
-
useCellAnimation,
|
|
429
|
-
onEndReached,
|
|
430
|
-
onVisibleRangeChange,
|
|
431
|
-
scrollTarget,
|
|
432
|
-
onScrollTargetReached,
|
|
433
|
-
} = props;
|
|
434
|
-
|
|
435
|
-
const gridRef = useRef<GridRef>(null);
|
|
436
|
-
|
|
437
|
-
const hasSelectColumn = deriveHasSelectColumn(selectedRows, onSelectedRowsChange);
|
|
438
|
-
|
|
439
|
-
const expandGroupLabel = props.expandGroupLabel ?? "Expand";
|
|
440
|
-
const collapseGroupLabel = props.collapseGroupLabel ?? "Collapse";
|
|
441
|
-
// Selection state: anchor (start) and focus (current) positions
|
|
442
|
-
const [selection, setSelection] = useState<SelectionRange | null>(null);
|
|
443
|
-
|
|
444
|
-
const [editingCell, setEditingCell] = useState<{
|
|
445
|
-
rowKey: RowPathKey;
|
|
446
|
-
column: number;
|
|
447
|
-
} | null>(null);
|
|
448
|
-
|
|
449
|
-
// Fill drag state for tracking fill handle drag
|
|
450
|
-
const [fillDrag, setFillDrag] = useState<FillDragState | null>(null);
|
|
451
|
-
|
|
452
|
-
// Column resize state
|
|
453
|
-
const [resizeDrag, setResizeDrag] = useState<{
|
|
454
|
-
columnKey: string;
|
|
455
|
-
startX: number;
|
|
456
|
-
startWidth: number;
|
|
457
|
-
} | null>(null);
|
|
458
|
-
|
|
459
|
-
// Column reorder drag state
|
|
460
|
-
const [reorderDrag, setReorderDrag] = useState<{
|
|
461
|
-
columnKey: string;
|
|
462
|
-
targetColumnKey: string | null;
|
|
463
|
-
} | null>(null);
|
|
464
|
-
|
|
465
|
-
const { gridGroups, groupDataMap, rowDataMap, allRowPathKeys, descendantCache } = useGridGroups({
|
|
466
|
-
groups,
|
|
467
|
-
rowIdGetter,
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
// Build ordered row keys array for navigation
|
|
471
|
-
const orderedRowKeys = useMemo(() => {
|
|
472
|
-
return Array.from(allRowPathKeys);
|
|
473
|
-
}, [allRowPathKeys]);
|
|
474
|
-
|
|
475
|
-
// Build row key to index lookup
|
|
476
|
-
const rowKeyToIndex = useMemo(() => {
|
|
477
|
-
const map = new Map<RowPathKey, number>();
|
|
478
|
-
orderedRowKeys.forEach((key, index) => {
|
|
479
|
-
map.set(key, index);
|
|
480
|
-
});
|
|
481
|
-
return map;
|
|
482
|
-
}, [orderedRowKeys]);
|
|
483
|
-
|
|
484
|
-
// Build rowId to data-array index lookup for O(1) row lookups in mutations
|
|
485
|
-
const rowIdToDataIndex = useMemo(() => {
|
|
486
|
-
const map = new Map<RowId, number>();
|
|
487
|
-
let idx = 0;
|
|
488
|
-
for (const row of rowDataMap.values()) {
|
|
489
|
-
map.set(rowIdGetter(row), idx);
|
|
490
|
-
idx++;
|
|
491
|
-
}
|
|
492
|
-
return map;
|
|
493
|
-
}, [rowDataMap, rowIdGetter]);
|
|
494
|
-
|
|
495
|
-
const rowIdToDataIndexRef = useRef(rowIdToDataIndex);
|
|
496
|
-
rowIdToDataIndexRef.current = rowIdToDataIndex;
|
|
497
|
-
|
|
498
|
-
const rowDataMapRef = useRef(rowDataMap);
|
|
499
|
-
rowDataMapRef.current = rowDataMap;
|
|
500
|
-
|
|
501
|
-
const descendantCacheRef = useRef(descendantCache);
|
|
502
|
-
descendantCacheRef.current = descendantCache;
|
|
503
|
-
|
|
504
|
-
const onRowsChangeRef = useRef(onRowsChange);
|
|
505
|
-
onRowsChangeRef.current = onRowsChange;
|
|
506
|
-
|
|
507
|
-
const onFillRef = useRef(onFill);
|
|
508
|
-
onFillRef.current = onFill;
|
|
509
|
-
|
|
510
|
-
const onCellCopyRef = useRef(onCellCopy);
|
|
511
|
-
onCellCopyRef.current = onCellCopy;
|
|
512
|
-
|
|
513
|
-
const onCellPasteRef = useRef(onCellPaste);
|
|
514
|
-
onCellPasteRef.current = onCellPaste;
|
|
515
|
-
|
|
516
|
-
const onFilePasteRef = useRef(onFilePaste);
|
|
517
|
-
onFilePasteRef.current = onFilePaste;
|
|
518
|
-
|
|
519
|
-
const isPasteableFileRef = useRef(isPasteableFile);
|
|
520
|
-
isPasteableFileRef.current = isPasteableFile;
|
|
521
|
-
|
|
522
|
-
const onColumnResizeRef = useRef(onColumnResize);
|
|
523
|
-
onColumnResizeRef.current = onColumnResize;
|
|
524
|
-
|
|
525
|
-
const onColumnReorderRef = useRef(onColumnReorder);
|
|
526
|
-
onColumnReorderRef.current = onColumnReorder;
|
|
527
|
-
|
|
528
|
-
const onCellSelectionChangeRef = useRef(onCellSelectionChange);
|
|
529
|
-
onCellSelectionChangeRef.current = onCellSelectionChange;
|
|
530
|
-
|
|
531
|
-
const orderedRowKeysRef = useRef(orderedRowKeys);
|
|
532
|
-
orderedRowKeysRef.current = orderedRowKeys;
|
|
533
|
-
|
|
534
|
-
const rowKeyToIndexRef = useRef(rowKeyToIndex);
|
|
535
|
-
rowKeyToIndexRef.current = rowKeyToIndex;
|
|
536
|
-
|
|
537
|
-
const fillDragRef = useRef(fillDrag);
|
|
538
|
-
fillDragRef.current = fillDrag;
|
|
539
|
-
|
|
540
|
-
// First selectable column: index 0 when there is no select column, else 1
|
|
541
|
-
// (the select checkbox column occupies index 0 and is not cell-selectable).
|
|
542
|
-
const firstSelectableColumn = hasSelectColumn ? 1 : 0;
|
|
543
|
-
|
|
544
|
-
// Build finalColumns first so we can use columnCount in handlers.
|
|
545
|
-
// The select column is prepended only when the consumer participates in row
|
|
546
|
-
// selection (`hasSelectColumn`); otherwise the grid is just its user columns.
|
|
547
|
-
const finalColumns = useMemo(() => {
|
|
548
|
-
const selectColumn: DataGridColumn<TRow> = {
|
|
549
|
-
key: "select",
|
|
550
|
-
name: "",
|
|
551
|
-
width: 32 + 4,
|
|
552
|
-
renderHeaderCell: renderSelectHeaderCellProp ?? (() => <SelectHeaderCell />),
|
|
553
|
-
renderCell: (props) => {
|
|
554
|
-
if (renderSelectCellProp) {
|
|
555
|
-
const custom = renderSelectCellProp({ rowKey: props.rowKey, rowId: props.rowId, row: props.row });
|
|
556
|
-
if (custom !== undefined) return custom;
|
|
557
|
-
}
|
|
558
|
-
return <SelectCell rowKey={props.rowKey} />;
|
|
559
|
-
},
|
|
560
|
-
renderSummaryCell: (props: RenderSummaryCellProps<TRow>) => (
|
|
561
|
-
<SelectGroupSummaryCell
|
|
562
|
-
groupKey={props.groupKey}
|
|
563
|
-
descendantRowKeys={descendantCacheRef.current.get(props.groupKey)?.rowPathKeys ?? []}
|
|
564
|
-
/>
|
|
565
|
-
),
|
|
566
|
-
};
|
|
567
|
-
|
|
568
|
-
return buildGridColumns(hasSelectColumn, selectColumn, userColumns);
|
|
569
|
-
}, [hasSelectColumn, userColumns, renderSelectCellProp, renderSelectHeaderCellProp]);
|
|
570
|
-
|
|
571
|
-
const columnCount = finalColumns.length;
|
|
572
|
-
|
|
573
|
-
const finalColumnsRef = useRef(finalColumns);
|
|
574
|
-
finalColumnsRef.current = finalColumns;
|
|
575
|
-
|
|
576
|
-
const columnCountRef = useRef(columnCount);
|
|
577
|
-
columnCountRef.current = columnCount;
|
|
578
|
-
|
|
579
|
-
// Check if any user column has a summary cell defined (for footer rendering)
|
|
580
|
-
const hasAnySummaryCell = useMemo(
|
|
581
|
-
() => userColumns.some((col) => col.renderSummaryCell !== undefined),
|
|
582
|
-
[userColumns],
|
|
583
|
-
);
|
|
584
|
-
|
|
585
|
-
// Compute selection bounds as primitives for efficient prop comparison
|
|
586
|
-
const selectionBounds = useMemo((): SelectionBounds | null => {
|
|
587
|
-
if (!selection) return null;
|
|
588
|
-
|
|
589
|
-
const anchorRowIndex = rowKeyToIndex.get(selection.anchor.rowKey);
|
|
590
|
-
const focusRowIndex = rowKeyToIndex.get(selection.focus.rowKey);
|
|
591
|
-
|
|
592
|
-
if (anchorRowIndex === undefined || focusRowIndex === undefined) {
|
|
593
|
-
return null;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
return {
|
|
597
|
-
minRowIndex: Math.min(anchorRowIndex, focusRowIndex),
|
|
598
|
-
maxRowIndex: Math.max(anchorRowIndex, focusRowIndex),
|
|
599
|
-
minColumn: Math.min(selection.anchor.column, selection.focus.column),
|
|
600
|
-
maxColumn: Math.max(selection.anchor.column, selection.focus.column),
|
|
601
|
-
activeRowIndex: focusRowIndex,
|
|
602
|
-
activeColumn: selection.focus.column,
|
|
603
|
-
};
|
|
604
|
-
}, [selection, rowKeyToIndex]);
|
|
605
|
-
|
|
606
|
-
// Notify parent when cell selection changes
|
|
607
|
-
useEffect(() => {
|
|
608
|
-
const callback = onCellSelectionChangeRef.current;
|
|
609
|
-
if (!callback) return;
|
|
610
|
-
|
|
611
|
-
if (!selectionBounds) {
|
|
612
|
-
callback(null);
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const { minRowIndex, maxRowIndex, minColumn, maxColumn } = selectionBounds;
|
|
617
|
-
const cells: SelectedCellData[] = [];
|
|
618
|
-
|
|
619
|
-
for (let rowIdx = minRowIndex; rowIdx <= maxRowIndex; rowIdx++) {
|
|
620
|
-
const rowKey = orderedRowKeysRef.current[rowIdx];
|
|
621
|
-
const row = rowDataMapRef.current.get(rowKey);
|
|
622
|
-
if (!row) continue;
|
|
623
|
-
|
|
624
|
-
const rowId = rowIdGetter(row);
|
|
625
|
-
|
|
626
|
-
for (let colIdx = minColumn; colIdx <= maxColumn; colIdx++) {
|
|
627
|
-
const column = finalColumnsRef.current[colIdx];
|
|
628
|
-
if (!column || column.key === "select") continue;
|
|
629
|
-
|
|
630
|
-
cells.push({
|
|
631
|
-
rowId,
|
|
632
|
-
columnKey: column.key,
|
|
633
|
-
value: row[column.key as keyof typeof row],
|
|
634
|
-
});
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
callback(cells.length > 0 ? cells : null);
|
|
639
|
-
}, [selectionBounds, rowIdGetter]);
|
|
640
|
-
|
|
641
|
-
// Clear selection when rows change if selected rows no longer exist
|
|
642
|
-
useEffect(() => {
|
|
643
|
-
setSelection((prev) => {
|
|
644
|
-
if (!prev) return null;
|
|
645
|
-
const anchorExists = rowKeyToIndex.has(prev.anchor.rowKey);
|
|
646
|
-
const focusExists = rowKeyToIndex.has(prev.focus.rowKey);
|
|
647
|
-
if (anchorExists && focusExists) return prev;
|
|
648
|
-
return null;
|
|
649
|
-
});
|
|
650
|
-
}, [rowKeyToIndex]);
|
|
651
|
-
|
|
652
|
-
const handleCellCommit = useCallback(
|
|
653
|
-
(row: TRow, columnKey: string, newValue: unknown) => {
|
|
654
|
-
if (!onRowsChangeRef.current) return;
|
|
655
|
-
|
|
656
|
-
const rowIndex = rowIdToDataIndexRef.current.get(rowIdGetter(row));
|
|
657
|
-
if (rowIndex === undefined) return;
|
|
658
|
-
|
|
659
|
-
const updatedRow = { ...row, [columnKey]: newValue } as TRow;
|
|
660
|
-
const allRows = Array.from(rowDataMapRef.current.values());
|
|
661
|
-
const newRows = [...allRows];
|
|
662
|
-
newRows[rowIndex] = updatedRow;
|
|
663
|
-
onRowsChangeRef.current(newRows, { indexes: [rowIndex] });
|
|
664
|
-
},
|
|
665
|
-
[rowIdGetter],
|
|
666
|
-
);
|
|
667
|
-
|
|
668
|
-
// Ref for selection to allow stable handlers
|
|
669
|
-
const selectionRef = useRef(selection);
|
|
670
|
-
selectionRef.current = selection;
|
|
671
|
-
|
|
672
|
-
const editingCellRef = useRef(editingCell);
|
|
673
|
-
editingCellRef.current = editingCell;
|
|
674
|
-
|
|
675
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
676
|
-
|
|
677
|
-
// Focus the grid container (keyboard events flow to container, not individual cells)
|
|
678
|
-
const focusContainer = useCallback(() => {
|
|
679
|
-
containerRef.current?.focus({ preventScroll: true });
|
|
680
|
-
}, []);
|
|
681
|
-
|
|
682
|
-
// Start new selection (mousedown without shift)
|
|
683
|
-
const handleSelectStart = useCallback(
|
|
684
|
-
(rowKey: RowPathKey, column: number) => {
|
|
685
|
-
// Skip non-selectable columns
|
|
686
|
-
if (column < firstSelectableColumn) return;
|
|
687
|
-
|
|
688
|
-
setSelection({
|
|
689
|
-
anchor: { rowKey, column },
|
|
690
|
-
focus: { rowKey, column },
|
|
691
|
-
});
|
|
692
|
-
focusContainer();
|
|
693
|
-
},
|
|
694
|
-
[focusContainer, firstSelectableColumn],
|
|
695
|
-
);
|
|
696
|
-
|
|
697
|
-
// Extend selection (shift+click or mouse drag)
|
|
698
|
-
const handleSelectUpdate = useCallback(
|
|
699
|
-
(rowKey: RowPathKey, column: number) => {
|
|
700
|
-
// Skip non-selectable columns
|
|
701
|
-
if (column < firstSelectableColumn) return;
|
|
702
|
-
|
|
703
|
-
const currentSelection = selectionRef.current;
|
|
704
|
-
if (!currentSelection) return;
|
|
705
|
-
|
|
706
|
-
// Extend from anchor to new cell
|
|
707
|
-
setSelection({
|
|
708
|
-
anchor: currentSelection.anchor,
|
|
709
|
-
focus: { rowKey, column },
|
|
710
|
-
});
|
|
711
|
-
},
|
|
712
|
-
[firstSelectableColumn],
|
|
713
|
-
);
|
|
714
|
-
|
|
715
|
-
const handleStartEditing = useCallback((rowKey: RowPathKey, column: number) => {
|
|
716
|
-
setEditingCell({ rowKey, column });
|
|
717
|
-
}, []);
|
|
718
|
-
|
|
719
|
-
// Stable callback for navigation - uses refs to read current values
|
|
720
|
-
const handleNavigate = useCallback(
|
|
721
|
-
(direction: NavigationDirection, extend: boolean) => {
|
|
722
|
-
const currentSelection = selectionRef.current;
|
|
723
|
-
const currentOrderedRowKeys = orderedRowKeysRef.current;
|
|
724
|
-
const currentRowKeyToIndex = rowKeyToIndexRef.current;
|
|
725
|
-
const currentColumnCount = columnCountRef.current;
|
|
726
|
-
|
|
727
|
-
// Calculate delta based on direction
|
|
728
|
-
let deltaRow = 0;
|
|
729
|
-
let deltaColumn = 0;
|
|
730
|
-
switch (direction) {
|
|
731
|
-
case "up":
|
|
732
|
-
deltaRow = -1;
|
|
733
|
-
break;
|
|
734
|
-
case "down":
|
|
735
|
-
deltaRow = 1;
|
|
736
|
-
break;
|
|
737
|
-
case "left":
|
|
738
|
-
deltaColumn = -1;
|
|
739
|
-
break;
|
|
740
|
-
case "right":
|
|
741
|
-
deltaColumn = 1;
|
|
742
|
-
break;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
if (!currentSelection) {
|
|
746
|
-
// No selection - start at first cell
|
|
747
|
-
if (currentOrderedRowKeys.length > 0) {
|
|
748
|
-
const firstRowKey = currentOrderedRowKeys[0];
|
|
749
|
-
setSelection({
|
|
750
|
-
anchor: { rowKey: firstRowKey, column: firstSelectableColumn },
|
|
751
|
-
focus: { rowKey: firstRowKey, column: firstSelectableColumn },
|
|
752
|
-
});
|
|
753
|
-
gridRef.current?.scrollToCell({
|
|
754
|
-
rowPath: keyToRowPath(firstRowKey),
|
|
755
|
-
column: firstSelectableColumn,
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
const currentRowIndex = currentRowKeyToIndex.get(currentSelection.focus.rowKey);
|
|
762
|
-
if (currentRowIndex === undefined) return;
|
|
763
|
-
|
|
764
|
-
const newRowIndex = Math.max(
|
|
765
|
-
0,
|
|
766
|
-
Math.min(currentOrderedRowKeys.length - 1, currentRowIndex + deltaRow),
|
|
767
|
-
);
|
|
768
|
-
const newColumn = Math.max(
|
|
769
|
-
firstSelectableColumn,
|
|
770
|
-
Math.min(currentColumnCount - 1, currentSelection.focus.column + deltaColumn),
|
|
771
|
-
);
|
|
772
|
-
|
|
773
|
-
const newRowKey = currentOrderedRowKeys[newRowIndex];
|
|
774
|
-
if (!newRowKey) return;
|
|
775
|
-
|
|
776
|
-
if (extend) {
|
|
777
|
-
setSelection({
|
|
778
|
-
anchor: currentSelection.anchor,
|
|
779
|
-
focus: { rowKey: newRowKey, column: newColumn },
|
|
780
|
-
});
|
|
781
|
-
} else {
|
|
782
|
-
setSelection({
|
|
783
|
-
anchor: { rowKey: newRowKey, column: newColumn },
|
|
784
|
-
focus: { rowKey: newRowKey, column: newColumn },
|
|
785
|
-
});
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
gridRef.current?.scrollToCell({
|
|
789
|
-
rowPath: keyToRowPath(newRowKey),
|
|
790
|
-
column: newColumn,
|
|
791
|
-
});
|
|
792
|
-
},
|
|
793
|
-
[firstSelectableColumn],
|
|
794
|
-
);
|
|
795
|
-
|
|
796
|
-
// Shared helper to clear editable cells within a range
|
|
797
|
-
const clearCellsInRange = useCallback(
|
|
798
|
-
(bounds: {
|
|
799
|
-
minRowIndex: number;
|
|
800
|
-
maxRowIndex: number;
|
|
801
|
-
minColumn: number;
|
|
802
|
-
maxColumn: number;
|
|
803
|
-
}) => {
|
|
804
|
-
const currentOrderedRowKeys = orderedRowKeysRef.current;
|
|
805
|
-
const currentRowDataMap = rowDataMapRef.current;
|
|
806
|
-
const currentColumns = finalColumnsRef.current;
|
|
807
|
-
const currentOnRowsChange = onRowsChangeRef.current;
|
|
808
|
-
const currentRowIdToDataIndex = rowIdToDataIndexRef.current;
|
|
809
|
-
|
|
810
|
-
if (!currentOnRowsChange) return;
|
|
811
|
-
|
|
812
|
-
const allRows = Array.from(currentRowDataMap.values());
|
|
813
|
-
const updatedRows = [...allRows];
|
|
814
|
-
const changedIndexes: number[] = [];
|
|
815
|
-
|
|
816
|
-
for (let rowIdx = bounds.minRowIndex; rowIdx <= bounds.maxRowIndex; rowIdx++) {
|
|
817
|
-
const rowKey = currentOrderedRowKeys[rowIdx];
|
|
818
|
-
const row = currentRowDataMap.get(rowKey);
|
|
819
|
-
if (!row) continue;
|
|
820
|
-
|
|
821
|
-
const rowIndex = currentRowIdToDataIndex.get(rowIdGetter(row));
|
|
822
|
-
if (rowIndex === undefined) continue;
|
|
823
|
-
|
|
824
|
-
let rowModified = false;
|
|
825
|
-
let updatedRow = { ...row } as TRow;
|
|
826
|
-
|
|
827
|
-
for (let colIdx = bounds.minColumn; colIdx <= bounds.maxColumn; colIdx++) {
|
|
828
|
-
const column = currentColumns[colIdx];
|
|
829
|
-
if (!column) continue;
|
|
830
|
-
|
|
831
|
-
const editable = column.isCellEditable?.(row, column.key) ?? true;
|
|
832
|
-
if (!editable) continue;
|
|
833
|
-
|
|
834
|
-
updatedRow = { ...updatedRow, [column.key]: null };
|
|
835
|
-
rowModified = true;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
if (rowModified) {
|
|
839
|
-
updatedRows[rowIndex] = updatedRow;
|
|
840
|
-
changedIndexes.push(rowIndex);
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
if (changedIndexes.length > 0) {
|
|
845
|
-
currentOnRowsChange(updatedRows, { indexes: changedIndexes });
|
|
846
|
-
}
|
|
847
|
-
},
|
|
848
|
-
[rowIdGetter],
|
|
849
|
-
);
|
|
850
|
-
|
|
851
|
-
// Clear selected cells (Delete key)
|
|
852
|
-
const handleClearCell = useCallback(() => {
|
|
853
|
-
const currentSelection = selectionRef.current;
|
|
854
|
-
if (!currentSelection) return;
|
|
855
|
-
|
|
856
|
-
const currentRowKeyToIndex = rowKeyToIndexRef.current;
|
|
857
|
-
const anchorRowIndex = currentRowKeyToIndex.get(currentSelection.anchor.rowKey);
|
|
858
|
-
const focusRowIndex = currentRowKeyToIndex.get(currentSelection.focus.rowKey);
|
|
859
|
-
if (anchorRowIndex === undefined || focusRowIndex === undefined) return;
|
|
860
|
-
|
|
861
|
-
clearCellsInRange({
|
|
862
|
-
minRowIndex: Math.min(anchorRowIndex, focusRowIndex),
|
|
863
|
-
maxRowIndex: Math.max(anchorRowIndex, focusRowIndex),
|
|
864
|
-
minColumn: Math.min(currentSelection.anchor.column, currentSelection.focus.column),
|
|
865
|
-
maxColumn: Math.max(currentSelection.anchor.column, currentSelection.focus.column),
|
|
866
|
-
});
|
|
867
|
-
}, [clearCellsInRange]);
|
|
868
|
-
|
|
869
|
-
// Stable callback for canceling editing (keeps cell selected, restores focus)
|
|
870
|
-
const handleCancelEditing = useCallback(() => {
|
|
871
|
-
setEditingCell(null);
|
|
872
|
-
focusContainer();
|
|
873
|
-
}, [focusContainer]);
|
|
874
|
-
|
|
875
|
-
// Stable callback for select all
|
|
876
|
-
const handleSelectAll = useCallback(() => {
|
|
877
|
-
const currentOrderedRowKeys = orderedRowKeysRef.current;
|
|
878
|
-
const currentColumnCount = columnCountRef.current;
|
|
879
|
-
|
|
880
|
-
if (currentOrderedRowKeys.length === 0) return;
|
|
881
|
-
|
|
882
|
-
setSelection({
|
|
883
|
-
anchor: {
|
|
884
|
-
rowKey: currentOrderedRowKeys[0],
|
|
885
|
-
column: firstSelectableColumn,
|
|
886
|
-
},
|
|
887
|
-
focus: {
|
|
888
|
-
rowKey: currentOrderedRowKeys[currentOrderedRowKeys.length - 1],
|
|
889
|
-
column: currentColumnCount - 1,
|
|
890
|
-
},
|
|
891
|
-
});
|
|
892
|
-
focusContainer();
|
|
893
|
-
}, [focusContainer, firstSelectableColumn]);
|
|
894
|
-
|
|
895
|
-
// Stable callback for selecting all cells in a column
|
|
896
|
-
const handleSelectColumn = useCallback(
|
|
897
|
-
(columnIndex: number) => {
|
|
898
|
-
const currentOrderedRowKeys = orderedRowKeysRef.current;
|
|
899
|
-
|
|
900
|
-
if (currentOrderedRowKeys.length === 0) return;
|
|
901
|
-
if (columnIndex < firstSelectableColumn) return;
|
|
902
|
-
|
|
903
|
-
setSelection({
|
|
904
|
-
anchor: {
|
|
905
|
-
rowKey: currentOrderedRowKeys[0],
|
|
906
|
-
column: columnIndex,
|
|
907
|
-
},
|
|
908
|
-
focus: {
|
|
909
|
-
rowKey: currentOrderedRowKeys[currentOrderedRowKeys.length - 1],
|
|
910
|
-
column: columnIndex,
|
|
911
|
-
},
|
|
912
|
-
});
|
|
913
|
-
|
|
914
|
-
// Scroll to make the column visible
|
|
915
|
-
gridRef.current?.scrollToCell({
|
|
916
|
-
rowPath: keyToRowPath(currentOrderedRowKeys[0]),
|
|
917
|
-
column: columnIndex,
|
|
918
|
-
});
|
|
919
|
-
focusContainer();
|
|
920
|
-
},
|
|
921
|
-
[focusContainer, firstSelectableColumn],
|
|
922
|
-
);
|
|
923
|
-
|
|
924
|
-
// Fill handle: start dragging from active cell
|
|
925
|
-
const handleFillStart = useCallback((sourceRowIndex: number, column: number) => {
|
|
926
|
-
setFillDrag({
|
|
927
|
-
sourceRowIndex,
|
|
928
|
-
targetRowIndex: sourceRowIndex,
|
|
929
|
-
column,
|
|
930
|
-
});
|
|
931
|
-
}, []);
|
|
932
|
-
|
|
933
|
-
// Fill handle: update target row during drag
|
|
934
|
-
const handleFillUpdate = useCallback((targetRowIndex: number) => {
|
|
935
|
-
setFillDrag((prev) => {
|
|
936
|
-
if (!prev) return null;
|
|
937
|
-
if (prev.targetRowIndex === targetRowIndex) return prev;
|
|
938
|
-
return { ...prev, targetRowIndex };
|
|
939
|
-
});
|
|
940
|
-
}, []);
|
|
941
|
-
|
|
942
|
-
// Fill handle: complete the fill operation
|
|
943
|
-
const handleFillComplete = useCallback(() => {
|
|
944
|
-
const currentFillDrag = fillDragRef.current;
|
|
945
|
-
if (!currentFillDrag) return;
|
|
946
|
-
|
|
947
|
-
const { sourceRowIndex, targetRowIndex, column } = currentFillDrag;
|
|
948
|
-
const currentOrderedRowKeys = orderedRowKeysRef.current;
|
|
949
|
-
const currentRowDataMap = rowDataMapRef.current;
|
|
950
|
-
const currentColumns = finalColumnsRef.current;
|
|
951
|
-
const currentOnFill = onFillRef.current;
|
|
952
|
-
const currentOnRowsChange = onRowsChangeRef.current;
|
|
953
|
-
const currentRowIdToDataIndex = rowIdToDataIndexRef.current;
|
|
954
|
-
|
|
955
|
-
// Clear fill state
|
|
956
|
-
setFillDrag(null);
|
|
957
|
-
|
|
958
|
-
// No-op if same row or no fill callback
|
|
959
|
-
if (sourceRowIndex === targetRowIndex || !currentOnFill) return;
|
|
960
|
-
|
|
961
|
-
const columnDef = currentColumns[column];
|
|
962
|
-
if (!columnDef) return;
|
|
963
|
-
|
|
964
|
-
const sourceRowKey = currentOrderedRowKeys[sourceRowIndex];
|
|
965
|
-
const sourceRow = currentRowDataMap.get(sourceRowKey);
|
|
966
|
-
if (!sourceRow) return;
|
|
967
|
-
|
|
968
|
-
// Determine fill direction and range
|
|
969
|
-
const minRow = Math.min(sourceRowIndex, targetRowIndex);
|
|
970
|
-
const maxRow = Math.max(sourceRowIndex, targetRowIndex);
|
|
971
|
-
|
|
972
|
-
// Collect all updated rows
|
|
973
|
-
const allRows = Array.from(currentRowDataMap.values());
|
|
974
|
-
const updatedRows = [...allRows];
|
|
975
|
-
const changedIndexes: number[] = [];
|
|
976
|
-
|
|
977
|
-
for (let i = minRow; i <= maxRow; i++) {
|
|
978
|
-
// Skip source row
|
|
979
|
-
if (i === sourceRowIndex) continue;
|
|
980
|
-
|
|
981
|
-
const rowKey = currentOrderedRowKeys[i];
|
|
982
|
-
const targetRow = currentRowDataMap.get(rowKey);
|
|
983
|
-
if (!targetRow) continue;
|
|
984
|
-
|
|
985
|
-
const rowIndex = currentRowIdToDataIndex.get(rowIdGetter(targetRow));
|
|
986
|
-
if (rowIndex === undefined) continue;
|
|
987
|
-
|
|
988
|
-
// Apply fill callback
|
|
989
|
-
const filledRow = currentOnFill({
|
|
990
|
-
columnKey: columnDef.key,
|
|
991
|
-
sourceRow,
|
|
992
|
-
targetRow,
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
// Only record change if row actually changed
|
|
996
|
-
if (filledRow !== targetRow) {
|
|
997
|
-
updatedRows[rowIndex] = filledRow;
|
|
998
|
-
changedIndexes.push(rowIndex);
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
// Call onRowsChange with all updates at once
|
|
1003
|
-
if (changedIndexes.length > 0 && currentOnRowsChange) {
|
|
1004
|
-
currentOnRowsChange(updatedRows, { indexes: changedIndexes });
|
|
1005
|
-
}
|
|
1006
|
-
}, [rowIdGetter]);
|
|
1007
|
-
|
|
1008
|
-
// Global listeners for fill drag - managed at DataGrid level
|
|
1009
|
-
useEffect(() => {
|
|
1010
|
-
if (!fillDrag) return;
|
|
1011
|
-
|
|
1012
|
-
const handleMouseMove = (e: MouseEvent) => {
|
|
1013
|
-
// Find the cell under cursor using data-row-index attribute
|
|
1014
|
-
const target = document.elementFromPoint(e.clientX, e.clientY);
|
|
1015
|
-
if (!target) return;
|
|
1016
|
-
|
|
1017
|
-
const cell = target.closest("[data-row-index]");
|
|
1018
|
-
if (cell) {
|
|
1019
|
-
const targetRowIndex = parseInt(cell.getAttribute("data-row-index") || "", 10);
|
|
1020
|
-
if (!isNaN(targetRowIndex)) {
|
|
1021
|
-
handleFillUpdate(targetRowIndex);
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
};
|
|
1025
|
-
|
|
1026
|
-
const handleMouseUp = () => {
|
|
1027
|
-
handleFillComplete();
|
|
1028
|
-
};
|
|
1029
|
-
|
|
1030
|
-
document.addEventListener("mousemove", handleMouseMove);
|
|
1031
|
-
document.addEventListener("mouseup", handleMouseUp);
|
|
1032
|
-
|
|
1033
|
-
return () => {
|
|
1034
|
-
document.removeEventListener("mousemove", handleMouseMove);
|
|
1035
|
-
document.removeEventListener("mouseup", handleMouseUp);
|
|
1036
|
-
};
|
|
1037
|
-
}, [fillDrag, handleFillUpdate, handleFillComplete]);
|
|
1038
|
-
|
|
1039
|
-
// Copy selected cells to clipboard in TSV format (Excel-compatible)
|
|
1040
|
-
// Returns the selection bounds for reuse (e.g., in cut operation)
|
|
1041
|
-
const copySelectionToClipboard = useCallback(() => {
|
|
1042
|
-
const currentSelection = selectionRef.current;
|
|
1043
|
-
if (!currentSelection) return;
|
|
1044
|
-
|
|
1045
|
-
const currentOrderedRowKeys = orderedRowKeysRef.current;
|
|
1046
|
-
const currentRowKeyToIndex = rowKeyToIndexRef.current;
|
|
1047
|
-
const currentRowDataMap = rowDataMapRef.current;
|
|
1048
|
-
const currentColumns = finalColumnsRef.current;
|
|
1049
|
-
const currentOnCellCopy = onCellCopyRef.current;
|
|
1050
|
-
|
|
1051
|
-
// Get selection bounds
|
|
1052
|
-
const anchorRowIndex = currentRowKeyToIndex.get(currentSelection.anchor.rowKey);
|
|
1053
|
-
const focusRowIndex = currentRowKeyToIndex.get(currentSelection.focus.rowKey);
|
|
1054
|
-
|
|
1055
|
-
if (anchorRowIndex === undefined || focusRowIndex === undefined) return;
|
|
1056
|
-
|
|
1057
|
-
const minRowIndex = Math.min(anchorRowIndex, focusRowIndex);
|
|
1058
|
-
const maxRowIndex = Math.max(anchorRowIndex, focusRowIndex);
|
|
1059
|
-
const minColumn = Math.min(currentSelection.anchor.column, currentSelection.focus.column);
|
|
1060
|
-
const maxColumn = Math.max(currentSelection.anchor.column, currentSelection.focus.column);
|
|
1061
|
-
|
|
1062
|
-
// Build TSV string (tab-separated columns, newline-separated rows)
|
|
1063
|
-
const rows: string[] = [];
|
|
1064
|
-
|
|
1065
|
-
for (let rowIdx = minRowIndex; rowIdx <= maxRowIndex; rowIdx++) {
|
|
1066
|
-
const rowKey = currentOrderedRowKeys[rowIdx];
|
|
1067
|
-
const row = currentRowDataMap.get(rowKey);
|
|
1068
|
-
if (!row) continue;
|
|
1069
|
-
|
|
1070
|
-
const cells: string[] = [];
|
|
1071
|
-
for (let colIdx = minColumn; colIdx <= maxColumn; colIdx++) {
|
|
1072
|
-
const column = currentColumns[colIdx];
|
|
1073
|
-
if (!column) continue;
|
|
1074
|
-
|
|
1075
|
-
// Format the value
|
|
1076
|
-
let cellText: string;
|
|
1077
|
-
if (currentOnCellCopy) {
|
|
1078
|
-
cellText = currentOnCellCopy({
|
|
1079
|
-
row,
|
|
1080
|
-
columnKey: column.key,
|
|
1081
|
-
});
|
|
1082
|
-
} else {
|
|
1083
|
-
// Default formatting
|
|
1084
|
-
cellText = formatValueForClipboard(row[column.key]);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
cells.push(cellText);
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
rows.push(cells.join("\t"));
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
const tsvData = rows.join("\n");
|
|
1094
|
-
|
|
1095
|
-
// Write to clipboard. `writeText` rejects when the Clipboard API is
|
|
1096
|
-
// blocked (e.g. a sandboxed iframe without clipboard-write permission, or
|
|
1097
|
-
// a non-secure context) — catch it so the failure surfaces as a logged
|
|
1098
|
-
// warning instead of an unhandled promise rejection.
|
|
1099
|
-
navigator.clipboard.writeText(tsvData).catch((err: unknown) => {
|
|
1100
|
-
console.warn("Grid copy to clipboard failed", err);
|
|
1101
|
-
});
|
|
1102
|
-
|
|
1103
|
-
return { minRowIndex, maxRowIndex, minColumn, maxColumn };
|
|
1104
|
-
}, []);
|
|
1105
|
-
|
|
1106
|
-
// Handle copy action (Ctrl/Cmd+C)
|
|
1107
|
-
const handleCopySelection = useCallback(() => {
|
|
1108
|
-
copySelectionToClipboard();
|
|
1109
|
-
}, [copySelectionToClipboard]);
|
|
1110
|
-
|
|
1111
|
-
// Handle cut action (Ctrl/Cmd+X) - copy then clear selected cells
|
|
1112
|
-
const handleCutSelection = useCallback(() => {
|
|
1113
|
-
const bounds = copySelectionToClipboard();
|
|
1114
|
-
if (!bounds) return;
|
|
1115
|
-
clearCellsInRange(bounds);
|
|
1116
|
-
}, [copySelectionToClipboard, clearCellsInRange]);
|
|
1117
|
-
|
|
1118
|
-
// Paste into the active cell from clipboard data (provided by native paste event)
|
|
1119
|
-
const handlePasteSelection = useCallback(
|
|
1120
|
-
(data: { text: string; files: File[] }) => {
|
|
1121
|
-
const currentSelection = selectionRef.current;
|
|
1122
|
-
if (!currentSelection) return;
|
|
1123
|
-
|
|
1124
|
-
const currentRowKeyToIndex = rowKeyToIndexRef.current;
|
|
1125
|
-
const currentOrderedRowKeys = orderedRowKeysRef.current;
|
|
1126
|
-
const currentRowDataMap = rowDataMapRef.current;
|
|
1127
|
-
const currentColumns = finalColumnsRef.current;
|
|
1128
|
-
const currentOnCellPaste = onCellPasteRef.current;
|
|
1129
|
-
const currentOnFilePaste = onFilePasteRef.current;
|
|
1130
|
-
const currentIsPasteableFile = isPasteableFileRef.current;
|
|
1131
|
-
const currentOnRowsChange = onRowsChangeRef.current;
|
|
1132
|
-
|
|
1133
|
-
// For now, only paste into single active cell
|
|
1134
|
-
const activeRowIndex = currentRowKeyToIndex.get(currentSelection.focus.rowKey);
|
|
1135
|
-
if (activeRowIndex === undefined) return;
|
|
1136
|
-
|
|
1137
|
-
const activeColumn = currentSelection.focus.column;
|
|
1138
|
-
const column = currentColumns[activeColumn];
|
|
1139
|
-
if (!column) return;
|
|
1140
|
-
|
|
1141
|
-
const rowKey = currentOrderedRowKeys[activeRowIndex];
|
|
1142
|
-
const row = currentRowDataMap.get(rowKey);
|
|
1143
|
-
if (!row) return;
|
|
1144
|
-
|
|
1145
|
-
// Check if cell is editable
|
|
1146
|
-
const editable = column.isCellEditable?.(row, column.key) ?? true;
|
|
1147
|
-
if (!editable) return;
|
|
1148
|
-
|
|
1149
|
-
// Handle pasted files (if onFilePaste is provided). `isPasteableFile`
|
|
1150
|
-
// narrows the set — records page restricts to image MIME types via
|
|
1151
|
-
// `isImageMimeType`; default accepts everything.
|
|
1152
|
-
if (currentOnFilePaste && data.files.length > 0) {
|
|
1153
|
-
const accept = currentIsPasteableFile ?? (() => true);
|
|
1154
|
-
const acceptedFiles = Array.from(data.files).filter(accept);
|
|
1155
|
-
if (acceptedFiles.length > 0) {
|
|
1156
|
-
currentOnFilePaste({
|
|
1157
|
-
row,
|
|
1158
|
-
columnKey: column.key,
|
|
1159
|
-
files: acceptedFiles,
|
|
1160
|
-
});
|
|
1161
|
-
return;
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
// Handle text paste
|
|
1166
|
-
const pastedText = data.text;
|
|
1167
|
-
if (!pastedText) return;
|
|
1168
|
-
|
|
1169
|
-
// Parse the value
|
|
1170
|
-
let newValue: unknown;
|
|
1171
|
-
if (currentOnCellPaste) {
|
|
1172
|
-
newValue = currentOnCellPaste({
|
|
1173
|
-
row,
|
|
1174
|
-
columnKey: column.key,
|
|
1175
|
-
pastedText,
|
|
1176
|
-
});
|
|
1177
|
-
// If callback returns undefined, skip the paste
|
|
1178
|
-
if (newValue === undefined) return;
|
|
1179
|
-
} else {
|
|
1180
|
-
// Default: use raw string
|
|
1181
|
-
newValue = pastedText;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
// Update the cell
|
|
1185
|
-
if (!currentOnRowsChange) return;
|
|
1186
|
-
|
|
1187
|
-
const rowIndex = rowIdToDataIndexRef.current.get(rowIdGetter(row));
|
|
1188
|
-
if (rowIndex === undefined) return;
|
|
1189
|
-
|
|
1190
|
-
const updatedRow = { ...row, [column.key]: newValue } as TRow;
|
|
1191
|
-
const allRows = Array.from(currentRowDataMap.values());
|
|
1192
|
-
const newRows = [...allRows];
|
|
1193
|
-
newRows[rowIndex] = updatedRow;
|
|
1194
|
-
currentOnRowsChange(newRows, { indexes: [rowIndex] });
|
|
1195
|
-
},
|
|
1196
|
-
[rowIdGetter],
|
|
1197
|
-
);
|
|
1198
|
-
|
|
1199
|
-
// Stable commit handler for RowCell (avoids inline closure in renderRowCell)
|
|
1200
|
-
// Uses the provided rowKey to identify the correct row, rather than reading
|
|
1201
|
-
// selectionRef which may have already moved (e.g. click-to-blur on another cell).
|
|
1202
|
-
const handleCommit = useCallback(
|
|
1203
|
-
(nextValue: unknown, columnKey: string, rowKey: RowPathKey) => {
|
|
1204
|
-
const row = rowDataMapRef.current.get(rowKey);
|
|
1205
|
-
if (!row) return;
|
|
1206
|
-
handleCellCommit(row, columnKey, nextValue);
|
|
1207
|
-
setEditingCell(null);
|
|
1208
|
-
focusContainer();
|
|
1209
|
-
},
|
|
1210
|
-
[handleCellCommit, focusContainer],
|
|
1211
|
-
);
|
|
1212
|
-
|
|
1213
|
-
// Check if the active cell can be edited
|
|
1214
|
-
const canEditActiveCell = useCallback((): boolean => {
|
|
1215
|
-
const sel = selectionRef.current;
|
|
1216
|
-
if (!sel) return false;
|
|
1217
|
-
const column = finalColumnsRef.current[sel.focus.column];
|
|
1218
|
-
if (!column?.renderEditCell) return false;
|
|
1219
|
-
const row = rowDataMapRef.current.get(sel.focus.rowKey);
|
|
1220
|
-
if (!row) return false;
|
|
1221
|
-
return column.isCellEditable?.(row, column.key) ?? true;
|
|
1222
|
-
}, []);
|
|
1223
|
-
|
|
1224
|
-
// Activate an inline-editable cell (e.g., toggle boolean) without opening an editor
|
|
1225
|
-
const handleActivateCell = useCallback(() => {
|
|
1226
|
-
const sel = selectionRef.current;
|
|
1227
|
-
if (!sel) return;
|
|
1228
|
-
const column = finalColumnsRef.current[sel.focus.column];
|
|
1229
|
-
if (!column?.onActivate) return;
|
|
1230
|
-
const row = rowDataMapRef.current.get(sel.focus.rowKey);
|
|
1231
|
-
if (!row) return;
|
|
1232
|
-
const editable = column.isCellEditable?.(row, column.key) ?? true;
|
|
1233
|
-
if (!editable) return;
|
|
1234
|
-
const nextValue = column.onActivate(row, column.key);
|
|
1235
|
-
handleCellCommit(row, column.key, nextValue);
|
|
1236
|
-
}, [handleCellCommit]);
|
|
1237
|
-
|
|
1238
|
-
// Container-level keyboard handler (replaces per-cell handleKeyDown)
|
|
1239
|
-
const handleContainerKeyDown = useCallback(
|
|
1240
|
-
(event: React.KeyboardEvent) => {
|
|
1241
|
-
// Don't intercept keys while editing — let editor handle them
|
|
1242
|
-
if (editingCellRef.current) return;
|
|
1243
|
-
|
|
1244
|
-
const sel = selectionRef.current;
|
|
1245
|
-
|
|
1246
|
-
// Escape: clear selection
|
|
1247
|
-
if (event.key === "Escape") {
|
|
1248
|
-
event.preventDefault();
|
|
1249
|
-
setSelection(null);
|
|
1250
|
-
return;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// Ctrl/Cmd+A: select all
|
|
1254
|
-
if ((event.ctrlKey || event.metaKey) && event.key === "a") {
|
|
1255
|
-
event.preventDefault();
|
|
1256
|
-
handleSelectAll();
|
|
1257
|
-
return;
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
// Ctrl/Cmd+C: copy
|
|
1261
|
-
if ((event.ctrlKey || event.metaKey) && event.key === "c") {
|
|
1262
|
-
event.preventDefault();
|
|
1263
|
-
handleCopySelection();
|
|
1264
|
-
return;
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
// Ctrl/Cmd+X: cut
|
|
1268
|
-
if ((event.ctrlKey || event.metaKey) && event.key === "x") {
|
|
1269
|
-
event.preventDefault();
|
|
1270
|
-
handleCutSelection();
|
|
1271
|
-
return;
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
// NOTE: Ctrl/Cmd+V paste is handled by the native onPaste event handler
|
|
1275
|
-
|
|
1276
|
-
// Enter / F2: start editing, or activate inline-editable cells (e.g., boolean toggle)
|
|
1277
|
-
if (event.key === "Enter" || event.key === "F2") {
|
|
1278
|
-
event.preventDefault();
|
|
1279
|
-
if (sel) {
|
|
1280
|
-
if (canEditActiveCell()) {
|
|
1281
|
-
handleStartEditing(sel.focus.rowKey, sel.focus.column);
|
|
1282
|
-
} else {
|
|
1283
|
-
handleActivateCell();
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
return;
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// Space: activate inline-editable cells (e.g., toggle checkbox)
|
|
1290
|
-
if (event.key === " ") {
|
|
1291
|
-
event.preventDefault();
|
|
1292
|
-
handleActivateCell();
|
|
1293
|
-
return;
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
// Tab / Shift+Tab: navigate
|
|
1297
|
-
if (event.key === "Tab") {
|
|
1298
|
-
event.preventDefault();
|
|
1299
|
-
handleNavigate(event.shiftKey ? "left" : "right", false);
|
|
1300
|
-
return;
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// Delete: clear cell
|
|
1304
|
-
if (event.key === "Delete") {
|
|
1305
|
-
event.preventDefault();
|
|
1306
|
-
handleClearCell();
|
|
1307
|
-
return;
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
// Backspace: start editing (clears then edits)
|
|
1311
|
-
if (event.key === "Backspace") {
|
|
1312
|
-
event.preventDefault();
|
|
1313
|
-
if (sel && canEditActiveCell()) {
|
|
1314
|
-
handleStartEditing(sel.focus.rowKey, sel.focus.column);
|
|
1315
|
-
}
|
|
1316
|
-
return;
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
// Printable character: start editing
|
|
1320
|
-
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
1321
|
-
if (sel && canEditActiveCell()) {
|
|
1322
|
-
handleStartEditing(sel.focus.rowKey, sel.focus.column);
|
|
1323
|
-
}
|
|
1324
|
-
return;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
// Arrow key navigation
|
|
1328
|
-
const direction = ARROW_KEY_TO_DIRECTION[event.key];
|
|
1329
|
-
if (direction) {
|
|
1330
|
-
event.preventDefault();
|
|
1331
|
-
handleNavigate(direction, event.shiftKey);
|
|
1332
|
-
}
|
|
1333
|
-
},
|
|
1334
|
-
[
|
|
1335
|
-
handleSelectAll,
|
|
1336
|
-
handleCopySelection,
|
|
1337
|
-
handleCutSelection,
|
|
1338
|
-
handleStartEditing,
|
|
1339
|
-
handleNavigate,
|
|
1340
|
-
handleClearCell,
|
|
1341
|
-
canEditActiveCell,
|
|
1342
|
-
handleActivateCell,
|
|
1343
|
-
],
|
|
1344
|
-
);
|
|
1345
|
-
|
|
1346
|
-
// Container-level paste handler (replaces per-cell handlePaste)
|
|
1347
|
-
const handleContainerPaste = useCallback(
|
|
1348
|
-
(event: React.ClipboardEvent) => {
|
|
1349
|
-
// Don't intercept paste while editing — let the editor handle it natively
|
|
1350
|
-
if (editingCellRef.current) return;
|
|
1351
|
-
|
|
1352
|
-
event.preventDefault();
|
|
1353
|
-
const text = event.clipboardData.getData("text/plain");
|
|
1354
|
-
const files = Array.from(event.clipboardData.files);
|
|
1355
|
-
handlePasteSelection({ text, files });
|
|
1356
|
-
},
|
|
1357
|
-
[handlePasteSelection],
|
|
1358
|
-
);
|
|
1359
|
-
|
|
1360
|
-
// Local state for column widths - allows immediate visual feedback during resize
|
|
1361
|
-
const [localColumnWidths, setLocalColumnWidths] = useState<Map<string, number>>(() => new Map());
|
|
1362
|
-
|
|
1363
|
-
// Compute column widths: use local override if present, otherwise from props
|
|
1364
|
-
const columnWidths = useMemo(() => {
|
|
1365
|
-
return finalColumns.map((col) => {
|
|
1366
|
-
const localWidth = localColumnWidths.get(col.key);
|
|
1367
|
-
return localWidth ?? col.width ?? 150;
|
|
1368
|
-
});
|
|
1369
|
-
}, [finalColumns, localColumnWidths]);
|
|
1370
|
-
|
|
1371
|
-
useImperativeHandle(ref, () => {
|
|
1372
|
-
return {
|
|
1373
|
-
selectCell: (params: { rowPathKey: RowPathKey; columnKey: string }) => {
|
|
1374
|
-
const { rowPathKey, columnKey } = params;
|
|
1375
|
-
|
|
1376
|
-
// Find column index
|
|
1377
|
-
const columnIndex = finalColumns.findIndex((col) => col.key === columnKey);
|
|
1378
|
-
if (columnIndex === -1) return;
|
|
1379
|
-
|
|
1380
|
-
// Set selection to the cell
|
|
1381
|
-
setSelection({
|
|
1382
|
-
anchor: { rowKey: rowPathKey, column: columnIndex },
|
|
1383
|
-
focus: { rowKey: rowPathKey, column: columnIndex },
|
|
1384
|
-
});
|
|
1385
|
-
|
|
1386
|
-
// Scroll to the cell
|
|
1387
|
-
gridRef.current?.scrollToCell({
|
|
1388
|
-
rowPath: keyToRowPath(rowPathKey),
|
|
1389
|
-
column: columnIndex,
|
|
1390
|
-
});
|
|
1391
|
-
focusContainer();
|
|
1392
|
-
},
|
|
1393
|
-
selectCells: (cells: SelectedCellData[]) => {
|
|
1394
|
-
if (cells.length === 0) {
|
|
1395
|
-
setSelection(null);
|
|
1396
|
-
return;
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
// Build rowId → rowKey reverse lookup
|
|
1400
|
-
const rowIdToKey = new Map<string, RowPathKey>();
|
|
1401
|
-
for (const [key, row] of rowDataMapRef.current.entries()) {
|
|
1402
|
-
rowIdToKey.set(rowIdGetter(row), key);
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
// Find bounding range
|
|
1406
|
-
let minRowIdx = Infinity;
|
|
1407
|
-
let maxRowIdx = -Infinity;
|
|
1408
|
-
let minCol = Infinity;
|
|
1409
|
-
let maxCol = -Infinity;
|
|
1410
|
-
|
|
1411
|
-
for (const cell of cells) {
|
|
1412
|
-
const rowKey = rowIdToKey.get(cell.rowId);
|
|
1413
|
-
if (!rowKey) continue;
|
|
1414
|
-
const rowIdx = rowKeyToIndexRef.current.get(rowKey);
|
|
1415
|
-
if (rowIdx === undefined) continue;
|
|
1416
|
-
const colIdx = finalColumns.findIndex((col) => col.key === cell.columnKey);
|
|
1417
|
-
if (colIdx === -1) continue;
|
|
1418
|
-
|
|
1419
|
-
minRowIdx = Math.min(minRowIdx, rowIdx);
|
|
1420
|
-
maxRowIdx = Math.max(maxRowIdx, rowIdx);
|
|
1421
|
-
minCol = Math.min(minCol, colIdx);
|
|
1422
|
-
maxCol = Math.max(maxCol, colIdx);
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
if (minRowIdx === Infinity) return;
|
|
1426
|
-
|
|
1427
|
-
const anchorRowKey = orderedRowKeysRef.current[minRowIdx];
|
|
1428
|
-
const focusRowKey = orderedRowKeysRef.current[maxRowIdx];
|
|
1429
|
-
if (!anchorRowKey || !focusRowKey) return;
|
|
1430
|
-
|
|
1431
|
-
setSelection({
|
|
1432
|
-
anchor: { rowKey: anchorRowKey, column: minCol },
|
|
1433
|
-
focus: { rowKey: focusRowKey, column: maxCol },
|
|
1434
|
-
});
|
|
1435
|
-
focusContainer();
|
|
1436
|
-
},
|
|
1437
|
-
clearSelection: () => {
|
|
1438
|
-
setSelection(null);
|
|
1439
|
-
},
|
|
1440
|
-
} as DataGridHandle;
|
|
1441
|
-
}, [finalColumns, focusContainer, rowIdGetter]);
|
|
1442
|
-
|
|
1443
|
-
// Declarative scroll target: when set, scroll to the row once the grid's
|
|
1444
|
-
// layout includes it. useLayoutEffect fires after Grid's useImperativeHandle
|
|
1445
|
-
// (child layout effects run first), so gridRef.current has the current layout.
|
|
1446
|
-
useLayoutEffect(() => {
|
|
1447
|
-
if (!scrollTarget || !gridRef.current) return;
|
|
1448
|
-
const { rowPathKey, columnKey, revealId } = scrollTarget;
|
|
1449
|
-
|
|
1450
|
-
// Wait until the row is in our data before scrolling
|
|
1451
|
-
if (!rowKeyToIndex.has(rowPathKey)) return;
|
|
1452
|
-
|
|
1453
|
-
const columnIndex = finalColumns.findIndex((col) => col.key === columnKey);
|
|
1454
|
-
if (columnIndex === -1) return;
|
|
1455
|
-
setSelection({
|
|
1456
|
-
anchor: { rowKey: rowPathKey, column: columnIndex },
|
|
1457
|
-
focus: { rowKey: rowPathKey, column: columnIndex },
|
|
1458
|
-
});
|
|
1459
|
-
gridRef.current.scrollToCell({
|
|
1460
|
-
rowPath: keyToRowPath(rowPathKey),
|
|
1461
|
-
column: columnIndex,
|
|
1462
|
-
});
|
|
1463
|
-
focusContainer();
|
|
1464
|
-
onScrollTargetReached?.(revealId);
|
|
1465
|
-
}, [scrollTarget, rowKeyToIndex, finalColumns, focusContainer, onScrollTargetReached]);
|
|
1466
|
-
|
|
1467
|
-
const isGroupCollapsed = useCallback(
|
|
1468
|
-
(groupPathKey: GroupPathKey) => {
|
|
1469
|
-
if (!collapsedGroups || collapsedGroups.length === 0) {
|
|
1470
|
-
return false;
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
return collapsedGroups.includes(groupPathKey);
|
|
1474
|
-
},
|
|
1475
|
-
[collapsedGroups],
|
|
1476
|
-
);
|
|
1477
|
-
|
|
1478
|
-
const toggleGroupCollapse = useCallback(
|
|
1479
|
-
(groupPathKey: GroupPathKey) => {
|
|
1480
|
-
if (!onCollapsedGroupsChange) {
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
const currentCollapsed = collapsedGroups ?? [];
|
|
1485
|
-
const isCollapsed = currentCollapsed.includes(groupPathKey);
|
|
1486
|
-
|
|
1487
|
-
if (isCollapsed) {
|
|
1488
|
-
onCollapsedGroupsChange(currentCollapsed.filter((key) => key !== groupPathKey));
|
|
1489
|
-
} else {
|
|
1490
|
-
onCollapsedGroupsChange([...currentCollapsed, groupPathKey]);
|
|
1491
|
-
}
|
|
1492
|
-
},
|
|
1493
|
-
[collapsedGroups, onCollapsedGroupsChange],
|
|
1494
|
-
);
|
|
1495
|
-
|
|
1496
|
-
const renderRowCell = useCallback(
|
|
1497
|
-
(params: GridRenderRowCellProps<TRow>) => {
|
|
1498
|
-
const {
|
|
1499
|
-
rowKey,
|
|
1500
|
-
frozen,
|
|
1501
|
-
inSelectedRow,
|
|
1502
|
-
row,
|
|
1503
|
-
active,
|
|
1504
|
-
selected,
|
|
1505
|
-
editing,
|
|
1506
|
-
rowIndex,
|
|
1507
|
-
inFillRange,
|
|
1508
|
-
rowBackgroundColor,
|
|
1509
|
-
} = params;
|
|
1510
|
-
const column = finalColumns[params.column];
|
|
1511
|
-
const rowId = rowIdGetter(row);
|
|
1512
|
-
|
|
1513
|
-
return (
|
|
1514
|
-
<RowCell
|
|
1515
|
-
rowKey={rowKey}
|
|
1516
|
-
rowId={rowId}
|
|
1517
|
-
row={row}
|
|
1518
|
-
column={column}
|
|
1519
|
-
frozen={frozen}
|
|
1520
|
-
inSelectedRow={inSelectedRow}
|
|
1521
|
-
columnIdx={params.column}
|
|
1522
|
-
editing={editing}
|
|
1523
|
-
value={row[column.key] as unknown}
|
|
1524
|
-
active={active}
|
|
1525
|
-
selected={selected}
|
|
1526
|
-
rowIndex={rowIndex}
|
|
1527
|
-
inFillRange={inFillRange}
|
|
1528
|
-
rowBackgroundColor={rowBackgroundColor}
|
|
1529
|
-
onStartEditing={handleStartEditing}
|
|
1530
|
-
onSelectStart={handleSelectStart}
|
|
1531
|
-
onSelectUpdate={handleSelectUpdate}
|
|
1532
|
-
onCommit={handleCommit}
|
|
1533
|
-
onFillStart={handleFillStart}
|
|
1534
|
-
onCancelEditing={handleCancelEditing}
|
|
1535
|
-
onNavigate={handleNavigate}
|
|
1536
|
-
onRequestLockedFieldChange={onRequestLockedFieldChange}
|
|
1537
|
-
/>
|
|
1538
|
-
);
|
|
1539
|
-
},
|
|
1540
|
-
[
|
|
1541
|
-
finalColumns,
|
|
1542
|
-
handleCommit,
|
|
1543
|
-
handleSelectStart,
|
|
1544
|
-
handleSelectUpdate,
|
|
1545
|
-
handleStartEditing,
|
|
1546
|
-
handleFillStart,
|
|
1547
|
-
handleCancelEditing,
|
|
1548
|
-
handleNavigate,
|
|
1549
|
-
onRequestLockedFieldChange,
|
|
1550
|
-
rowIdGetter,
|
|
1551
|
-
],
|
|
1552
|
-
);
|
|
1553
|
-
|
|
1554
|
-
// Start column resize drag
|
|
1555
|
-
const handleResizeStart = useCallback((columnKey: string, startX: number, startWidth: number) => {
|
|
1556
|
-
setResizeDrag({ columnKey, startX, startWidth });
|
|
1557
|
-
}, []);
|
|
1558
|
-
|
|
1559
|
-
// Global listeners for column resize - managed at DataGrid level
|
|
1560
|
-
useEffect(() => {
|
|
1561
|
-
if (!resizeDrag) return;
|
|
1562
|
-
|
|
1563
|
-
const handleMouseMove = (e: MouseEvent) => {
|
|
1564
|
-
const delta = e.clientX - resizeDrag.startX;
|
|
1565
|
-
const newWidth = Math.max(50, resizeDrag.startWidth + delta);
|
|
1566
|
-
// Update local state for immediate visual feedback
|
|
1567
|
-
setLocalColumnWidths((prev) => {
|
|
1568
|
-
const next = new Map(prev);
|
|
1569
|
-
next.set(resizeDrag.columnKey, newWidth);
|
|
1570
|
-
return next;
|
|
1571
|
-
});
|
|
1572
|
-
};
|
|
1573
|
-
|
|
1574
|
-
const handleMouseUp = (e: MouseEvent) => {
|
|
1575
|
-
// Calculate final width and notify parent to persist
|
|
1576
|
-
const delta = e.clientX - resizeDrag.startX;
|
|
1577
|
-
const newWidth = Math.max(50, resizeDrag.startWidth + delta);
|
|
1578
|
-
onColumnResizeRef.current?.(resizeDrag.columnKey, newWidth);
|
|
1579
|
-
// Keep local override - it stays until parent props update or next resize
|
|
1580
|
-
setResizeDrag(null);
|
|
1581
|
-
};
|
|
1582
|
-
|
|
1583
|
-
document.addEventListener("mousemove", handleMouseMove);
|
|
1584
|
-
document.addEventListener("mouseup", handleMouseUp);
|
|
1585
|
-
|
|
1586
|
-
return () => {
|
|
1587
|
-
document.removeEventListener("mousemove", handleMouseMove);
|
|
1588
|
-
document.removeEventListener("mouseup", handleMouseUp);
|
|
1589
|
-
};
|
|
1590
|
-
}, [resizeDrag]);
|
|
1591
|
-
|
|
1592
|
-
// Column reorder handlers
|
|
1593
|
-
const handleReorderStart = useCallback((columnKey: string) => {
|
|
1594
|
-
setReorderDrag({ columnKey, targetColumnKey: null });
|
|
1595
|
-
}, []);
|
|
1596
|
-
|
|
1597
|
-
const handleReorderUpdate = useCallback((targetColumnKey: string | null) => {
|
|
1598
|
-
setReorderDrag((prev) => {
|
|
1599
|
-
if (!prev) return null;
|
|
1600
|
-
if (prev.targetColumnKey === targetColumnKey) return prev;
|
|
1601
|
-
return { ...prev, targetColumnKey };
|
|
1602
|
-
});
|
|
1603
|
-
}, []);
|
|
1604
|
-
|
|
1605
|
-
const handleReorderEnd = useCallback(() => {
|
|
1606
|
-
if (reorderDrag && reorderDrag.targetColumnKey !== null) {
|
|
1607
|
-
// Don't reorder to same position
|
|
1608
|
-
if (reorderDrag.columnKey !== reorderDrag.targetColumnKey) {
|
|
1609
|
-
onColumnReorderRef.current?.(reorderDrag.columnKey, reorderDrag.targetColumnKey);
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
setReorderDrag(null);
|
|
1613
|
-
}, [reorderDrag]);
|
|
1614
|
-
|
|
1615
|
-
const handleReorderCancel = useCallback(() => {
|
|
1616
|
-
setReorderDrag(null);
|
|
1617
|
-
}, []);
|
|
1618
|
-
|
|
1619
|
-
// Global listeners for column reorder - managed at DataGrid level
|
|
1620
|
-
useEffect(() => {
|
|
1621
|
-
if (!reorderDrag) return;
|
|
1622
|
-
|
|
1623
|
-
const handleDragEnd = () => {
|
|
1624
|
-
handleReorderEnd();
|
|
1625
|
-
};
|
|
1626
|
-
|
|
1627
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1628
|
-
if (e.key === "Escape") {
|
|
1629
|
-
handleReorderCancel();
|
|
1630
|
-
}
|
|
1631
|
-
};
|
|
1632
|
-
|
|
1633
|
-
document.addEventListener("dragend", handleDragEnd);
|
|
1634
|
-
document.addEventListener("keydown", handleKeyDown);
|
|
1635
|
-
|
|
1636
|
-
return () => {
|
|
1637
|
-
document.removeEventListener("dragend", handleDragEnd);
|
|
1638
|
-
document.removeEventListener("keydown", handleKeyDown);
|
|
1639
|
-
};
|
|
1640
|
-
}, [reorderDrag, handleReorderEnd, handleReorderCancel]);
|
|
1641
|
-
|
|
1642
|
-
const renderHeaderCell = useCallback(
|
|
1643
|
-
(params: GridRenderHeaderCellProps) => {
|
|
1644
|
-
const column = finalColumns[params.column];
|
|
1645
|
-
const columnIndex = params.column;
|
|
1646
|
-
const isDragging = reorderDrag?.columnKey === column.key;
|
|
1647
|
-
const isDropTarget = reorderDrag?.targetColumnKey === column.key;
|
|
1648
|
-
const canReorder = enableReordering && onColumnReorderRef.current !== undefined;
|
|
1649
|
-
const canResize = enableResizing && onColumnResizeRef.current !== undefined;
|
|
1650
|
-
|
|
1651
|
-
return (
|
|
1652
|
-
<HeaderCellWrapper
|
|
1653
|
-
columnKey={column.key}
|
|
1654
|
-
width={params.width}
|
|
1655
|
-
onResizeStart={canResize ? handleResizeStart : undefined}
|
|
1656
|
-
onReorderStart={canReorder ? handleReorderStart : undefined}
|
|
1657
|
-
onReorderUpdate={canReorder ? handleReorderUpdate : undefined}
|
|
1658
|
-
isDragging={isDragging}
|
|
1659
|
-
isDropTarget={isDropTarget}
|
|
1660
|
-
>
|
|
1661
|
-
{column.renderHeaderCell?.({
|
|
1662
|
-
columnKey: column.key,
|
|
1663
|
-
onResize: canResize
|
|
1664
|
-
? (width: number) => onColumnResizeRef.current?.(column.key, width)
|
|
1665
|
-
: undefined,
|
|
1666
|
-
onSelectColumn: () => handleSelectColumn(columnIndex),
|
|
1667
|
-
})}
|
|
1668
|
-
</HeaderCellWrapper>
|
|
1669
|
-
);
|
|
1670
|
-
},
|
|
1671
|
-
[
|
|
1672
|
-
finalColumns,
|
|
1673
|
-
handleResizeStart,
|
|
1674
|
-
handleReorderStart,
|
|
1675
|
-
handleReorderUpdate,
|
|
1676
|
-
handleSelectColumn,
|
|
1677
|
-
reorderDrag,
|
|
1678
|
-
enableReordering,
|
|
1679
|
-
enableResizing,
|
|
1680
|
-
],
|
|
1681
|
-
);
|
|
1682
|
-
|
|
1683
|
-
const renderGroupSummaryCell = useCallback(
|
|
1684
|
-
(params: GridRenderGroupSummaryCellProps) => {
|
|
1685
|
-
const groupPathKey = params.groupKey;
|
|
1686
|
-
|
|
1687
|
-
const column = finalColumns[params.column];
|
|
1688
|
-
|
|
1689
|
-
const groupRows = descendantCacheRef.current.get(groupPathKey)?.rows ?? [];
|
|
1690
|
-
|
|
1691
|
-
return column.renderSummaryCell?.({
|
|
1692
|
-
groupKey: groupPathKey,
|
|
1693
|
-
rows: groupRows,
|
|
1694
|
-
columnKey: column.key,
|
|
1695
|
-
});
|
|
1696
|
-
},
|
|
1697
|
-
[finalColumns],
|
|
1698
|
-
);
|
|
1699
|
-
|
|
1700
|
-
const renderFooterCell = useCallback(
|
|
1701
|
-
(params: GridRenderFooterCellProps) => {
|
|
1702
|
-
const column = finalColumns[params.column];
|
|
1703
|
-
if (column.key === "select") {
|
|
1704
|
-
return null;
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
return column.renderSummaryCell?.({
|
|
1708
|
-
groupKey: "footer",
|
|
1709
|
-
rows: Array.from(rowDataMap.values()),
|
|
1710
|
-
columnKey: column.key,
|
|
1711
|
-
});
|
|
1712
|
-
},
|
|
1713
|
-
[finalColumns, rowDataMap],
|
|
1714
|
-
);
|
|
1715
|
-
|
|
1716
|
-
const renderGroupHeading = useCallback(
|
|
1717
|
-
(params: GridRenderGroupHeadingProps) => {
|
|
1718
|
-
const groupPathKey = params.groupKey;
|
|
1719
|
-
const groupData = groupDataMap.get(groupPathKey)!;
|
|
1720
|
-
|
|
1721
|
-
const collapsed = isGroupCollapsed(groupPathKey);
|
|
1722
|
-
const column = finalColumns.find((col) => col.key === groupData.columnKey);
|
|
1723
|
-
|
|
1724
|
-
let groupHeadingContent: React.ReactNode;
|
|
1725
|
-
if (column?.renderGroupHeading) {
|
|
1726
|
-
groupHeadingContent = column.renderGroupHeading({
|
|
1727
|
-
value: groupData.value,
|
|
1728
|
-
columnKey: groupData.columnKey,
|
|
1729
|
-
level: groupData.level,
|
|
1730
|
-
});
|
|
1731
|
-
} else {
|
|
1732
|
-
groupHeadingContent = String(groupData.value ?? "");
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
return (
|
|
1736
|
-
<div
|
|
1737
|
-
style={{
|
|
1738
|
-
display: "flex",
|
|
1739
|
-
flexDirection: "row",
|
|
1740
|
-
alignItems: "center",
|
|
1741
|
-
height: "100%",
|
|
1742
|
-
paddingLeft: 6,
|
|
1743
|
-
paddingRight: 6,
|
|
1744
|
-
gap: 4,
|
|
1745
|
-
}}
|
|
1746
|
-
>
|
|
1747
|
-
<IconButton
|
|
1748
|
-
icon={collapsed ? "chevron-right" : "chevron-down"}
|
|
1749
|
-
tooltip={collapsed ? expandGroupLabel : collapseGroupLabel}
|
|
1750
|
-
onPress={() => toggleGroupCollapse(groupPathKey)}
|
|
1751
|
-
/>
|
|
1752
|
-
{groupHeadingContent}
|
|
1753
|
-
</div>
|
|
1754
|
-
);
|
|
1755
|
-
},
|
|
1756
|
-
[finalColumns, groupDataMap, isGroupCollapsed, expandGroupLabel, collapseGroupLabel, toggleGroupCollapse],
|
|
1757
|
-
);
|
|
1758
|
-
|
|
1759
|
-
const renderFooter = useCallback(
|
|
1760
|
-
({ children }: GridRenderFooterProps) => (
|
|
1761
|
-
<View
|
|
1762
|
-
style={{
|
|
1763
|
-
height: "100%",
|
|
1764
|
-
width: "100%",
|
|
1765
|
-
backgroundColor: colors.background,
|
|
1766
|
-
borderTopWidth: 1,
|
|
1767
|
-
borderStyle: "solid",
|
|
1768
|
-
borderColor: colors.border,
|
|
1769
|
-
flexDirection: "row",
|
|
1770
|
-
}}
|
|
1771
|
-
>
|
|
1772
|
-
{children}
|
|
1773
|
-
</View>
|
|
1774
|
-
),
|
|
1775
|
-
[],
|
|
1776
|
-
);
|
|
1777
|
-
|
|
1778
|
-
const renderGroupSummaryRow = useCallback(
|
|
1779
|
-
({ children }: GridRenderGroupSummaryRowProps) => (
|
|
1780
|
-
<View
|
|
1781
|
-
style={{
|
|
1782
|
-
backgroundColor: colors.background,
|
|
1783
|
-
flexDirection: "row",
|
|
1784
|
-
width: "100%",
|
|
1785
|
-
height: "100%",
|
|
1786
|
-
borderTopWidth: 1,
|
|
1787
|
-
borderBottomWidth: 1,
|
|
1788
|
-
borderStyle: "solid",
|
|
1789
|
-
borderColor: colors.border,
|
|
1790
|
-
}}
|
|
1791
|
-
>
|
|
1792
|
-
{children}
|
|
1793
|
-
</View>
|
|
1794
|
-
),
|
|
1795
|
-
[],
|
|
1796
|
-
);
|
|
1797
|
-
|
|
1798
|
-
const renderRow = useCallback(({ children }: GridRenderRowProps<TRow>) => <>{children}</>, []);
|
|
1799
|
-
|
|
1800
|
-
const renderHeader = useCallback(({ children }: GridRenderHeaderProps) => <>{children}</>, []);
|
|
1801
|
-
|
|
1802
|
-
const rowSelectionContextValue = useMemo<RowSelectionContextValue>(
|
|
1803
|
-
() => ({
|
|
1804
|
-
selectedRows,
|
|
1805
|
-
onSelectedRowsChange,
|
|
1806
|
-
allRowPathKeys,
|
|
1807
|
-
}),
|
|
1808
|
-
[selectedRows, onSelectedRowsChange, allRowPathKeys],
|
|
1809
|
-
);
|
|
1810
|
-
|
|
1811
|
-
return (
|
|
1812
|
-
<AutoSizer>
|
|
1813
|
-
{({ width, height }) => {
|
|
1814
|
-
// Compute actual max frozen width: if value is between 0-1, treat as ratio
|
|
1815
|
-
const computedMaxFrozenColumnsWidth =
|
|
1816
|
-
maxFrozenColumnsWidth !== undefined
|
|
1817
|
-
? maxFrozenColumnsWidth > 0 && maxFrozenColumnsWidth <= 1
|
|
1818
|
-
? Math.floor(width * maxFrozenColumnsWidth)
|
|
1819
|
-
: maxFrozenColumnsWidth
|
|
1820
|
-
: undefined;
|
|
1821
|
-
|
|
1822
|
-
return (
|
|
1823
|
-
<div
|
|
1824
|
-
ref={containerRef}
|
|
1825
|
-
role="grid"
|
|
1826
|
-
aria-rowcount={orderedRowKeys.length}
|
|
1827
|
-
aria-colcount={columnCount - (hasSelectColumn ? 1 : 0)}
|
|
1828
|
-
aria-multiselectable
|
|
1829
|
-
tabIndex={editingCell ? -1 : 0}
|
|
1830
|
-
onKeyDown={handleContainerKeyDown}
|
|
1831
|
-
onPaste={handleContainerPaste}
|
|
1832
|
-
style={{ outline: "none", width, height }}
|
|
1833
|
-
>
|
|
1834
|
-
<RowSelectionContext.Provider value={rowSelectionContextValue}>
|
|
1835
|
-
<CellAnimationContext.Provider value={useCellAnimation ?? noopUseCellAnimation}>
|
|
1836
|
-
<Grid<TRow>
|
|
1837
|
-
selectedRows={selectedRows}
|
|
1838
|
-
ref={gridRef}
|
|
1839
|
-
style={styles.grid}
|
|
1840
|
-
height={height}
|
|
1841
|
-
width={width}
|
|
1842
|
-
columns={columnWidths}
|
|
1843
|
-
frozenColumnCount={frozenColumnCount + (hasSelectColumn ? 1 : 0)}
|
|
1844
|
-
maxFrozenColumnsWidth={computedMaxFrozenColumnsWidth}
|
|
1845
|
-
groups={gridGroups}
|
|
1846
|
-
rowData={rowDataMap}
|
|
1847
|
-
rowKeyToIndex={rowKeyToIndex}
|
|
1848
|
-
editingCell={editingCell}
|
|
1849
|
-
selectionMinRowIndex={selectionBounds?.minRowIndex ?? null}
|
|
1850
|
-
selectionMaxRowIndex={selectionBounds?.maxRowIndex ?? null}
|
|
1851
|
-
selectionMinColumn={selectionBounds?.minColumn ?? null}
|
|
1852
|
-
selectionMaxColumn={selectionBounds?.maxColumn ?? null}
|
|
1853
|
-
selectionActiveRowIndex={selectionBounds?.activeRowIndex ?? null}
|
|
1854
|
-
selectionActiveColumn={selectionBounds?.activeColumn ?? null}
|
|
1855
|
-
fillSourceRowIndex={fillDrag?.sourceRowIndex ?? null}
|
|
1856
|
-
fillTargetRowIndex={fillDrag?.targetRowIndex ?? null}
|
|
1857
|
-
fillColumn={fillDrag?.column ?? null}
|
|
1858
|
-
rowBackgroundColorGetter={rowColorGetter}
|
|
1859
|
-
collapsedRows={collapsedGroups}
|
|
1860
|
-
leafRowHeight={rowHeight}
|
|
1861
|
-
groupHeadingHeight={groupHeadingHeight}
|
|
1862
|
-
groupSummaryHeight={groupSummaryHeight}
|
|
1863
|
-
headerHeight={48}
|
|
1864
|
-
spacerHeight={spacerHeight}
|
|
1865
|
-
renderRowCell={renderRowCell}
|
|
1866
|
-
renderHeaderCell={renderHeaderCell}
|
|
1867
|
-
renderGroupHeading={renderGroupHeading}
|
|
1868
|
-
renderGroupSummaryCell={renderGroupSummaryCell}
|
|
1869
|
-
renderGroupSummaryRow={renderGroupSummaryRow}
|
|
1870
|
-
renderRow={renderRow}
|
|
1871
|
-
renderHeader={renderHeader}
|
|
1872
|
-
footerHeight={hasAnySummaryCell ? groupSummaryHeight : 0}
|
|
1873
|
-
renderFooterCell={hasAnySummaryCell ? renderFooterCell : undefined}
|
|
1874
|
-
renderFooter={hasAnySummaryCell ? renderFooter : undefined}
|
|
1875
|
-
onEndReached={onEndReached}
|
|
1876
|
-
onVisibleRangeChange={onVisibleRangeChange}
|
|
1877
|
-
/>
|
|
1878
|
-
</CellAnimationContext.Provider>
|
|
1879
|
-
</RowSelectionContext.Provider>
|
|
1880
|
-
</div>
|
|
1881
|
-
);
|
|
1882
|
-
}}
|
|
1883
|
-
</AutoSizer>
|
|
1884
|
-
);
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
interface HeaderCellWrapperProps {
|
|
1888
|
-
columnKey: string;
|
|
1889
|
-
width: number;
|
|
1890
|
-
onResizeStart?: (columnKey: string, startX: number, startWidth: number) => void;
|
|
1891
|
-
onReorderStart?: (columnKey: string) => void;
|
|
1892
|
-
onReorderUpdate?: (targetColumnKey: string | null) => void;
|
|
1893
|
-
isDragging?: boolean;
|
|
1894
|
-
isDropTarget?: boolean;
|
|
1895
|
-
children: React.ReactNode;
|
|
1896
|
-
}
|
|
1897
|
-
|
|
1898
|
-
function HeaderCellWrapper(props: HeaderCellWrapperProps) {
|
|
1899
|
-
const {
|
|
1900
|
-
columnKey,
|
|
1901
|
-
width,
|
|
1902
|
-
onResizeStart,
|
|
1903
|
-
onReorderStart,
|
|
1904
|
-
onReorderUpdate,
|
|
1905
|
-
isDragging,
|
|
1906
|
-
isDropTarget,
|
|
1907
|
-
children,
|
|
1908
|
-
} = props;
|
|
1909
|
-
|
|
1910
|
-
const handleResizeMouseDown = useCallback(
|
|
1911
|
-
(event: React.MouseEvent) => {
|
|
1912
|
-
if (!onResizeStart) return;
|
|
1913
|
-
event.preventDefault();
|
|
1914
|
-
event.stopPropagation();
|
|
1915
|
-
onResizeStart(columnKey, event.clientX, width);
|
|
1916
|
-
},
|
|
1917
|
-
[columnKey, width, onResizeStart],
|
|
1918
|
-
);
|
|
1919
|
-
|
|
1920
|
-
const handleDragStart = useCallback(
|
|
1921
|
-
(event: React.DragEvent) => {
|
|
1922
|
-
if (!onReorderStart) return;
|
|
1923
|
-
event.dataTransfer.effectAllowed = "move";
|
|
1924
|
-
event.dataTransfer.setData("text/plain", columnKey);
|
|
1925
|
-
onReorderStart(columnKey);
|
|
1926
|
-
},
|
|
1927
|
-
[columnKey, onReorderStart],
|
|
1928
|
-
);
|
|
1929
|
-
|
|
1930
|
-
const handleDragOver = useCallback(
|
|
1931
|
-
(event: React.DragEvent) => {
|
|
1932
|
-
if (!onReorderUpdate) return;
|
|
1933
|
-
event.preventDefault();
|
|
1934
|
-
event.dataTransfer.dropEffect = "move";
|
|
1935
|
-
onReorderUpdate(columnKey);
|
|
1936
|
-
},
|
|
1937
|
-
[columnKey, onReorderUpdate],
|
|
1938
|
-
);
|
|
1939
|
-
|
|
1940
|
-
const handleDragLeave = useCallback(() => {
|
|
1941
|
-
// Don't clear target here - let the next dragOver or dragEnd handle it
|
|
1942
|
-
}, []);
|
|
1943
|
-
|
|
1944
|
-
return (
|
|
1945
|
-
<View
|
|
1946
|
-
style={{
|
|
1947
|
-
backgroundColor: colors.zinc["50"],
|
|
1948
|
-
width: "100%",
|
|
1949
|
-
height: "100%",
|
|
1950
|
-
borderLeftWidth: isDropTarget ? 2 : 0,
|
|
1951
|
-
borderLeftColor: colors.blue["500"],
|
|
1952
|
-
position: "relative",
|
|
1953
|
-
opacity: isDragging ? 0.5 : 1,
|
|
1954
|
-
}}
|
|
1955
|
-
>
|
|
1956
|
-
{/* Draggable area */}
|
|
1957
|
-
<div
|
|
1958
|
-
draggable={!!onReorderStart}
|
|
1959
|
-
onDragStart={handleDragStart}
|
|
1960
|
-
onDragOver={handleDragOver}
|
|
1961
|
-
onDragLeave={handleDragLeave}
|
|
1962
|
-
style={{
|
|
1963
|
-
width: "100%",
|
|
1964
|
-
height: "100%",
|
|
1965
|
-
cursor: onReorderStart ? "grab" : "default",
|
|
1966
|
-
}}
|
|
1967
|
-
>
|
|
1968
|
-
{children}
|
|
1969
|
-
</div>
|
|
1970
|
-
{/* Resize handle */}
|
|
1971
|
-
{onResizeStart && (
|
|
1972
|
-
<div
|
|
1973
|
-
onMouseDown={handleResizeMouseDown}
|
|
1974
|
-
style={{
|
|
1975
|
-
position: "absolute",
|
|
1976
|
-
right: 0,
|
|
1977
|
-
top: 0,
|
|
1978
|
-
bottom: 0,
|
|
1979
|
-
width: 6,
|
|
1980
|
-
cursor: "col-resize",
|
|
1981
|
-
backgroundColor: "transparent",
|
|
1982
|
-
zIndex: 1,
|
|
1983
|
-
}}
|
|
1984
|
-
onMouseEnter={(e) => {
|
|
1985
|
-
(e.target as HTMLDivElement).style.backgroundColor = colors.blue["200"];
|
|
1986
|
-
}}
|
|
1987
|
-
onMouseLeave={(e) => {
|
|
1988
|
-
(e.target as HTMLDivElement).style.backgroundColor = "transparent";
|
|
1989
|
-
}}
|
|
1990
|
-
/>
|
|
1991
|
-
)}
|
|
1992
|
-
</View>
|
|
1993
|
-
);
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
const styles = StyleSheet.create({
|
|
1997
|
-
grid: {
|
|
1998
|
-
backgroundColor: colors.background,
|
|
1999
|
-
borderTopRightRadius: 8,
|
|
2000
|
-
borderTopLeftRadius: 8,
|
|
2001
|
-
borderWidth: 0,
|
|
2002
|
-
},
|
|
2003
|
-
});
|