@opencorestack/opengridx 3.2.2 → 3.3.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 (56) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +12 -1
  3. package/dist/index.d.ts +173 -1
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +5472 -4841
  6. package/dist/opengridx.es.js.map +1 -1
  7. package/dist/opengridx.umd.js +15 -14
  8. package/dist/opengridx.umd.js.map +1 -1
  9. package/docs/API_REFERENCE.md +34 -2
  10. package/docs/README.md +1 -0
  11. package/docs/architecture/datagrid-orchestration.md +13 -5
  12. package/docs/components/header.md +25 -0
  13. package/docs/customization/theming.md +3 -1
  14. package/docs/features/cell-selection.md +131 -0
  15. package/docs/features/clipboard.md +2 -0
  16. package/docs/features/keyboard-navigation.md +14 -2
  17. package/docs/roadmap.md +64 -7
  18. package/lib/components/Cell/Cell.css +30 -0
  19. package/lib/components/Cell/Cell.tsx +25 -13
  20. package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
  21. package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
  22. package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
  23. package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
  24. package/lib/components/DataGrid/DataGrid.tsx +61 -1
  25. package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
  26. package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
  27. package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
  28. package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
  29. package/lib/components/FilterPanel/FilterPanel.tsx +2 -1
  30. package/lib/components/Header/Header.css +12 -0
  31. package/lib/components/Header/Header.tsx +17 -4
  32. package/lib/components/Row/Row.tsx +38 -1
  33. package/lib/components/Toolbar/GridToolbar.tsx +10 -4
  34. package/lib/components/ui/Checkbox.tsx +10 -5
  35. package/lib/hooks/core/gridApiPlaceholder.ts +6 -0
  36. package/lib/hooks/core/useGridCellSelection.ts +603 -0
  37. package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
  38. package/lib/hooks/core/useGridClipboardApi.ts +8 -2
  39. package/lib/hooks/core/useGridControlledState.ts +38 -0
  40. package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
  41. package/lib/hooks/core/useGridRowInteractions.ts +8 -4
  42. package/lib/hooks/features/useAggregation.ts +8 -6
  43. package/lib/hooks/features/useGridClipboard.ts +47 -2
  44. package/lib/hooks/features/useGridEditing.ts +16 -7
  45. package/lib/index.ts +9 -0
  46. package/lib/styles/base/variables.css +6 -1
  47. package/lib/styles/opengridx.css +1 -0
  48. package/lib/theme/DataGridThemeProvider.tsx +4 -0
  49. package/lib/theme/presets.ts +8 -0
  50. package/lib/theme/types.ts +4 -0
  51. package/lib/types/index.ts +130 -0
  52. package/lib/utils/attempt.ts +14 -0
  53. package/lib/utils/cellSelection.ts +238 -0
  54. package/lib/utils/headerWrap.ts +23 -0
  55. package/llms.txt +17 -2
  56. package/package.json +7 -3
@@ -144,6 +144,12 @@ export interface GridColDef<R extends GridValidRowModel = GridRowModel> {
144
144
  align?: GridAlignment;
145
145
  /** Content alignment within the header cell. */
146
146
  headerAlign?: GridAlignment;
147
+ /**
148
+ * Wraps this column's header title onto several lines instead of cutting it with an ellipsis.
149
+ * Overrides the grid's `wrapHeaderText`. The header keeps `headerHeight`: the title shows as many
150
+ * lines as fit and ends in an ellipsis if it is still longer. Raise `headerHeight` for more lines.
151
+ */
152
+ wrapHeaderText?: boolean;
147
153
  /** If false, sorting is disabled for this column. */
148
154
  sortable?: boolean;
149
155
  /** If false, filtering is disabled for this column. */
@@ -658,6 +664,68 @@ export interface GridPaginationModel {
658
664
 
659
665
  export type GridRowSelectionModel = GridRowId[];
660
666
 
667
+ /** One cell, addressed by its row id and column field. @since v3.3 */
668
+ export interface GridCellCoordinates {
669
+ id: GridRowId;
670
+ field: string;
671
+ }
672
+
673
+ /**
674
+ * A rectangle of cells between two corners. `anchor` is the active cell: it keeps focus, is the cell
675
+ * an edit starts in, and stays put while the range is extended. `head` is the corner that moves with
676
+ * Shift+arrows or the pointer, and the one scrolled into view. A single cell has `anchor` equal to `head`.
677
+ * @since v3.3
678
+ */
679
+ export interface GridCellRange {
680
+ anchor: GridCellCoordinates;
681
+ head: GridCellCoordinates;
682
+ }
683
+
684
+ /**
685
+ * The cell selection: zero or one range in 3.3 (the array form leaves room for several ranges later).
686
+ * Corners are ids and fields, so the rectangle follows its corner rows through sorting and filtering.
687
+ * @since v3.3
688
+ */
689
+ export type GridCellSelectionModel = readonly GridCellRange[];
690
+
691
+ /** What changed the cell selection. @since v3.3 */
692
+ export type GridCellSelectionReason = 'pointer' | 'keyboard' | 'selectAll' | 'api' | 'clear' | 'dataChange';
693
+
694
+ /** Second argument of `onCellSelectionModelChange`. @since v3.3 */
695
+ export interface GridCellSelectionChangeDetails {
696
+ reason: GridCellSelectionReason;
697
+ }
698
+
699
+ /** One cell of the selected range, as returned by `apiRef.getSelectedCells()`. @since v3.3 */
700
+ export interface GridSelectedCell {
701
+ id: GridRowId;
702
+ field: string;
703
+ /** The cell's value, read through the column's `valueGetter`. */
704
+ value: unknown;
705
+ }
706
+
707
+ /**
708
+ * Props the grid passes to `slots.cellSelectionStats` (the status bar of `showCellSelectionStats`),
709
+ * before `slotProps.cellSelectionStats` is merged over them. Rendered only while more than one cell
710
+ * is selected.
711
+ * @since v3.3
712
+ */
713
+ export interface GridCellSelectionStatsSlotProps {
714
+ apiRef: React.MutableRefObject<GridApi>;
715
+ /** Cells in the rectangle (rows × columns), synthetic rows and span-covered positions included. */
716
+ cellCount: number;
717
+ rowCount: number;
718
+ columnCount: number;
719
+ /** Non-empty cells of data rows. */
720
+ count: number;
721
+ /** Cells holding a number. */
722
+ numericCount: number;
723
+ /** Sum of the numeric cells, or `null` when there are none. */
724
+ sum: number | null;
725
+ /** Average of the numeric cells, or `null` when there are none. */
726
+ average: number | null;
727
+ }
728
+
661
729
  export interface GridPinnedColumns {
662
730
  left?: string[];
663
731
  right?: string[];
@@ -745,6 +813,12 @@ export interface GridLocaleText {
745
813
  paginationPage?: (page: number, pageCount: number) => string;
746
814
  /** Empty-state label. When set, overrides the `noRowsLabel` prop on DataGrid. Default: `"No Data"` */
747
815
  noRowsLabel?: string;
816
+ /**
817
+ * Screen-reader announcement after the cell range changes (`cellSelection`). Receives (cells, rows,
818
+ * columns). Default: `` (c, r, col) => `${c} cells selected, ${r} rows by ${col} columns` `` (singular for 1).
819
+ * @since v3.3
820
+ */
821
+ cellSelectionAnnouncement?: (cellCount: number, rowCount: number, columnCount: number) => string;
748
822
  }
749
823
 
750
824
  /**
@@ -763,6 +837,13 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
763
837
  rowHeight?: number;
764
838
  /** Height of the header row in pixels. Defaults to 56. */
765
839
  headerHeight?: number;
840
+ /**
841
+ * Wraps header titles onto several lines instead of cutting them with an ellipsis. The header
842
+ * keeps `headerHeight` (2 lines at the default 56px, 3 at 72px, 4 at 88px); a title that still
843
+ * does not fit ends in an ellipsis. `GridColDef.wrapHeaderText` overrides it per column.
844
+ * Defaults to `false`.
845
+ */
846
+ wrapHeaderText?: boolean;
766
847
  /** If true, the grid height will adjust to match the total height of its rows. */
767
848
  autoHeight?: boolean;
768
849
  /**
@@ -840,6 +921,24 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
840
921
  */
841
922
  disableClipboardCopy?: boolean;
842
923
 
924
+ /**
925
+ * Lets users select a rectangle of cells (drag, Shift+click, Shift+arrows, Ctrl/Cmd+A) and copy it
926
+ * with Ctrl/Cmd+C as tab-separated text. While on, Ctrl/Cmd+C copies the cell range (the focused
927
+ * cell at minimum) instead of the selected rows; `apiRef.copySelectedRows()` still copies rows.
928
+ * Default: false. @since v3.3
929
+ */
930
+ cellSelection?: boolean;
931
+ /** Controlled cell selection (zero or one range). Needs `cellSelection`. @since v3.3 */
932
+ cellSelectionModel?: GridCellSelectionModel;
933
+ /** Fired when the cell selection changes, with what changed it. @since v3.3 */
934
+ onCellSelectionModelChange?: (model: GridCellSelectionModel, details: GridCellSelectionChangeDetails) => void;
935
+ /**
936
+ * Shows a status bar under the grid with the count of non-empty cells and the sum and average of the
937
+ * numeric cells in the range, while more than one cell is selected. Replaceable through
938
+ * `slots.cellSelectionStats`. Needs `cellSelection`. Default: false. @since v3.3
939
+ */
940
+ showCellSelectionStats?: boolean;
941
+
843
942
  /** Loading state. With no rows the body shows skeleton rows (or `slots.loadingOverlay`); with rows, they stay and a progress bar (or `slots.loadingOverlay`) is shown over them. */
844
943
  loading?: boolean;
845
944
  /** Visual density of the grid. */
@@ -992,6 +1091,11 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
992
1091
  * Receives `GridFooterSlotProps` (plus `slotProps.footer`).
993
1092
  */
994
1093
  footer?: React.ComponentType<GridFooterSlotProps & Record<string, unknown>>;
1094
+ /**
1095
+ * Replaces the status bar of `showCellSelectionStats`. Receives `GridCellSelectionStatsSlotProps`
1096
+ * (plus `slotProps.cellSelectionStats`). @since v3.3
1097
+ */
1098
+ cellSelectionStats?: React.ComponentType<GridCellSelectionStatsSlotProps & Record<string, unknown>>;
995
1099
  };
996
1100
  /** Properties passed directly to custom slots. */
997
1101
  slotProps?: {
@@ -1007,6 +1111,8 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
1007
1111
  loadingOverlay?: GridOverlaySlotProps;
1008
1112
  /** Merged over the `GridFooterSlotProps` the grid passes to the footer slot. */
1009
1113
  footer?: Partial<GridFooterSlotProps> & Record<string, unknown>;
1114
+ /** Merged over the `GridCellSelectionStatsSlotProps` the grid passes to the status bar. */
1115
+ cellSelectionStats?: Partial<GridCellSelectionStatsSlotProps> & Record<string, unknown>;
1010
1116
  };
1011
1117
 
1012
1118
  /** When true, renders the grid as a single-column list of cards. Perfect for mobile/responsive views. */
@@ -1198,4 +1304,28 @@ export interface GridApi<R extends GridValidRowModel = GridRowModel> {
1198
1304
  * in which case nothing is written) and rejects when the clipboard write fails.
1199
1305
  */
1200
1306
  copySelectedRows: () => Promise<void>;
1307
+
1308
+ /** The cell selection (`[]` when `cellSelection` is off or nothing is selected). @since v3.3 */
1309
+ getCellSelectionModel: () => GridCellSelectionModel;
1310
+ /**
1311
+ * Replaces the cell selection (only the first range is kept) and moves focus to its anchor.
1312
+ * Does nothing while `cellSelection` is off. @since v3.3
1313
+ */
1314
+ setCellSelectionModel: (model: GridCellSelectionModel) => void;
1315
+ /** Selects the rectangle between two cells; `anchor` becomes the active cell. @since v3.3 */
1316
+ selectCellRange: (anchor: GridCellCoordinates, head: GridCellCoordinates) => void;
1317
+ /** Empties the cell selection. Focus stays where it is. @since v3.3 */
1318
+ clearCellSelection: () => void;
1319
+ /**
1320
+ * Cells of the current range in display order (row by row, columns in screen order), values read
1321
+ * through the column's `valueGetter`. Synthetic rows (group headers, subtotals, tree auto-parents, the
1322
+ * pivot Grand Total) and positions covered by a span are left out. @since v3.3
1323
+ */
1324
+ getSelectedCells: () => GridSelectedCell[];
1325
+ /**
1326
+ * Copies the cell range (the focused cell when nothing is selected) as tab-separated text, formatted
1327
+ * the way the cells show it, with no header line. Resolves without writing when there is nothing to
1328
+ * copy; rejects when the clipboard write fails. @since v3.3
1329
+ */
1330
+ copySelectedCells: () => Promise<void>;
1201
1331
  }
@@ -0,0 +1,14 @@
1
+ export type AttemptResult<T> = { ok: true; value: T } | { ok: false; error: unknown };
2
+
3
+ /**
4
+ * Calls `fn` and contains a throw. Components and hooks use it instead of an inline `try` / `catch`
5
+ * around expressions with `?.`, `??`, `||` or `? :`: React Compiler cannot compile a function that has
6
+ * those inside a `try` statement yet and skips the whole component or hook.
7
+ */
8
+ export function attempt<T>(fn: () => T): AttemptResult<T> {
9
+ try {
10
+ return { ok: true, value: fn() };
11
+ } catch (error) {
12
+ return { ok: false, error };
13
+ }
14
+ }
@@ -0,0 +1,238 @@
1
+ import type { GridCellCoordinates, GridCellRange, GridCellSelectionModel, GridRowId } from '../types';
2
+ import { isSystemField } from './focus';
3
+
4
+ /**
5
+ * Pure helpers of cell range selection (`cellSelection`). A range is stored as two corner cells
6
+ * (ids and fields); everything here works it out against the current display order.
7
+ */
8
+
9
+ /** The synthetic row-grouping column. Like the system columns, it never joins a range. */
10
+ export const GROUPING_COLUMN_FIELD = '__group__';
11
+
12
+ /** Whether cells of this column can be part of a range: data columns only. */
13
+ export function isRangeColumnField(field: string): boolean {
14
+ return !isSystemField(field) && field !== GROUPING_COLUMN_FIELD;
15
+ }
16
+
17
+ export const isSameCoordinates = (a: GridCellCoordinates | null | undefined, b: GridCellCoordinates | null | undefined): boolean =>
18
+ a === b || (!!a && !!b && a.id === b.id && a.field === b.field);
19
+
20
+ export const isSingleCellRange = (range: GridCellRange): boolean => isSameCoordinates(range.anchor, range.head);
21
+
22
+ /** A model holding the same range(s) as `b`. */
23
+ export function isSameCellSelectionModel(a: GridCellSelectionModel, b: GridCellSelectionModel): boolean {
24
+ if (a === b) return true;
25
+ if (a.length !== b.length) return false;
26
+ return a.every((range, i) => isSameCoordinates(range.anchor, b[i].anchor) && isSameCoordinates(range.head, b[i].head));
27
+ }
28
+
29
+ /** A rectangle in display indices: rows of the renderable rows, columns of the range columns. Inclusive. */
30
+ export interface CellRangeRect {
31
+ top: number;
32
+ bottom: number;
33
+ left: number;
34
+ right: number;
35
+ }
36
+
37
+ /** The rectangle of a range, plus where its two corners are. */
38
+ export interface ResolvedCellRange extends CellRangeRect {
39
+ anchorRow: number;
40
+ anchorCol: number;
41
+ headRow: number;
42
+ headCol: number;
43
+ }
44
+
45
+ /** Span information, from useGridSpanning. */
46
+ export interface CellRangeSpanLookup {
47
+ /** For a cell covered by a span, the origin cell of that span; `null` for any other cell. */
48
+ getSpanOrigin: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
49
+ /** The colSpan of a span origin (1 for other cells). */
50
+ getColSpan: (rowId: GridRowId, field: string) => number;
51
+ /** The rowSpan of a span origin (1 for other cells). */
52
+ getRowSpan: (rowId: GridRowId, field: string) => number;
53
+ }
54
+
55
+ export interface CellRangeContext {
56
+ /** Ids of the renderable rows (top-pinned, page rows, bottom-pinned), in display order. */
57
+ rowIds: readonly GridRowId[];
58
+ rowIndexById: ReadonlyMap<GridRowId, number>;
59
+ /** Range columns in screen order: left-pinned, centre, right-pinned. */
60
+ fields: readonly string[];
61
+ colIndexByField: ReadonlyMap<string, number>;
62
+ /** Present when the grid has column or row spans. */
63
+ spans?: CellRangeSpanLookup;
64
+ }
65
+
66
+ /** The merged area (span) that contains a cell, or the cell itself. */
67
+ function mergedArea(r: number, c: number, ctx: CellRangeContext): CellRangeRect {
68
+ const { rowIds, fields, rowIndexById, colIndexByField, spans } = ctx;
69
+ const single = { top: r, bottom: r, left: c, right: c };
70
+ if (!spans) return single;
71
+ const rowId = rowIds[r];
72
+ const field = fields[c];
73
+ const origin = spans.getSpanOrigin(rowId, field) ?? { rowId, field };
74
+ const originRow = rowIndexById.get(origin.rowId);
75
+ const originCol = colIndexByField.get(origin.field);
76
+ if (originRow === undefined || originCol === undefined) return single;
77
+ const rowSpan = Math.max(1, spans.getRowSpan(origin.rowId, origin.field));
78
+ const colSpan = Math.max(1, spans.getColSpan(origin.rowId, origin.field));
79
+ return {
80
+ top: originRow,
81
+ bottom: Math.min(rowIds.length - 1, originRow + rowSpan - 1),
82
+ left: originCol,
83
+ right: Math.min(fields.length - 1, originCol + colSpan - 1),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Grows a rectangle until no span crosses its edge, as Excel does with merged cells. A span that
89
+ * crosses the edge holds a cell on the edge, so only the edge cells are looked at.
90
+ */
91
+ export function growRectToSpans(rect: CellRangeRect, ctx: CellRangeContext): CellRangeRect {
92
+ if (!ctx.spans) return rect;
93
+ let { top, bottom, left, right } = rect;
94
+ let changed = true;
95
+ const take = (area: CellRangeRect) => {
96
+ if (area.top < top) { top = area.top; changed = true; }
97
+ if (area.bottom > bottom) { bottom = area.bottom; changed = true; }
98
+ if (area.left < left) { left = area.left; changed = true; }
99
+ if (area.right > right) { right = area.right; changed = true; }
100
+ };
101
+ while (changed) {
102
+ changed = false;
103
+ for (let r = top; r <= bottom; r++) {
104
+ take(mergedArea(r, left, ctx));
105
+ if (right !== left) take(mergedArea(r, right, ctx));
106
+ }
107
+ for (let c = left + 1; c < right; c++) {
108
+ take(mergedArea(top, c, ctx));
109
+ if (bottom !== top) take(mergedArea(bottom, c, ctx));
110
+ }
111
+ }
112
+ return { top, bottom, left, right };
113
+ }
114
+
115
+ /**
116
+ * Where a range lies in the current display order, or `null` when one of its corners is not
117
+ * displayed (its row was filtered, paged or removed, or its column hidden).
118
+ */
119
+ export function resolveCellRange(range: GridCellRange, ctx: CellRangeContext): ResolvedCellRange | null {
120
+ const anchorRow = ctx.rowIndexById.get(range.anchor.id);
121
+ const headRow = ctx.rowIndexById.get(range.head.id);
122
+ const anchorCol = ctx.colIndexByField.get(range.anchor.field);
123
+ const headCol = ctx.colIndexByField.get(range.head.field);
124
+ if (anchorRow === undefined || headRow === undefined || anchorCol === undefined || headCol === undefined) return null;
125
+ const rect = growRectToSpans({
126
+ top: Math.min(anchorRow, headRow),
127
+ bottom: Math.max(anchorRow, headRow),
128
+ left: Math.min(anchorCol, headCol),
129
+ right: Math.max(anchorCol, headCol),
130
+ }, ctx);
131
+ return { ...rect, anchorRow, anchorCol, headRow, headCol };
132
+ }
133
+
134
+ export const rectCellCount = (rect: CellRangeRect): number => (rect.bottom - rect.top + 1) * (rect.right - rect.left + 1);
135
+
136
+ /**
137
+ * The new head for a Shift+navigation key, as display indices, or `null` when it does not move.
138
+ * Shift+Arrow moves one cell and does not wrap to the next row. Extending moves from the far edge
139
+ * of the rectangle, so the head steps out of a span it grew over.
140
+ */
141
+ export function nextRangeHead(
142
+ resolved: ResolvedCellRange,
143
+ key: string,
144
+ withModifier: boolean,
145
+ pageSize: number,
146
+ lastRow: number,
147
+ lastCol: number,
148
+ ): { row: number; col: number } | null {
149
+ const { headRow, headCol, anchorRow, anchorCol, top, bottom, left, right } = resolved;
150
+ let row = headRow;
151
+ let col = headCol;
152
+ switch (key) {
153
+ case 'ArrowRight': col = headCol >= anchorCol ? right + 1 : headCol + 1; break;
154
+ case 'ArrowLeft': col = headCol <= anchorCol ? left - 1 : headCol - 1; break;
155
+ case 'ArrowDown': row = headRow >= anchorRow ? bottom + 1 : headRow + 1; break;
156
+ case 'ArrowUp': row = headRow <= anchorRow ? top - 1 : headRow - 1; break;
157
+ case 'Home':
158
+ col = 0;
159
+ if (withModifier) row = 0;
160
+ break;
161
+ case 'End':
162
+ col = lastCol;
163
+ if (withModifier) row = lastRow;
164
+ break;
165
+ case 'PageUp': row = headRow - pageSize; break;
166
+ case 'PageDown': row = headRow + pageSize; break;
167
+ default: return null;
168
+ }
169
+ row = Math.min(Math.max(row, 0), lastRow);
170
+ col = Math.min(Math.max(col, 0), lastCol);
171
+ if (row === headRow && col === headCol) return null;
172
+ return { row, col };
173
+ }
174
+
175
+ /**
176
+ * The range flags one row passes to its cells, or `null` for a row outside the range. Rows outside
177
+ * keep `null`, so a drag only re-renders the rows it enters or leaves. `left` / `right` are data
178
+ * column indices (`columnIndexMap`).
179
+ */
180
+ export interface GridRowCellRange {
181
+ left: number;
182
+ right: number;
183
+ isTop: boolean;
184
+ isBottom: boolean;
185
+ /** Rows from this one to the bottom of the range; only set (else 0) when the grid has row spans. */
186
+ bottomOffset: number;
187
+ }
188
+
189
+ export function getRowCellRange(
190
+ range: { top: number; bottom: number; left: number; right: number } | null | undefined,
191
+ rowIndex: number,
192
+ hasRowSpan: boolean,
193
+ ): GridRowCellRange | null {
194
+ if (!range || rowIndex < range.top || rowIndex > range.bottom) return null;
195
+ return {
196
+ left: range.left,
197
+ right: range.right,
198
+ isTop: rowIndex === range.top,
199
+ isBottom: rowIndex === range.bottom,
200
+ bottomOffset: hasRowSpan ? range.bottom - rowIndex : 0,
201
+ };
202
+ }
203
+
204
+ export const isSameRowCellRange = (a: GridRowCellRange | null | undefined, b: GridRowCellRange | null | undefined): boolean =>
205
+ a === b || (!!a && !!b && a.left === b.left && a.right === b.right && a.isTop === b.isTop && a.isBottom === b.isBottom
206
+ && a.bottomOffset === b.bottomOffset);
207
+
208
+ /** Bit flags a cell receives for its place in the range (a number keeps memoised cells stable). */
209
+ export const RANGE_CELL = 1;
210
+ export const RANGE_TOP = 2;
211
+ export const RANGE_BOTTOM = 4;
212
+ export const RANGE_LEFT = 8;
213
+ export const RANGE_RIGHT = 16;
214
+
215
+ /**
216
+ * The flags of the cell at data column `colIndex` (spanning `colSpan` columns and `rowSpan` rows from
217
+ * this row), or 0 when it is outside the range.
218
+ */
219
+ export function getCellRangeFlags(rowRange: GridRowCellRange | null | undefined, colIndex: number, colSpan: number, rowSpan: number): number {
220
+ if (!rowRange) return 0;
221
+ const lastCol = colIndex + Math.max(1, colSpan) - 1;
222
+ if (colIndex < rowRange.left || lastCol > rowRange.right) return 0;
223
+ let flags = RANGE_CELL;
224
+ if (rowRange.isTop) flags |= RANGE_TOP;
225
+ if (rowRange.isBottom || (rowSpan > 1 && rowSpan - 1 >= rowRange.bottomOffset)) flags |= RANGE_BOTTOM;
226
+ if (colIndex === rowRange.left) flags |= RANGE_LEFT;
227
+ if (lastCol === rowRange.right) flags |= RANGE_RIGHT;
228
+ return flags;
229
+ }
230
+
231
+ /** The default screen-reader text after a range change. */
232
+ export function defaultCellSelectionAnnouncement(cellCount: number, rowCount: number, columnCount: number): string {
233
+ const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
234
+ return `${plural(cellCount, 'cell', 'cells')} selected, ${plural(rowCount, 'row', 'rows')} by ${plural(columnCount, 'column', 'columns')}`;
235
+ }
236
+
237
+ /** Above this many cells a copy warns (development only) that exportToCsv suits the job better. */
238
+ export const LARGE_RANGE_COPY_CELLS = 100_000;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Line height of a wrapped header title in pixels. Must match `line-height` of
3
+ * `.ogx__header-cell--wrap .ogx__header-cell-title` in Header.css.
4
+ */
5
+ export const HEADER_WRAP_LINE_HEIGHT = 16;
6
+
7
+ /** Vertical padding of a header cell (`--ogx-grid-cell-padding-y`, 8px by default), top plus bottom. */
8
+ const HEADER_VERTICAL_PADDING = 16;
9
+
10
+ /**
11
+ * How many lines a wrapped header title may take inside a header of `headerHeight` pixels: 2 at 56px,
12
+ * 3 at 72px, 4 at 88px. One line less when the aggregation label sits under the title. Never below 1.
13
+ */
14
+ export function getHeaderLineClamp(headerHeight: number, hasAggregationLabel = false): number {
15
+ const available = Number.isFinite(headerHeight) ? headerHeight - HEADER_VERTICAL_PADDING : 0;
16
+ const lines = Math.floor(available / HEADER_WRAP_LINE_HEIGHT) - (hasAggregationLabel ? 1 : 0);
17
+ return Math.max(1, lines);
18
+ }
19
+
20
+ /** Whether a column's header title wraps: the column's own `wrapHeaderText` wins over the grid's. */
21
+ export function shouldWrapHeaderText(columnWrap: boolean | undefined, gridWrap: boolean | undefined): boolean {
22
+ return columnWrap ?? gridWrap ?? false;
23
+ }
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenGridX — AI Agent Context
2
2
  # Package: @opencorestack/opengridx
3
- # Version: 3.2.2
3
+ # Version: 3.3.0
4
4
  # Docs: https://opencorestack.github.io/OpenGridX/
5
5
  # This file is machine-readable. AI developers: start here.
6
6
 
@@ -78,6 +78,7 @@ export default function App() {
78
78
  | localeText | GridLocaleText | — | Override pager / no-rows strings: paginationRowsPerPage, paginationOf(from,to,count), paginationPage(page,count), noRowsLabel |
79
79
  | rowHeight | number | 52 | Row height in pixels |
80
80
  | headerHeight | number | 56 | Header height in pixels |
81
+ | wrapHeaderText | boolean | false | Wrap header titles within headerHeight (2 lines at 56px, 3 at 72, 4 at 88; ellipsis on the last line). Header never grows: raise headerHeight for more lines. Class ogx__header-cell--wrap. Per column: GridColDef.wrapHeaderText (3.3.0+) |
81
82
  | loading | boolean | false | No rows: skeleton rows (or slots.loadingOverlay). Rows shown: they stay, a progress bar (.ogx__loading-bar) runs along the top, or slots.loadingOverlay is shown over them |
82
83
  | noRowsLabel | string | 'No Data' | Empty state message |
83
84
  | density | 'compact'/'standard'/'comfortable' | 'standard' | 32px / rowHeight / 72px rows |
@@ -92,6 +93,10 @@ export default function App() {
92
93
  | onRowSelectionModelChange | (model: GridRowId[]) => void | Selection change callback. Also fires once with ids of removed rows pruned (client-owned rows) |
93
94
  | disableRowSelectionOnClick | boolean | Prevent click toggling selection |
94
95
  | disableClipboardCopy | boolean | Turn off the grid's Ctrl/Cmd+C copy (copySelectedRows still works) |
96
+ | cellSelection | boolean (3.3.0+) | Spreadsheet-style cell ranges: drag (auto-scrolls), Shift+click, Shift+arrows/Home/End/PageUp/PageDown, Ctrl/Cmd+A = every data cell of the page, Escape collapses. Ctrl/Cmd+C then copies the range as TSV (no header line) instead of the selected rows. Ranges never change the row selection |
97
+ | cellSelectionModel | GridCellSelectionModel | Controlled range: readonly { anchor: { id, field }, head: { id, field } }[] (0 or 1 range). Corners are ids/fields: the range follows sorting; cleared ('dataChange') when a corner is no longer displayed |
98
+ | onCellSelectionModelChange | (model, { reason }) => void | reason: 'pointer' | 'keyboard' | 'selectAll' | 'api' | 'clear' | 'dataChange' |
99
+ | showCellSelectionStats | boolean | Status bar: Count (non-empty), Sum and Average (numeric) of the range; slots.cellSelectionStats replaces it |
95
100
 
96
101
  ### Sorting
97
102
  | Prop | Type | Description |
@@ -247,6 +252,7 @@ There are no getRowClassName / getCellClassName / theme props: use GridColDef.ce
247
252
  | rowSpan | number / (params) => number | Cell row span (clamped to the row section) |
248
253
  | align | 'left'/'center'/'right' | Cell content alignment |
249
254
  | headerAlign | 'left'/'center'/'right' | Header content alignment |
255
+ | wrapHeaderText | boolean | Wrap this header title; overrides the grid prop (3.3.0+) |
250
256
  | sortable | boolean (default true) | Enable sorting |
251
257
  | sortComparator | (v1, v2, params1, params2) => number | Custom ascending comparator (3.1.0+). v1/v2 are valueGetter values incl. null/undefined; params { id, field, row, value }. Negated for desc; multi-sort tie-break chain; groups by grouping value; tree siblings; pivot row labels. Throw/NaN = 0. Ignored in server sortingMode |
252
258
  | filterable | boolean (default true) | Enable filtering and quick search |
@@ -307,6 +313,14 @@ apiRef.current.autosizeColumns(fields?)
307
313
  apiRef.current.copySelectedRows() // → Promise<void>; rejects if the write fails. Copies every selected row that passes
308
314
  // the filter (all pages, collapsed groups, pinned rows), visible columns in screen order
309
315
 
316
+ // Cell range selection (cellSelection, 3.3.0+); inert while cellSelection is off
317
+ apiRef.current.selectCellRange({ id: 1, field: 'a' }, { id: 5, field: 'c' }) // anchor = active cell
318
+ apiRef.current.setCellSelectionModel(model)
319
+ apiRef.current.getCellSelectionModel() // → GridCellSelectionModel
320
+ apiRef.current.getSelectedCells() // → { id, field, value }[]; synthetic rows and span-covered cells left out
321
+ apiRef.current.copySelectedCells() // → Promise<void>; TSV, no header line
322
+ apiRef.current.clearCellSelection()
323
+
310
324
  // Aggregation
311
325
  apiRef.current.getAggregationResult() // → Record<string, unknown> | null (null in pivot mode)
312
326
  apiRef.current.getAggregationModel() // → GridAggregationModel | null
@@ -530,7 +544,8 @@ Derived tokens (e.g. `--ogx-grid-cell-focus-border`, which defaults to the prima
530
544
  Class names use the `ogx__` BEM prefix, e.g.:
531
545
  - `ogx__row`, `ogx__row--selected`, `ogx__row--even`, `ogx__row--odd`
532
546
  - `ogx__cell`, `ogx__cell--focused`, `ogx__cell--editing`
533
- - `ogx__header-cell`, `ogx__header-cell--sorted`
547
+ - Cell ranges (3.3.0+): `ogx__cell--range`, `ogx__cell--range-top|bottom|left|right` (inset box-shadow outline), `ogx--range-dragging` on the root during a drag; variables `--ogx-range-background`, `--ogx-range-border` (theme keys `grid.rangeBackground`, `grid.rangeBorder`)
548
+ - `ogx__header-cell`, `ogx__header-cell--sorted`, `ogx__header-cell--wrap` (wrapHeaderText, 3.3.0+)
534
549
  - Sticky wrappers: `ogx__sticky-top` (header + top-pinned rows), `ogx__sticky-bottom` (bottom-pinned rows + aggregation footer)
535
550
 
536
551
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencorestack/opengridx",
3
- "version": "3.2.2",
3
+ "version": "3.3.0",
4
4
  "description": "OpenGridX: High-performance React data infrastructure. Unlock advanced Row Grouping, Excel Export, and Column Pinning without the usual \"Pro\" gatekeeping. Built for speed, scale, and complete architectural freedom. Fully open, virtualization-ready, and feature-complete.",
5
5
  "type": "module",
6
6
  "main": "./dist/opengridx.umd.js",
@@ -55,7 +55,9 @@
55
55
  "test:smoke": "node scripts/smoke.mjs",
56
56
  "test:watch": "vitest --project unit",
57
57
  "coverage": "vitest run --coverage",
58
- "prepublishOnly": "npm run build:lib"
58
+ "prepublishOnly": "npm run build:lib",
59
+ "wiki:build": "node scripts/build-wiki.mjs",
60
+ "check:compiler": "node scripts/check-react-compiler.mjs"
59
61
  },
60
62
  "keywords": [
61
63
  "react",
@@ -106,6 +108,7 @@
106
108
  }
107
109
  },
108
110
  "devDependencies": {
111
+ "@babel/core": "^7.29.7",
109
112
  "@eslint/js": "^9.39.1",
110
113
  "@playwright/test": "^1.58.2",
111
114
  "@testing-library/jest-dom": "^7.0.0",
@@ -117,8 +120,9 @@
117
120
  "@vitejs/plugin-react": "^5.1.1",
118
121
  "@vitest/browser-playwright": "^4.1.11",
119
122
  "@vitest/coverage-v8": "^4.1.11",
123
+ "babel-plugin-react-compiler": "^1.0.0",
120
124
  "eslint": "^9.39.1",
121
- "eslint-plugin-react-hooks": "^7.0.1",
125
+ "eslint-plugin-react-hooks": "^7.1.1",
122
126
  "eslint-plugin-react-refresh": "^0.4.24",
123
127
  "exceljs": "^4.4.0",
124
128
  "globals": "^16.5.0",