@opencorestack/opengridx 3.3.0 → 3.5.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 (101) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +10 -1
  3. package/dist/ai.cjs +2 -0
  4. package/dist/ai.cjs.map +1 -0
  5. package/dist/ai.d.ts +366 -0
  6. package/dist/ai.es.js +2 -0
  7. package/dist/ai.es.js.map +1 -0
  8. package/dist/index.d.ts +396 -6
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +8029 -6090
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -14
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +182 -4
  15. package/docs/README.md +5 -1
  16. package/docs/architecture/datagrid-orchestration.md +19 -5
  17. package/docs/components/filter-panel.md +1 -0
  18. package/docs/components/header.md +4 -1
  19. package/docs/components/toolbar.md +7 -0
  20. package/docs/customization/theming.md +12 -1
  21. package/docs/features/ai-toolkit.md +213 -0
  22. package/docs/features/cell-selection.md +8 -2
  23. package/docs/features/clipboard.md +88 -0
  24. package/docs/features/editing-reordering.md +50 -0
  25. package/docs/features/fill-handle.md +125 -0
  26. package/docs/features/filtering.md +3 -0
  27. package/docs/features/header-filters.md +138 -0
  28. package/docs/features/keyboard-navigation.md +31 -2
  29. package/docs/features/undo-redo.md +79 -0
  30. package/docs/roadmap.md +3 -4
  31. package/lib/ai/agentTools.ts +160 -0
  32. package/lib/ai/coerce.ts +90 -0
  33. package/lib/ai/columns.ts +90 -0
  34. package/lib/ai/index.ts +33 -0
  35. package/lib/ai/promptHandler.ts +109 -0
  36. package/lib/ai/schema.ts +214 -0
  37. package/lib/ai/types.ts +152 -0
  38. package/lib/ai/validate.ts +296 -0
  39. package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
  40. package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
  41. package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
  42. package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
  43. package/lib/components/Cell/Cell.css +33 -0
  44. package/lib/components/Cell/Cell.tsx +6 -2
  45. package/lib/components/Cell/GridEditInputCell.css +12 -4
  46. package/lib/components/Cell/GridEditInputCell.tsx +19 -12
  47. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
  48. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  49. package/lib/components/DataGrid/DataGrid.tsx +138 -5
  50. package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
  51. package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
  52. package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
  53. package/lib/components/FilterPanel/FilterPanel.css +14 -0
  54. package/lib/components/FilterPanel/FilterPanel.tsx +5 -11
  55. package/lib/components/Header/Header.tsx +44 -23
  56. package/lib/components/Header/headerColumnStyle.ts +28 -0
  57. package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
  58. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
  59. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
  60. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
  61. package/lib/components/ListView/ListView.css +2 -4
  62. package/lib/components/ListView/ListViewRow.tsx +2 -2
  63. package/lib/components/Toolbar/GlobalSearch.css +16 -0
  64. package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
  65. package/lib/components/Toolbar/GridToolbar.tsx +51 -0
  66. package/lib/components/ui/Checkbox.tsx +16 -5
  67. package/lib/components/ui/Input.css +43 -0
  68. package/lib/components/ui/Input.tsx +24 -5
  69. package/lib/hooks/core/gridApiPlaceholder.ts +16 -1
  70. package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
  71. package/lib/hooks/core/useDataGrid.ts +27 -0
  72. package/lib/hooks/core/useGridApiMethods.ts +39 -0
  73. package/lib/hooks/core/useGridAriaRows.ts +5 -3
  74. package/lib/hooks/core/useGridCellSelection.ts +107 -13
  75. package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
  76. package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
  77. package/lib/hooks/core/useGridControlledState.ts +27 -0
  78. package/lib/hooks/core/useGridFillHandle.ts +323 -0
  79. package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
  80. package/lib/hooks/core/useGridToolbarProps.ts +19 -0
  81. package/lib/hooks/core/useGridUndoRedo.ts +220 -0
  82. package/lib/hooks/features/useGridAiAssistant.ts +284 -0
  83. package/lib/hooks/features/useGridBatchEdit.ts +217 -0
  84. package/lib/hooks/features/useGridEditing.ts +24 -45
  85. package/lib/index.ts +10 -0
  86. package/lib/styles/opengridx.css +4 -1
  87. package/lib/theme/DataGridThemeProvider.tsx +2 -0
  88. package/lib/theme/types.ts +2 -0
  89. package/lib/types/index.ts +343 -1
  90. package/lib/utils/aiAssistant.ts +205 -0
  91. package/lib/utils/aiBrand.ts +17 -0
  92. package/lib/utils/cellSelection.ts +25 -5
  93. package/lib/utils/editing/commit.ts +69 -0
  94. package/lib/utils/editing/fill.ts +238 -0
  95. package/lib/utils/editing/targets.ts +109 -0
  96. package/lib/utils/filtering/index.ts +14 -0
  97. package/lib/utils/focus/index.ts +9 -3
  98. package/lib/utils/headerFilters.ts +189 -0
  99. package/lib/utils/parsing.ts +252 -0
  100. package/llms.txt +97 -3
  101. package/package.json +8 -2
@@ -184,26 +184,43 @@ export interface GridRowCellRange {
184
184
  isBottom: boolean;
185
185
  /** Rows from this one to the bottom of the range; only set (else 0) when the grid has row spans. */
186
186
  bottomOffset: number;
187
+ /** Data column index (`columnIndexMap`) of the cell carrying the fill handle; only set on its row. @since v3.5 */
188
+ fillHandleCol?: number;
189
+ }
190
+
191
+ /** Where the fill handle is drawn: a renderable row index and a data column index (`columnIndexMap`). */
192
+ export interface GridFillHandlePosition {
193
+ row: number;
194
+ col: number;
187
195
  }
188
196
 
189
197
  export function getRowCellRange(
190
198
  range: { top: number; bottom: number; left: number; right: number } | null | undefined,
191
199
  rowIndex: number,
192
200
  hasRowSpan: boolean,
201
+ fillHandle?: GridFillHandlePosition | null,
193
202
  ): GridRowCellRange | null {
194
- if (!range || rowIndex < range.top || rowIndex > range.bottom) return null;
195
- return {
203
+ const handleCol = fillHandle && fillHandle.row === rowIndex ? fillHandle.col : undefined;
204
+ if (!range || rowIndex < range.top || rowIndex > range.bottom) {
205
+ // A single selected cell draws no range, only the handle: an empty column interval.
206
+ return handleCol === undefined
207
+ ? null
208
+ : { left: 0, right: -1, isTop: false, isBottom: false, bottomOffset: 0, fillHandleCol: handleCol };
209
+ }
210
+ const rowRange: GridRowCellRange = {
196
211
  left: range.left,
197
212
  right: range.right,
198
213
  isTop: rowIndex === range.top,
199
214
  isBottom: rowIndex === range.bottom,
200
215
  bottomOffset: hasRowSpan ? range.bottom - rowIndex : 0,
201
216
  };
217
+ if (handleCol !== undefined) rowRange.fillHandleCol = handleCol;
218
+ return rowRange;
202
219
  }
203
220
 
204
221
  export const isSameRowCellRange = (a: GridRowCellRange | null | undefined, b: GridRowCellRange | null | undefined): boolean =>
205
222
  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);
223
+ && a.bottomOffset === b.bottomOffset && a.fillHandleCol === b.fillHandleCol);
207
224
 
208
225
  /** Bit flags a cell receives for its place in the range (a number keeps memoised cells stable). */
209
226
  export const RANGE_CELL = 1;
@@ -211,6 +228,8 @@ export const RANGE_TOP = 2;
211
228
  export const RANGE_BOTTOM = 4;
212
229
  export const RANGE_LEFT = 8;
213
230
  export const RANGE_RIGHT = 16;
231
+ /** The cell carries the fill handle (v3.5). */
232
+ export const RANGE_FILL_HANDLE = 32;
214
233
 
215
234
  /**
216
235
  * The flags of the cell at data column `colIndex` (spanning `colSpan` columns and `rowSpan` rows from
@@ -218,9 +237,10 @@ export const RANGE_RIGHT = 16;
218
237
  */
219
238
  export function getCellRangeFlags(rowRange: GridRowCellRange | null | undefined, colIndex: number, colSpan: number, rowSpan: number): number {
220
239
  if (!rowRange) return 0;
240
+ const handle = rowRange.fillHandleCol === colIndex ? RANGE_FILL_HANDLE : 0;
221
241
  const lastCol = colIndex + Math.max(1, colSpan) - 1;
222
- if (colIndex < rowRange.left || lastCol > rowRange.right) return 0;
223
- let flags = RANGE_CELL;
242
+ if (colIndex < rowRange.left || lastCol > rowRange.right) return handle;
243
+ let flags = RANGE_CELL | handle;
224
244
  if (rowRange.isTop) flags |= RANGE_TOP;
225
245
  if (rowRange.isBottom || (rowSpan > 1 && rowSpan - 1 >= rowRange.bottomOffset)) flags |= RANGE_BOTTOM;
226
246
  if (colIndex === rowRange.left) flags |= RANGE_LEFT;
@@ -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,238 @@
1
+ import type { GridFillDirection, GridRowId } from '../../types';
2
+ import type { CellRangeRect } from '../cellSelection';
3
+
4
+ /**
5
+ * Pure helpers of the fill handle (v3.5): what a fill writes (series detection) and where (the target
6
+ * rectangle the pointer asks for, the cells of each line).
7
+ */
8
+
9
+ /** Digits after the decimal point of a finite number, or null for exponent notation. */
10
+ function decimalPlaces(value: number): number | null {
11
+ const text = String(value);
12
+ if (text.includes('e') || text.includes('E')) return null;
13
+ const dot = text.indexOf('.');
14
+ return dot === -1 ? 0 : text.length - dot - 1;
15
+ }
16
+
17
+ /** Rounds to `places` decimals (no rounding for null), so 0.1 + 0.2 reads 0.3. */
18
+ function roundTo(value: number, places: number | null): number {
19
+ if (places === null) return value;
20
+ return Number(value.toFixed(Math.min(places, 20)));
21
+ }
22
+
23
+ function numberSeries(source: readonly number[], count: number): number[] | null {
24
+ let places: number | null = 0;
25
+ for (const value of source) {
26
+ const p = decimalPlaces(value);
27
+ places = p === null || places === null ? null : Math.max(places, p);
28
+ }
29
+ const step = roundTo(source[1] - source[0], places);
30
+ for (let i = 2; i < source.length; i++) {
31
+ if (roundTo(source[i] - source[i - 1], places) !== step) return null;
32
+ }
33
+ const last = source[source.length - 1];
34
+ const out: number[] = [];
35
+ for (let k = 1; k <= count; k++) out.push(roundTo(last + step * k, places));
36
+ return out;
37
+ }
38
+
39
+ const MS_PER_DAY = 86_400_000;
40
+ const timeOfDay = (d: Date) => ((d.getHours() * 60 + d.getMinutes()) * 60 + d.getSeconds()) * 1000 + d.getMilliseconds();
41
+ const dayNumber = (d: Date) => Math.round(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / MS_PER_DAY);
42
+ const monthNumber = (d: Date) => d.getFullYear() * 12 + d.getMonth();
43
+ const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
44
+
45
+ /** The equal step between consecutive keys, or null. */
46
+ function equalStep(keys: readonly number[]): number | null {
47
+ const step = keys[1] - keys[0];
48
+ for (let i = 2; i < keys.length; i++) {
49
+ if (keys[i] - keys[i - 1] !== step) return null;
50
+ }
51
+ return step;
52
+ }
53
+
54
+ function dateSeries(source: readonly Date[], count: number): Date[] | null {
55
+ const last = source[source.length - 1];
56
+ const sameTime = source.every(d => timeOfDay(d) === timeOfDay(last));
57
+ // Same day of the month, months apart by an equal step: continue by months (31 Jan + 1 month is
58
+ // the last day of February).
59
+ if (sameTime && source.every(d => d.getDate() === last.getDate())) {
60
+ const step = equalStep(source.map(monthNumber));
61
+ if (step !== null && step !== 0) {
62
+ const out: Date[] = [];
63
+ for (let k = 1; k <= count; k++) {
64
+ const month = last.getMonth() + step * k;
65
+ const year = last.getFullYear() + Math.floor(month / 12);
66
+ const m = ((month % 12) + 12) % 12;
67
+ const date = new Date(last.getTime());
68
+ date.setFullYear(year, m, Math.min(last.getDate(), daysInMonth(year, m)));
69
+ out.push(date);
70
+ }
71
+ return out;
72
+ }
73
+ }
74
+ // Whole days apart (calendar days, so a daylight-saving change does not break the step).
75
+ if (sameTime) {
76
+ const step = equalStep(source.map(dayNumber));
77
+ if (step === null) return null;
78
+ const out: Date[] = [];
79
+ for (let k = 1; k <= count; k++) {
80
+ const date = new Date(last.getTime());
81
+ date.setDate(last.getDate() + step * k);
82
+ out.push(date);
83
+ }
84
+ return out;
85
+ }
86
+ const step = equalStep(source.map(d => d.getTime()));
87
+ if (step === null) return null;
88
+ const out: Date[] = [];
89
+ for (let k = 1; k <= count; k++) out.push(new Date(last.getTime() + step * k));
90
+ return out;
91
+ }
92
+
93
+ const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
94
+ const isValidDate = (value: unknown): value is Date => value instanceof Date && !Number.isNaN(value.getTime());
95
+
96
+ /**
97
+ * The continued series of `source` (two or more numbers, or two or more dates, with an exactly equal
98
+ * step), `count` values long, or null when `source` is not such a series.
99
+ */
100
+ export function detectFillSeries(source: readonly unknown[], count: number): unknown[] | null {
101
+ if (source.length < 2) return null;
102
+ if (source.every(isFiniteNumber)) return numberSeries(source, count);
103
+ if (source.every(isValidDate)) return dateSeries(source, count);
104
+ return null;
105
+ }
106
+
107
+ /** A value repeated into another cell: dates are copied, so cells never share one Date object. */
108
+ const cloneValue = (value: unknown): unknown => (value instanceof Date ? new Date(value.getTime()) : value);
109
+
110
+ /**
111
+ * The `count` values a fill writes after `source` (in fill order): the continued series when `series`
112
+ * is allowed and `source` is one, otherwise `source` repeated (one value: a copy).
113
+ */
114
+ export function computeFillValues(source: readonly unknown[], count: number, series: boolean): unknown[] {
115
+ if (source.length === 0 || count <= 0) return [];
116
+ const continued = series ? detectFillSeries(source, count) : null;
117
+ if (continued) return continued;
118
+ const out: unknown[] = [];
119
+ for (let k = 0; k < count; k++) out.push(cloneValue(source[k % source.length]));
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Where a fill-handle drag reaches: the source rectangle extended in one direction to the cell under
125
+ * the pointer, or null while the pointer is over the source. When the cell lies beyond the source
126
+ * both down/up and right/left, the larger pointer offset (px, `dx` / `dy` from where the drag
127
+ * started) decides between vertical and horizontal.
128
+ */
129
+ export function fillTargetFromPointer(
130
+ source: CellRangeRect,
131
+ head: { row: number; col: number },
132
+ dx: number,
133
+ dy: number,
134
+ ): { rect: CellRangeRect; direction: GridFillDirection } | null {
135
+ const outVertical = head.row > source.bottom || head.row < source.top;
136
+ const outHorizontal = head.col > source.right || head.col < source.left;
137
+ if (!outVertical && !outHorizontal) return null;
138
+ const vertical = outVertical && (!outHorizontal || Math.abs(dy) >= Math.abs(dx));
139
+ if (vertical) {
140
+ return head.row > source.bottom
141
+ ? { rect: { ...source, bottom: head.row }, direction: 'down' }
142
+ : { rect: { ...source, top: head.row }, direction: 'up' };
143
+ }
144
+ return head.col > source.right
145
+ ? { rect: { ...source, right: head.col }, direction: 'right' }
146
+ : { rect: { ...source, left: head.col }, direction: 'left' };
147
+ }
148
+
149
+ /** One line of a fill: the source positions in fill order and the positions it fills, in order. */
150
+ export interface FillLine {
151
+ /** Row index for a vertical fill, column index for a horizontal one. */
152
+ line: number;
153
+ source: number[];
154
+ targets: number[];
155
+ }
156
+
157
+ /** The lines (columns for a vertical fill, rows for a horizontal one) of a fill from `source` to `target`. */
158
+ export function fillLines(source: CellRangeRect, target: CellRangeRect, direction: GridFillDirection): FillLine[] {
159
+ const range = (from: number, to: number) => {
160
+ const out: number[] = [];
161
+ if (from <= to) for (let i = from; i <= to; i++) out.push(i);
162
+ else for (let i = from; i >= to; i--) out.push(i);
163
+ return out;
164
+ };
165
+ const vertical = direction === 'down' || direction === 'up';
166
+ const lines = vertical ? range(source.left, source.right) : range(source.top, source.bottom);
167
+ let sourceOrder: number[];
168
+ let targets: number[];
169
+ switch (direction) {
170
+ case 'down':
171
+ sourceOrder = range(source.top, source.bottom);
172
+ targets = target.bottom > source.bottom ? range(source.bottom + 1, target.bottom) : [];
173
+ break;
174
+ case 'up':
175
+ sourceOrder = range(source.bottom, source.top);
176
+ targets = target.top < source.top ? range(source.top - 1, target.top) : [];
177
+ break;
178
+ case 'right':
179
+ sourceOrder = range(source.left, source.right);
180
+ targets = target.right > source.right ? range(source.right + 1, target.right) : [];
181
+ break;
182
+ default:
183
+ sourceOrder = range(source.right, source.left);
184
+ targets = target.left < source.left ? range(source.left - 1, target.left) : [];
185
+ }
186
+ return lines.map(line => ({ line, source: sourceOrder, targets }));
187
+ }
188
+
189
+ /**
190
+ * The corners the range takes after a fill (or during a fill drag): the original anchor when it is a
191
+ * corner of `rect`, otherwise the corner on the source side of the fill, so focus stays near it.
192
+ */
193
+ export function fillRangeCorners(
194
+ rect: CellRangeRect,
195
+ direction: GridFillDirection,
196
+ anchor: { row: number; col: number },
197
+ ): { anchor: { row: number; col: number }; head: { row: number; col: number } } {
198
+ const isCornerRow = anchor.row === rect.top || anchor.row === rect.bottom;
199
+ const isCornerCol = anchor.col === rect.left || anchor.col === rect.right;
200
+ let row = anchor.row;
201
+ let col = anchor.col;
202
+ if (!isCornerRow || !isCornerCol) {
203
+ const vertical = direction === 'down' || direction === 'up';
204
+ if (vertical) {
205
+ row = direction === 'down' ? rect.top : rect.bottom;
206
+ col = anchor.col === rect.right ? rect.right : rect.left;
207
+ } else {
208
+ col = direction === 'right' ? rect.left : rect.right;
209
+ row = anchor.row === rect.bottom ? rect.bottom : rect.top;
210
+ }
211
+ }
212
+ return {
213
+ anchor: { row, col },
214
+ head: { row: row === rect.top ? rect.bottom : rect.top, col: col === rect.left ? rect.right : rect.left },
215
+ };
216
+ }
217
+
218
+ /** The cell the fill handle sits on: the bottom-right cell of the range, or the origin of the span covering it. */
219
+ export function fillHandleCell(
220
+ rect: CellRangeRect,
221
+ rowIdAt: (row: number) => GridRowId | undefined,
222
+ fields: readonly string[],
223
+ getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null,
224
+ ): { row: number; field: string } | null {
225
+ const rowId = rowIdAt(rect.bottom);
226
+ const field = fields[rect.right];
227
+ if (rowId === undefined || field === undefined) return null;
228
+ const origin = getSpanOrigin?.(rowId, field);
229
+ if (!origin || (origin.rowId === rowId && origin.field === field)) return { row: rect.bottom, field };
230
+ // A span covers the corner: the handle goes on the cell that renders it, a few rows up.
231
+ for (let r = rect.bottom; r >= Math.max(0, rect.bottom - MAX_SPAN_LOOKBACK); r--) {
232
+ if (rowIdAt(r) === origin.rowId) return { row: r, field: origin.field };
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /** How far up a span origin is looked for. */
238
+ const MAX_SPAN_LOOKBACK = 1000;
@@ -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
+ }
@@ -367,3 +367,17 @@ export function getOperatorsForType(type?: string): GridFilterOperator[] {
367
367
  return ['contains', 'equals', 'startsWith', 'endsWith', 'isEmpty', 'isNotEmpty'];
368
368
  }
369
369
  }
370
+
371
+ /**
372
+ * The text an input shows for a committed filter value: dates as `YYYY-MM-DD`, lists joined with
373
+ * commas. Never used to emit anything (the filter panel and the header filter row).
374
+ */
375
+ export function filterValueToInputText(value: unknown, colType?: string): string {
376
+ if (value == null) return '';
377
+ if (colType === 'date' || value instanceof Date) {
378
+ const day = toLocalDateString(value);
379
+ return day || (typeof value === 'string' ? value : '');
380
+ }
381
+ if (Array.isArray(value)) return value.map(v => String(v)).join(', ');
382
+ return String(value);
383
+ }
@@ -13,12 +13,14 @@ export interface FocusedCell {
13
13
  /** Row id of the focused body cell, or `null` when a column header cell is focused. */
14
14
  id: GridRowId | null;
15
15
  field: string;
16
+ /** With `id: null`: the cell of the header filter row (`headerFilters`), not the column header. */
17
+ headerFilter?: boolean;
16
18
  /** Index in the renderable rows when focus was set; used to pick a neighbour if the row disappears. */
17
19
  rowIndex?: number;
18
20
  }
19
21
 
20
22
  export const isSameCell = (a: FocusedCell | null, b: FocusedCell | null): boolean =>
21
- a === b || (a !== null && b !== null && a.id === b.id && a.field === b.field);
23
+ a === b || (a !== null && b !== null && a.id === b.id && a.field === b.field && !a.headerFilter === !b.headerFilter);
22
24
 
23
25
  /**
24
26
  * Where a keydown came from, from the grid's point of view:
@@ -81,7 +83,9 @@ export function focusedCellFromElement<T>(
81
83
  const field = cell?.dataset.field;
82
84
  if (!cell || !field) return null;
83
85
  if (cell.getAttribute('role') === 'columnheader') {
84
- return cell.parentElement?.classList.contains('ogx__header') ? { id: null, field } : null;
86
+ const headerRow = cell.parentElement;
87
+ if (headerRow?.classList.contains('ogx__header')) return { id: null, field };
88
+ return headerRow?.classList.contains('ogx__header-filter-row') ? { id: null, field, headerFilter: true } : null;
85
89
  }
86
90
  const rowIndex = Number(cell.parentElement?.getAttribute('data-rowindex'));
87
91
  const row = Number.isInteger(rowIndex) ? rows[rowIndex] : undefined;
@@ -100,7 +104,9 @@ function childWithField(parent: Element | null, field: string): HTMLElement | nu
100
104
  export function findFocusTarget(viewport: HTMLElement, cell: FocusedCell, rowIndex: number): HTMLElement | null {
101
105
  if (cell.id === null) {
102
106
  // The header sits in the sticky top block together with the top-pinned rows.
103
- const header = viewport.querySelector(':scope > .ogx__content > .ogx__sticky-top > .ogx__header-wrap > .ogx__header');
107
+ const header = viewport.querySelector(
108
+ `:scope > .ogx__content > .ogx__sticky-top > .ogx__header-wrap > ${cell.headerFilter ? '.ogx__header-filter-row' : '.ogx__header'}`
109
+ );
104
110
  const el = childWithField(header, cell.field);
105
111
  if (el && cell.field === CHECKBOX_FIELD) return el.querySelector('input') ?? el;
106
112
  return el;