@opencorestack/opengridx 3.4.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 (76) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +6 -0
  3. package/dist/ai.cjs +2 -1
  4. package/dist/ai.cjs.map +1 -1
  5. package/dist/ai.d.ts +203 -3
  6. package/dist/ai.es.js +2 -1
  7. package/dist/ai.es.js.map +1 -1
  8. package/dist/index.d.ts +248 -4
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +7104 -5726
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -16
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +88 -7
  15. package/docs/README.md +3 -1
  16. package/docs/architecture/datagrid-orchestration.md +9 -4
  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 +3 -1
  21. package/docs/features/ai-toolkit.md +99 -5
  22. package/docs/features/cell-selection.md +8 -2
  23. package/docs/features/fill-handle.md +125 -0
  24. package/docs/features/filtering.md +3 -0
  25. package/docs/features/header-filters.md +138 -0
  26. package/docs/features/keyboard-navigation.md +20 -2
  27. package/docs/roadmap.md +2 -3
  28. package/lib/ai/agentTools.ts +160 -0
  29. package/lib/ai/index.ts +19 -2
  30. package/lib/ai/promptHandler.ts +109 -0
  31. package/lib/ai/types.ts +89 -25
  32. package/lib/ai/validate.ts +10 -0
  33. package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
  34. package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
  35. package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
  36. package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
  37. package/lib/components/Cell/Cell.css +28 -0
  38. package/lib/components/Cell/Cell.tsx +6 -2
  39. package/lib/components/DataGrid/DataGrid.tsx +76 -3
  40. package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
  41. package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
  42. package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
  43. package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
  44. package/lib/components/Header/Header.tsx +44 -23
  45. package/lib/components/Header/headerColumnStyle.ts +28 -0
  46. package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
  47. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
  48. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
  49. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
  50. package/lib/components/Toolbar/GridToolbar.tsx +51 -0
  51. package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
  52. package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
  53. package/lib/hooks/core/useGridApiMethods.ts +39 -0
  54. package/lib/hooks/core/useGridAriaRows.ts +5 -3
  55. package/lib/hooks/core/useGridCellSelection.ts +77 -11
  56. package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
  57. package/lib/hooks/core/useGridControlledState.ts +27 -0
  58. package/lib/hooks/core/useGridFillHandle.ts +323 -0
  59. package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
  60. package/lib/hooks/core/useGridToolbarProps.ts +19 -0
  61. package/lib/hooks/features/useGridAiAssistant.ts +284 -0
  62. package/lib/hooks/features/useGridBatchEdit.ts +2 -2
  63. package/lib/index.ts +3 -0
  64. package/lib/styles/opengridx.css +2 -0
  65. package/lib/theme/DataGridThemeProvider.tsx +2 -0
  66. package/lib/theme/types.ts +2 -0
  67. package/lib/types/index.ts +219 -1
  68. package/lib/utils/aiAssistant.ts +205 -0
  69. package/lib/utils/aiBrand.ts +17 -0
  70. package/lib/utils/cellSelection.ts +25 -5
  71. package/lib/utils/editing/fill.ts +238 -0
  72. package/lib/utils/filtering/index.ts +14 -0
  73. package/lib/utils/focus/index.ts +9 -3
  74. package/lib/utils/headerFilters.ts +189 -0
  75. package/llms.txt +44 -4
  76. package/package.json +1 -1
@@ -0,0 +1,205 @@
1
+ // Pure helpers of the `aiAssistant` panel: which parts of a reply it may apply, what to restore on
2
+ // Undo, the removable chips and what a state looks like without one of them. No AI code: the reply
3
+ // was validated by the `/ai` entry point before it reaches the grid.
4
+ import type {
5
+ GridAiChip,
6
+ GridAiPart,
7
+ GridAiState,
8
+ GridAiValidationError,
9
+ GridApi,
10
+ GridFilterGroup,
11
+ GridFilterItem,
12
+ } from '../types';
13
+
14
+ export const AI_PARTS: readonly GridAiPart[] = ['filter', 'sort', 'grouping', 'aggregation', 'pivot', 'columnVisibility'];
15
+
16
+ const PART_KEYS: Record<GridAiPart, keyof GridAiState> = {
17
+ filter: 'filterModel',
18
+ sort: 'sortModel',
19
+ grouping: 'rowGroupingModel',
20
+ aggregation: 'aggregationModel',
21
+ pivot: 'pivotModel',
22
+ columnVisibility: 'columnVisibilityModel',
23
+ };
24
+
25
+ type StateKey = keyof GridAiState;
26
+
27
+ function keysOf(state: GridAiState): StateKey[] {
28
+ return AI_PARTS.map((part) => PART_KEYS[part]).filter((key) => state[key] !== undefined);
29
+ }
30
+
31
+ /** The parts of `state` that `parts` allows, and an error for each part it does not. */
32
+ export function pickAllowedParts(state: GridAiState, parts: readonly GridAiPart[]): { state: GridAiState; errors: GridAiValidationError[] } {
33
+ const picked: GridAiState = {};
34
+ const errors: GridAiValidationError[] = [];
35
+ for (const part of AI_PARTS) {
36
+ const key = PART_KEYS[part];
37
+ if (state[key] === undefined) continue;
38
+ if (parts.includes(part)) Object.assign(picked, { [key]: state[key] });
39
+ else errors.push({ path: key, message: 'Part not allowed' });
40
+ }
41
+ return { state: picked, errors };
42
+ }
43
+
44
+ function isEmptyPart(state: GridAiState, key: StateKey): boolean {
45
+ const value = state[key];
46
+ if (Array.isArray(value)) return value.length === 0;
47
+ if (key === 'filterModel') {
48
+ const model = state.filterModel;
49
+ return !model || ((model.items ?? []).length === 0 && (model.quickFilterValues ?? []).length === 0);
50
+ }
51
+ if (key === 'pivotModel') {
52
+ const model = state.pivotModel;
53
+ return !model || (model.rowFields.length === 0 && model.columnFields.length === 0 && model.valueFields.length === 0);
54
+ }
55
+ return typeof value === 'object' && value !== null && Object.keys(value).length === 0;
56
+ }
57
+
58
+ /** The state key an error path starts with (`'sortModel[0].field'` → `'sortModel'`). */
59
+ function errorKey(path: string): string {
60
+ let end = path.length;
61
+ for (const sep of ['.', '[']) {
62
+ const at = path.indexOf(sep);
63
+ if (at >= 0 && at < end) end = at;
64
+ }
65
+ return path.slice(0, end);
66
+ }
67
+
68
+ /**
69
+ * Leaves out a part that is empty only because the validator dropped everything in it (`sortModel`
70
+ * naming an unknown field comes back as `[]`): applying it would clear the grid's model, which the
71
+ * reply did not ask for. An empty part without errors ("clear the sort") is kept.
72
+ */
73
+ export function dropEmptiedParts(state: GridAiState, errors: readonly GridAiValidationError[]): GridAiState {
74
+ const failed = new Set(errors.map((error) => errorKey(typeof error.path === 'string' ? error.path : '')));
75
+ const out: GridAiState = {};
76
+ for (const key of keysOf(state)) {
77
+ if (!(failed.has(key) && isEmptyPart(state, key))) Object.assign(out, { [key]: state[key] });
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** The current values of the parts `state` changes: what Undo restores. */
83
+ export function snapshotParts(state: GridAiState, current: GridAiState): GridAiState {
84
+ const snapshot: GridAiState = {};
85
+ for (const key of keysOf(state)) Object.assign(snapshot, { [key]: current[key] });
86
+ return snapshot;
87
+ }
88
+
89
+ /**
90
+ * The models to apply for `applied` over `previous` (the snapshot taken before the reply): column
91
+ * visibility is merged over the previous model; a part removed from `applied` gets its previous value back.
92
+ */
93
+ export function effectiveState(applied: GridAiState, previous: GridAiState): GridAiState {
94
+ const out: GridAiState = {};
95
+ for (const key of AI_PARTS.map((part) => PART_KEYS[part])) {
96
+ if (!(key in previous) && applied[key] === undefined) continue;
97
+ if (applied[key] === undefined) Object.assign(out, { [key]: previous[key] });
98
+ else if (key === 'columnVisibilityModel') out.columnVisibilityModel = { ...previous.columnVisibilityModel, ...applied.columnVisibilityModel };
99
+ else Object.assign(out, { [key]: applied[key] });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ type GridAiStateSetters = Pick<GridApi, 'setFilterModel' | 'setSortModel' | 'setRowGroupingModel' | 'setAggregationModel' | 'setPivotModel' | 'setColumnVisibilityModel'>;
105
+
106
+ /** Applies every part present in `state` through the API (each fires its change callback). */
107
+ export function applyGridAiState(api: GridAiStateSetters, state: GridAiState): void {
108
+ if (state.filterModel) api.setFilterModel(state.filterModel);
109
+ if (state.sortModel) api.setSortModel(state.sortModel);
110
+ if (state.rowGroupingModel) api.setRowGroupingModel(state.rowGroupingModel);
111
+ if (state.aggregationModel) api.setAggregationModel(state.aggregationModel);
112
+ if (state.pivotModel) api.setPivotModel(state.pivotModel);
113
+ if (state.columnVisibilityModel) api.setColumnVisibilityModel(state.columnVisibilityModel);
114
+ }
115
+
116
+ function valueText(value: unknown): string {
117
+ if (Array.isArray(value)) return value.map(valueText).join(', ');
118
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
119
+ return value === undefined || value === null ? '' : String(value);
120
+ }
121
+
122
+ function filterLabel(node: GridFilterItem | GridFilterGroup, label: (field: string) => string): string {
123
+ if ('items' in node) return `(${node.items.map((child) => filterLabel(child, label)).join(` ${node.logicOperator} `)})`;
124
+ return [label(node.field), node.operator, valueText(node.value)].filter(Boolean).join(' ');
125
+ }
126
+
127
+ const FN_LABELS: Record<string, string> = { sum: 'Sum', avg: 'Average', count: 'Count', min: 'Min', max: 'Max', unique: 'Unique' };
128
+
129
+ /** One chip per change: filter item, quick-filter words, sort key, grouping field, aggregation, visibility change, pivot. */
130
+ export function buildAiChips(applied: GridAiState, label: (field: string) => string): GridAiChip[] {
131
+ const chips: GridAiChip[] = [];
132
+ const { filterModel, sortModel, rowGroupingModel, aggregationModel, pivotModel, columnVisibilityModel } = applied;
133
+ if (filterModel) {
134
+ const items = filterModel.items ?? [];
135
+ items.forEach((item, i) => chips.push({ id: `filter:${i}`, part: 'filter', label: filterLabel(item, label) }));
136
+ const words = filterModel.quickFilterValues ?? [];
137
+ if (words.length > 0) chips.push({ id: 'filter:quick', part: 'filter', label: `Search: ${words.join(' ')}` });
138
+ if (items.length === 0 && words.length === 0) chips.push({ id: 'filter:clear', part: 'filter', label: 'No filters' });
139
+ }
140
+ if (sortModel) {
141
+ sortModel.forEach((s, i) => chips.push({ id: `sort:${i}`, part: 'sort', label: `Sort: ${label(s.field)}, ${s.sort === 'desc' ? 'descending' : 'ascending'}` }));
142
+ if (sortModel.length === 0) chips.push({ id: 'sort:clear', part: 'sort', label: 'No sorting' });
143
+ }
144
+ if (rowGroupingModel) {
145
+ rowGroupingModel.forEach((field, i) => chips.push({ id: `grouping:${i}`, part: 'grouping', label: `Group: ${label(field)}` }));
146
+ if (rowGroupingModel.length === 0) chips.push({ id: 'grouping:clear', part: 'grouping', label: 'No grouping' });
147
+ }
148
+ if (aggregationModel) {
149
+ const fields = Object.keys(aggregationModel);
150
+ fields.forEach((field) => {
151
+ const fn = aggregationModel[field];
152
+ chips.push({ id: `aggregation:${field}`, part: 'aggregation', label: `${FN_LABELS[fn] ?? fn}: ${label(field)}` });
153
+ });
154
+ if (fields.length === 0) chips.push({ id: 'aggregation:clear', part: 'aggregation', label: 'No summaries' });
155
+ }
156
+ if (pivotModel) {
157
+ const fields = [...pivotModel.rowFields, ...pivotModel.columnFields, ...pivotModel.valueFields.map((v) => v.field)];
158
+ chips.push({ id: 'pivot', part: 'pivot', label: fields.length > 0 ? `Pivot: ${fields.map(label).join(', ')}` : 'No pivot' });
159
+ }
160
+ if (columnVisibilityModel) {
161
+ for (const field of Object.keys(columnVisibilityModel)) {
162
+ chips.push({ id: `columnVisibility:${field}`, part: 'columnVisibility', label: `${columnVisibilityModel[field] ? 'Show' : 'Hide'}: ${label(field)}` });
163
+ }
164
+ }
165
+ return chips;
166
+ }
167
+
168
+ /**
169
+ * `applied` without the change a chip stands for. A part left with no change is dropped, so
170
+ * `effectiveState` gives it its value from before the reply.
171
+ */
172
+ export function removeAiChip(applied: GridAiState, id: string): GridAiState {
173
+ const next: GridAiState = { ...applied };
174
+ const sep = id.indexOf(':');
175
+ const part = sep < 0 ? id : id.slice(0, sep);
176
+ const rest = sep < 0 ? '' : id.slice(sep + 1);
177
+ const index = Number(rest);
178
+ if (part === 'filter' && next.filterModel) {
179
+ const model = { ...next.filterModel };
180
+ if (rest === 'quick') delete model.quickFilterValues;
181
+ else if (rest === 'clear') model.items = [];
182
+ else model.items = (model.items ?? []).filter((_, i) => i !== index);
183
+ if ((model.items ?? []).length === 0 && (model.quickFilterValues ?? []).length === 0) delete next.filterModel;
184
+ else next.filterModel = model;
185
+ } else if (part === 'sort' && next.sortModel) {
186
+ next.sortModel = next.sortModel.filter((_, i) => i !== index);
187
+ if (next.sortModel.length === 0 || rest === 'clear') delete next.sortModel;
188
+ } else if (part === 'grouping' && next.rowGroupingModel) {
189
+ next.rowGroupingModel = next.rowGroupingModel.filter((_, i) => i !== index);
190
+ if (next.rowGroupingModel.length === 0 || rest === 'clear') delete next.rowGroupingModel;
191
+ } else if (part === 'aggregation' && next.aggregationModel) {
192
+ const model = { ...next.aggregationModel };
193
+ delete model[rest];
194
+ if (Object.keys(model).length === 0) delete next.aggregationModel;
195
+ else next.aggregationModel = model;
196
+ } else if (part === 'columnVisibility' && next.columnVisibilityModel) {
197
+ const model = { ...next.columnVisibilityModel };
198
+ delete model[rest];
199
+ if (Object.keys(model).length === 0) delete next.columnVisibilityModel;
200
+ else next.columnVisibilityModel = model;
201
+ } else if (part === 'pivot') {
202
+ delete next.pivotModel;
203
+ }
204
+ return next;
205
+ }
@@ -0,0 +1,17 @@
1
+ // The "validated" brand of the AI toolkit. `validateGridAiState` (in the `/ai` entry point) puts it on
2
+ // its result and on the result's `state`; the grid's `aiAssistant` applies a state only when it carries
3
+ // it. Shared by both bundles, so it holds no AI code: just the registered symbol and two helpers.
4
+
5
+ /** `Symbol.for('opengridx.ai.validated')`: the same symbol in every bundle and realm. */
6
+ export const GRID_AI_VALIDATED_BRAND: symbol = Symbol.for('opengridx.ai.validated');
7
+
8
+ /** Brands `value` (non-enumerable, so it does not change equality, spreading or JSON) and returns it. */
9
+ export function brandGridAiValidated<T extends object>(value: T): T {
10
+ Object.defineProperty(value, GRID_AI_VALIDATED_BRAND, { value: true, enumerable: false });
11
+ return value;
12
+ }
13
+
14
+ /** Whether `value` carries the brand. */
15
+ export function isGridAiValidated(value: unknown): boolean {
16
+ return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[GRID_AI_VALIDATED_BRAND] === true;
17
+ }
@@ -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,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;
@@ -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;