@opencorestack/opengridx 3.2.2 → 3.4.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 (88) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +16 -2
  3. package/dist/ai.cjs +1 -0
  4. package/dist/ai.cjs.map +1 -0
  5. package/dist/ai.d.ts +166 -0
  6. package/dist/ai.es.js +1 -0
  7. package/dist/ai.es.js.map +1 -0
  8. package/dist/index.d.ts +321 -3
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +6483 -5291
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -13
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +132 -3
  15. package/docs/README.md +4 -1
  16. package/docs/architecture/datagrid-orchestration.md +23 -6
  17. package/docs/components/header.md +25 -0
  18. package/docs/customization/theming.md +12 -1
  19. package/docs/features/ai-toolkit.md +119 -0
  20. package/docs/features/cell-selection.md +131 -0
  21. package/docs/features/clipboard.md +90 -0
  22. package/docs/features/editing-reordering.md +50 -0
  23. package/docs/features/keyboard-navigation.md +25 -2
  24. package/docs/features/undo-redo.md +79 -0
  25. package/docs/roadmap.md +64 -7
  26. package/lib/ai/coerce.ts +90 -0
  27. package/lib/ai/columns.ts +90 -0
  28. package/lib/ai/index.ts +16 -0
  29. package/lib/ai/schema.ts +214 -0
  30. package/lib/ai/types.ts +88 -0
  31. package/lib/ai/validate.ts +286 -0
  32. package/lib/components/Cell/Cell.css +35 -0
  33. package/lib/components/Cell/Cell.tsx +25 -13
  34. package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
  35. package/lib/components/Cell/GridEditInputCell.css +12 -4
  36. package/lib/components/Cell/GridEditInputCell.tsx +19 -12
  37. package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
  38. package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
  39. package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
  40. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
  41. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  42. package/lib/components/DataGrid/DataGrid.tsx +123 -3
  43. package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
  44. package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
  45. package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
  46. package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
  47. package/lib/components/FilterPanel/FilterPanel.css +14 -0
  48. package/lib/components/FilterPanel/FilterPanel.tsx +5 -3
  49. package/lib/components/Header/Header.css +12 -0
  50. package/lib/components/Header/Header.tsx +17 -4
  51. package/lib/components/ListView/ListView.css +2 -4
  52. package/lib/components/ListView/ListViewRow.tsx +2 -2
  53. package/lib/components/Row/Row.tsx +38 -1
  54. package/lib/components/Toolbar/GlobalSearch.css +16 -0
  55. package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
  56. package/lib/components/Toolbar/GridToolbar.tsx +10 -4
  57. package/lib/components/ui/Checkbox.tsx +26 -10
  58. package/lib/components/ui/Input.css +43 -0
  59. package/lib/components/ui/Input.tsx +24 -5
  60. package/lib/hooks/core/gridApiPlaceholder.ts +14 -1
  61. package/lib/hooks/core/useDataGrid.ts +27 -0
  62. package/lib/hooks/core/useGridCellSelection.ts +631 -0
  63. package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
  64. package/lib/hooks/core/useGridClipboardApi.ts +8 -2
  65. package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
  66. package/lib/hooks/core/useGridControlledState.ts +38 -0
  67. package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
  68. package/lib/hooks/core/useGridRowInteractions.ts +8 -4
  69. package/lib/hooks/core/useGridUndoRedo.ts +220 -0
  70. package/lib/hooks/features/useAggregation.ts +8 -6
  71. package/lib/hooks/features/useGridBatchEdit.ts +217 -0
  72. package/lib/hooks/features/useGridClipboard.ts +47 -2
  73. package/lib/hooks/features/useGridEditing.ts +39 -51
  74. package/lib/index.ts +16 -0
  75. package/lib/styles/base/variables.css +6 -1
  76. package/lib/styles/opengridx.css +3 -1
  77. package/lib/theme/DataGridThemeProvider.tsx +4 -0
  78. package/lib/theme/presets.ts +8 -0
  79. package/lib/theme/types.ts +4 -0
  80. package/lib/types/index.ts +254 -0
  81. package/lib/utils/attempt.ts +14 -0
  82. package/lib/utils/cellSelection.ts +238 -0
  83. package/lib/utils/editing/commit.ts +69 -0
  84. package/lib/utils/editing/targets.ts +109 -0
  85. package/lib/utils/headerWrap.ts +23 -0
  86. package/lib/utils/parsing.ts +252 -0
  87. package/llms.txt +71 -2
  88. package/package.json +14 -4
@@ -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,69 @@
1
+ import type { GridColDef, GridRowId, GridRowModel } from '../../types';
2
+
3
+ /**
4
+ * The steps every committed edit shares: a single-cell edit (`useGridEditing`) and a batch edit
5
+ * (`useGridBatchEdit`: paste, range clear, undo, redo) build the row the same way and report errors
6
+ * the same way.
7
+ */
8
+
9
+ /** A misuse of the editing contract (e.g. processRowUpdate returned nothing), as opposed to a consumer rejection. */
10
+ export class OpenGridXEditError extends Error {}
11
+
12
+ /** One committed cell change: the value before and the value stored after (both read through `getCellValue`). */
13
+ export interface GridCellChange {
14
+ id: GridRowId;
15
+ field: string;
16
+ before: unknown;
17
+ after: unknown;
18
+ }
19
+
20
+ /** Cell values compare equal: dates by time, anything else with `Object.is`. */
21
+ export function isSameValue(a: unknown, b: unknown): boolean {
22
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
23
+ return Object.is(a, b);
24
+ }
25
+
26
+ export function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
27
+ return typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function';
28
+ }
29
+
30
+ /**
31
+ * Sends an edit failure to `onProcessRowUpdateError`. Without one, a misuse of the contract is logged
32
+ * in development; a consumer rejection is the consumer's own and stays silent.
33
+ */
34
+ export function reportEditError(error: unknown, onError: ((error: unknown) => void) | undefined): void {
35
+ if (onError) {
36
+ onError(error);
37
+ } else if (process.env.NODE_ENV !== 'production' && error instanceof OpenGridXEditError) {
38
+ console.error(error.message);
39
+ }
40
+ }
41
+
42
+ /** The error for a `processRowUpdate` that resolved to something other than a row object. */
43
+ export function invalidProcessedRowError(processed: unknown, outcome: string): OpenGridXEditError {
44
+ return new OpenGridXEditError(
45
+ `[OpenGridX] processRowUpdate must return the updated row object (or a Promise of it); ` +
46
+ `it returned ${processed === null ? 'null' : typeof processed}. ${outcome}`
47
+ );
48
+ }
49
+
50
+ /** The row with one cell changed: through the column's `valueSetter`, else `row[field] = value`. */
51
+ export function buildEditedRow<R extends GridRowModel>(
52
+ row: R,
53
+ cell: { field: string; value: unknown },
54
+ colDef: GridColDef<R> | undefined,
55
+ warnedFields: Set<string>
56
+ ): R {
57
+ if (colDef?.valueSetter) {
58
+ return colDef.valueSetter({ value: cell.value, row, field: cell.field });
59
+ }
60
+ if (colDef?.valueGetter && process.env.NODE_ENV !== 'production' && !warnedFields.has(cell.field)) {
61
+ warnedFields.add(cell.field);
62
+ console.warn(
63
+ `[OpenGridX] Column "${cell.field}" is editable and has a valueGetter but no valueSetter. ` +
64
+ `The edited value was written to row["${cell.field}"], which the valueGetter may ignore, so the cell ` +
65
+ 'can keep showing the old value. Add a valueSetter that maps the value back onto the row.'
66
+ );
67
+ }
68
+ return { ...row, [cell.field]: cell.value };
69
+ }
@@ -0,0 +1,109 @@
1
+ import type { GridCellCoordinates, GridCellParams, GridColDef, GridRowId, GridRowMeta, GridRowModel } from '../../types';
2
+ import { isRangeColumnField } from '../cellSelection';
3
+ import type { CellRangeRect } from '../cellSelection';
4
+ import { isSystemField } from '../focus';
5
+ import { isSyntheticRowId } from '../syntheticRows';
6
+ import { resolveCellEditable } from './index';
7
+ import { getCellValue } from '../values';
8
+
9
+ /**
10
+ * Where batch edits (paste, range clear, undo/redo) land: the renderable rows and the range columns
11
+ * (data columns, no system or grouping column) in display order, the same coordinates as a cell range.
12
+ */
13
+ export interface GridEditTargetContext<R extends GridRowModel> {
14
+ rows: readonly R[];
15
+ rowIds: GridRowId[];
16
+ rowIndexById: Map<GridRowId, number>;
17
+ /** Range columns in screen order. */
18
+ columns: GridColDef<R>[];
19
+ colIndexByField: Map<string, number>;
20
+ /** Number of system columns before the data columns (for `isCellEditable`'s `colIndex`). */
21
+ dataColumnOffset: number;
22
+ }
23
+
24
+ export function buildEditTargetContext<R extends GridRowModel>(
25
+ rows: readonly R[],
26
+ getRowId: (row: R) => GridRowId,
27
+ navigationColumns: ReadonlyArray<GridColDef<R>>,
28
+ ): GridEditTargetContext<R> {
29
+ const rowIds = rows.map(getRowId);
30
+ const rowIndexById = new Map<GridRowId, number>();
31
+ rowIds.forEach((id, index) => { if (!rowIndexById.has(id)) rowIndexById.set(id, index); });
32
+ const columns = navigationColumns.filter(c => isRangeColumnField(c.field));
33
+ const colIndexByField = new Map<string, number>();
34
+ columns.forEach((c, index) => { colIndexByField.set(c.field, index); });
35
+ const dataColumnOffset = navigationColumns.filter(c => isSystemField(c.field)).length;
36
+ return { rows, rowIds, rowIndexById, columns, colIndexByField, dataColumnOffset };
37
+ }
38
+
39
+ export interface GridEditTargetRules<R extends GridRowModel> {
40
+ isCellEditable?: (params: GridCellParams<R>) => boolean;
41
+ rowMetaMap?: ReadonlyMap<GridRowId, GridRowMeta>;
42
+ /** For a cell covered by a span, the origin cell (`null` otherwise). */
43
+ getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
44
+ }
45
+
46
+ /**
47
+ * Whether a batch edit may write the cell at display position (r, c): a data row (not synthetic),
48
+ * not covered by a span whose origin is another cell, and editable by the same rule as double-click.
49
+ */
50
+ export function isEditTargetWritable<R extends GridRowModel>(
51
+ ctx: GridEditTargetContext<R>,
52
+ rules: GridEditTargetRules<R>,
53
+ row: R,
54
+ r: number,
55
+ c: number,
56
+ ): boolean {
57
+ const id = ctx.rowIds[r];
58
+ const colDef = ctx.columns[c];
59
+ if (!colDef || isSyntheticRowId(id, rules.rowMetaMap)) return false;
60
+ const origin = rules.getSpanOrigin?.(id, colDef.field);
61
+ if (origin && (origin.rowId !== id || origin.field !== colDef.field)) return false;
62
+ return resolveCellEditable({
63
+ row,
64
+ field: colDef.field,
65
+ value: getCellValue(row, colDef.field, colDef),
66
+ colDef,
67
+ rowIndex: r,
68
+ colIndex: ctx.dataColumnOffset + c,
69
+ rowMeta: rules.rowMetaMap?.get(id),
70
+ }, rules.isCellEditable);
71
+ }
72
+
73
+ /** The rectangle around the displayed cells among `cells`, or null when none is displayed. */
74
+ export function boundingTargetRect<R extends GridRowModel>(
75
+ ctx: GridEditTargetContext<R>,
76
+ cells: ReadonlyArray<{ id: GridRowId; field: string }>,
77
+ ): CellRangeRect | null {
78
+ let top = Infinity;
79
+ let bottom = -Infinity;
80
+ let left = Infinity;
81
+ let right = -Infinity;
82
+ for (const cell of cells) {
83
+ const r = ctx.rowIndexById.get(cell.id);
84
+ const c = ctx.colIndexByField.get(cell.field);
85
+ if (r === undefined || c === undefined) continue;
86
+ if (r < top) top = r;
87
+ if (r > bottom) bottom = r;
88
+ if (c < left) left = c;
89
+ if (c > right) right = c;
90
+ }
91
+ return top === Infinity ? null : { top, bottom, left, right };
92
+ }
93
+
94
+ /** The two corner cells of a rectangle. */
95
+ export function rectCorners<R extends GridRowModel>(
96
+ ctx: GridEditTargetContext<R>,
97
+ rect: CellRangeRect,
98
+ ): { anchor: GridCellCoordinates; head: GridCellCoordinates } {
99
+ return {
100
+ anchor: { id: ctx.rowIds[rect.top], field: ctx.columns[rect.left].field },
101
+ head: { id: ctx.rowIds[rect.bottom], field: ctx.columns[rect.right].field },
102
+ };
103
+ }
104
+
105
+ /** Columns whose empty value is `''` rather than `null`. */
106
+ export function isTextColumn(colDef: Pick<GridColDef, 'type'> | undefined): boolean {
107
+ const type = colDef?.type;
108
+ return type === undefined || type === 'string' || type === 'image';
109
+ }
@@ -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
+ }