@lotics/ui 2.6.0 → 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.
Files changed (47) hide show
  1. package/package.json +2 -15
  2. package/src/format_date.test.ts +64 -0
  3. package/src/format_date.ts +71 -0
  4. package/src/react_native.d.ts +2 -2
  5. package/src/cell_date.tsx +0 -30
  6. package/src/cell_date_format.test.ts +0 -32
  7. package/src/cell_date_format.ts +0 -73
  8. package/src/cell_number.test.ts +0 -42
  9. package/src/cell_number.tsx +0 -25
  10. package/src/cell_number_format.ts +0 -42
  11. package/src/cell_select.tsx +0 -68
  12. package/src/cell_text.tsx +0 -45
  13. package/src/grid/data_grid.tsx +0 -2003
  14. package/src/grid/data_grid_columns.test.ts +0 -72
  15. package/src/grid/data_grid_columns.ts +0 -30
  16. package/src/grid/data_grid_context.ts +0 -119
  17. package/src/grid/dispatch_safely.ts +0 -39
  18. package/src/grid/engine.module.css +0 -114
  19. package/src/grid/engine.tsx +0 -1042
  20. package/src/grid/helpers.ts +0 -205
  21. package/src/grid/layout.test.ts +0 -515
  22. package/src/grid/layout.ts +0 -425
  23. package/src/grid/recycling.test.ts +0 -236
  24. package/src/grid/recycling.ts +0 -172
  25. package/src/grid/row_cell.module.css +0 -105
  26. package/src/grid/row_cell.tsx +0 -313
  27. package/src/grid/search_highlight.ts +0 -71
  28. package/src/grid/select_cell.tsx +0 -58
  29. package/src/grid/select_group_summary_cell.tsx +0 -76
  30. package/src/grid/select_header_cell.tsx +0 -32
  31. package/src/grid/skeleton_row.module.css +0 -34
  32. package/src/grid/skeleton_row.tsx +0 -20
  33. package/src/grid/use_grid_groups.ts +0 -311
  34. package/src/grid/use_scroll_to_cell.ts +0 -135
  35. package/src/grid/use_virtual_grid.ts +0 -383
  36. package/src/grid/visibility.test.ts +0 -208
  37. package/src/grid/visibility.ts +0 -77
  38. package/src/kanban/constants.ts +0 -18
  39. package/src/kanban/default_renderers.tsx +0 -160
  40. package/src/kanban/drag_preview.tsx +0 -157
  41. package/src/kanban/index.ts +0 -13
  42. package/src/kanban/insert_card_zone.tsx +0 -135
  43. package/src/kanban/kanban_board.tsx +0 -635
  44. package/src/kanban/kanban_card.tsx +0 -321
  45. package/src/kanban/kanban_column.tsx +0 -499
  46. package/src/kanban/placeholders.tsx +0 -54
  47. package/src/kanban/types.ts +0 -116
@@ -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
- });