@opencorestack/opengridx 3.4.0 → 3.5.1
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.
- package/CHANGELOG.md +45 -0
- package/README.md +10 -3
- package/dist/ai.cjs +2 -1
- package/dist/ai.cjs.map +1 -1
- package/dist/ai.d.ts +203 -3
- package/dist/ai.es.js +2 -1
- package/dist/ai.es.js.map +1 -1
- package/dist/index.d.ts +248 -4
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +7334 -5906
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -16
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +88 -7
- package/docs/README.md +3 -1
- package/docs/architecture/datagrid-orchestration.md +11 -5
- package/docs/components/filter-panel.md +1 -0
- package/docs/components/header.md +4 -1
- package/docs/components/toolbar.md +7 -0
- package/docs/customization/theming.md +3 -1
- package/docs/features/ai-toolkit.md +99 -5
- package/docs/features/cell-selection.md +8 -2
- package/docs/features/fill-handle.md +125 -0
- package/docs/features/filtering.md +3 -0
- package/docs/features/header-filters.md +138 -0
- package/docs/features/keyboard-navigation.md +20 -2
- package/docs/features/virtualization.md +5 -5
- package/docs/performance.md +212 -1
- package/docs/roadmap.md +3 -4
- package/lib/ai/agentTools.ts +160 -0
- package/lib/ai/index.ts +19 -2
- package/lib/ai/promptHandler.ts +109 -0
- package/lib/ai/types.ts +89 -25
- package/lib/ai/validate.ts +10 -0
- package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
- package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
- package/lib/components/Cell/Cell.css +28 -0
- package/lib/components/Cell/Cell.tsx +6 -2
- package/lib/components/DataGrid/DataGrid.tsx +84 -4
- package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
- package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
- package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
- package/lib/components/Header/Header.tsx +44 -23
- package/lib/components/Header/headerColumnStyle.ts +28 -0
- package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
- package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
- package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
- package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
- package/lib/components/Toolbar/GridToolbar.tsx +51 -0
- package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
- package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
- package/lib/hooks/core/useGridApiMethods.ts +39 -0
- package/lib/hooks/core/useGridAriaRows.ts +5 -3
- package/lib/hooks/core/useGridCellSelection.ts +77 -11
- package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
- package/lib/hooks/core/useGridControlledState.ts +27 -0
- package/lib/hooks/core/useGridDevWarnings.ts +21 -6
- package/lib/hooks/core/useGridFillHandle.ts +323 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
- package/lib/hooks/core/useGridMaxContentHeight.ts +16 -0
- package/lib/hooks/core/useGridToolbarProps.ts +19 -0
- package/lib/hooks/core/useGridVirtualization.ts +19 -0
- package/lib/hooks/features/useGridAiAssistant.ts +284 -0
- package/lib/hooks/features/useGridBatchEdit.ts +2 -2
- package/lib/index.ts +3 -0
- package/lib/styles/opengridx.css +2 -0
- package/lib/theme/DataGridThemeProvider.tsx +2 -0
- package/lib/theme/types.ts +2 -0
- package/lib/types/index.ts +219 -1
- package/lib/utils/aiAssistant.ts +205 -0
- package/lib/utils/aiBrand.ts +17 -0
- package/lib/utils/cellSelection.ts +25 -5
- package/lib/utils/editing/fill.ts +238 -0
- package/lib/utils/filtering/index.ts +14 -0
- package/lib/utils/focus/index.ts +9 -3
- package/lib/utils/headerFilters.ts +189 -0
- package/lib/utils/maxElementHeight.ts +53 -0
- package/llms.txt +44 -4
- package/package.json +3 -2
|
@@ -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
|
+
}
|
package/lib/utils/focus/index.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { GridColDef, GridFilterGroup, GridFilterItem, GridFilterModel, GridFilterOperator } from '../types';
|
|
2
|
+
import { NO_VALUE_OPERATORS, getOperatorsForType, isEmptyFilterValue } from './filtering';
|
|
3
|
+
import { isSystemField } from './focus';
|
|
4
|
+
|
|
5
|
+
/** Prefix of the ids of the filter items the header filter row owns: `header:<field>`. */
|
|
6
|
+
export const HEADER_FILTER_ID_PREFIX = 'header:';
|
|
7
|
+
|
|
8
|
+
/** Default height of the header filter row, in pixels. */
|
|
9
|
+
export const DEFAULT_HEADER_FILTER_HEIGHT = 40;
|
|
10
|
+
|
|
11
|
+
/** Debounce for typed values, the same as the filter panel's. */
|
|
12
|
+
export const HEADER_FILTER_DEBOUNCE_MS = 300;
|
|
13
|
+
|
|
14
|
+
/** The synthetic row-grouping column; it has no header filter. */
|
|
15
|
+
const GROUPING_FIELD = '__group__';
|
|
16
|
+
|
|
17
|
+
type FilterEntry = GridFilterItem | GridFilterGroup;
|
|
18
|
+
|
|
19
|
+
const NO_ENTRIES: FilterEntry[] = [];
|
|
20
|
+
|
|
21
|
+
export function getHeaderFilterItemId(field: string): string {
|
|
22
|
+
return `${HEADER_FILTER_ID_PREFIX}${field}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isFilterGroup(entry: FilterEntry): entry is GridFilterGroup {
|
|
26
|
+
return 'items' in entry && Array.isArray(entry.items);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether a column gets a control in the header filter row. */
|
|
30
|
+
export function hasHeaderFilter(col: Pick<GridColDef, 'field' | 'type' | 'filterable' | 'headerFilter' | 'isSpacer'>): boolean {
|
|
31
|
+
if (col.isSpacer || isSystemField(col.field) || col.field === GROUPING_FIELD) return false;
|
|
32
|
+
return col.headerFilter !== false && col.filterable !== false && col.type !== 'image';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The operator a new header filter starts with: `headerFilterOperator`, else the default for the type. */
|
|
36
|
+
export function getHeaderFilterDefaultOperator(col: Pick<GridColDef, 'type' | 'headerFilterOperator'>): GridFilterOperator {
|
|
37
|
+
if (col.headerFilterOperator) return col.headerFilterOperator;
|
|
38
|
+
switch (col.type) {
|
|
39
|
+
case 'number': return '=';
|
|
40
|
+
case 'date':
|
|
41
|
+
case 'boolean':
|
|
42
|
+
case 'singleSelect':
|
|
43
|
+
return 'is';
|
|
44
|
+
default:
|
|
45
|
+
return 'contains';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The operators the operator menu offers: the type's operators, with the starting one first if the type does not list it. */
|
|
50
|
+
export function getHeaderFilterOperators(col: Pick<GridColDef, 'type' | 'headerFilterOperator'>): GridFilterOperator[] {
|
|
51
|
+
const operators = getOperatorsForType(col.type);
|
|
52
|
+
const start = getHeaderFilterDefaultOperator(col);
|
|
53
|
+
return operators.includes(start) ? operators : [start, ...operators];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a header filter cell shows:
|
|
58
|
+
* - `editable`: the cell owns `item` (the root-level item `header:<field>`), or there is none yet;
|
|
59
|
+
* - `custom`: the model holds a condition on the field the row cannot show (root `logicOperator`
|
|
60
|
+
* `'or'`, another root item on the field, the field inside a nested group). The cell is read-only.
|
|
61
|
+
*/
|
|
62
|
+
export type GridHeaderFilterState =
|
|
63
|
+
| { kind: 'editable'; item: GridFilterItem | undefined }
|
|
64
|
+
| { kind: 'custom' };
|
|
65
|
+
|
|
66
|
+
function groupMentionsField(group: GridFilterGroup, field: string): boolean {
|
|
67
|
+
return group.items.some(entry => (isFilterGroup(entry) ? groupMentionsField(entry, field) : entry.field === field));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether a model's root combines its items with OR (with something to combine). */
|
|
71
|
+
export function isOrRootModel(model: GridFilterModel | null | undefined): boolean {
|
|
72
|
+
return (model?.logicOperator ?? 'and') === 'or' && (model?.items?.length ?? 0) > 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getHeaderFilterState(model: GridFilterModel | null | undefined, field: string): GridHeaderFilterState {
|
|
76
|
+
const items = model?.items ?? NO_ENTRIES;
|
|
77
|
+
if (isOrRootModel(model)) return { kind: 'custom' };
|
|
78
|
+
const ownId = getHeaderFilterItemId(field);
|
|
79
|
+
let own: GridFilterItem | undefined;
|
|
80
|
+
for (const entry of items) {
|
|
81
|
+
if (isFilterGroup(entry)) {
|
|
82
|
+
if (groupMentionsField(entry, field)) return { kind: 'custom' };
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (entry.field !== field) continue;
|
|
86
|
+
if (entry.id !== ownId || own) return { kind: 'custom' };
|
|
87
|
+
own = entry;
|
|
88
|
+
}
|
|
89
|
+
return { kind: 'editable', item: own };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The model with the header filter item for `field` replaced by `item` (added at the end when
|
|
94
|
+
* absent, removed when `item` is null). Every other entry, the logic operator and the quick filter
|
|
95
|
+
* pass through untouched. Returns `model` itself when the cell cannot own the field's filter.
|
|
96
|
+
*/
|
|
97
|
+
export function setHeaderFilterItem(model: GridFilterModel | null | undefined, field: string, item: GridFilterItem | null): GridFilterModel {
|
|
98
|
+
const base: GridFilterModel = model ?? { items: [] };
|
|
99
|
+
if (getHeaderFilterState(base, field).kind !== 'editable') return base;
|
|
100
|
+
const ownId = getHeaderFilterItemId(field);
|
|
101
|
+
const items = base.items ?? NO_ENTRIES;
|
|
102
|
+
const index = items.findIndex(entry => !isFilterGroup(entry) && entry.id === ownId);
|
|
103
|
+
const nextItem: GridFilterItem | null = item ? { ...item, id: ownId, field } : null;
|
|
104
|
+
let next: FilterEntry[];
|
|
105
|
+
if (index === -1) {
|
|
106
|
+
if (!nextItem) return base;
|
|
107
|
+
// With no items the logic operator means nothing yet; a leftover 'or' would make the
|
|
108
|
+
// next column's cell read the model as custom, so it starts as 'and'.
|
|
109
|
+
if (items.length === 0 && base.logicOperator === 'or') return { ...base, logicOperator: 'and', items: [nextItem] };
|
|
110
|
+
next = [...items, nextItem];
|
|
111
|
+
} else if (nextItem) {
|
|
112
|
+
next = items.map((entry, i) => (i === index ? nextItem : entry));
|
|
113
|
+
} else {
|
|
114
|
+
next = items.filter((_, i) => i !== index);
|
|
115
|
+
}
|
|
116
|
+
return { ...base, items: next };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The item a header filter cell stores for an operator and value, or null when it should hold no
|
|
121
|
+
* item: an empty value with the starting operator. An empty value with another operator keeps the
|
|
122
|
+
* item, so the chosen operator stays while the value is retyped (an empty value never filters).
|
|
123
|
+
*/
|
|
124
|
+
export function buildHeaderFilterItem(
|
|
125
|
+
col: Pick<GridColDef, 'field' | 'type' | 'headerFilterOperator'>,
|
|
126
|
+
operator: GridFilterOperator,
|
|
127
|
+
value: unknown,
|
|
128
|
+
): GridFilterItem | null {
|
|
129
|
+
const noValue = NO_VALUE_OPERATORS.has(operator);
|
|
130
|
+
if (!noValue && isEmptyFilterValue(value) && operator === getHeaderFilterDefaultOperator(col)) return null;
|
|
131
|
+
return { id: getHeaderFilterItemId(col.field), field: col.field, operator, value: noValue ? undefined : value };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Carries a value over to a newly picked operator: lists for isAnyOf, scalars for the rest. */
|
|
135
|
+
export function convertHeaderFilterValue(value: unknown, operator: GridFilterOperator): unknown {
|
|
136
|
+
if (NO_VALUE_OPERATORS.has(operator)) return undefined;
|
|
137
|
+
if (operator === 'isAnyOf') {
|
|
138
|
+
if (value == null || value === '') return [];
|
|
139
|
+
return Array.isArray(value) ? value : [value];
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(value)) return value.length > 0 ? value[0] : '';
|
|
142
|
+
return value ?? '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Short label shown on the operator button. */
|
|
146
|
+
export const HEADER_FILTER_OPERATOR_SYMBOLS: Record<GridFilterOperator, string> = {
|
|
147
|
+
contains: '∋',
|
|
148
|
+
equals: '=',
|
|
149
|
+
startsWith: 'a…',
|
|
150
|
+
endsWith: '…a',
|
|
151
|
+
isEmpty: '∅',
|
|
152
|
+
isNotEmpty: '!∅',
|
|
153
|
+
isAnyOf: '∈',
|
|
154
|
+
'=': '=',
|
|
155
|
+
'!=': '≠',
|
|
156
|
+
'>': '>',
|
|
157
|
+
'>=': '≥',
|
|
158
|
+
'<': '<',
|
|
159
|
+
'<=': '≤',
|
|
160
|
+
is: '=',
|
|
161
|
+
not: '≠',
|
|
162
|
+
after: '>',
|
|
163
|
+
onOrAfter: '≥',
|
|
164
|
+
before: '<',
|
|
165
|
+
onOrBefore: '≤',
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** Readable operator names for the operator menu and labels. */
|
|
169
|
+
export const HEADER_FILTER_OPERATOR_LABELS: Record<GridFilterOperator, string> = {
|
|
170
|
+
contains: 'Contains',
|
|
171
|
+
equals: 'Equals',
|
|
172
|
+
startsWith: 'Starts with',
|
|
173
|
+
endsWith: 'Ends with',
|
|
174
|
+
isEmpty: 'Is empty',
|
|
175
|
+
isNotEmpty: 'Is not empty',
|
|
176
|
+
isAnyOf: 'Is any of',
|
|
177
|
+
'=': 'Equals',
|
|
178
|
+
'!=': 'Does not equal',
|
|
179
|
+
'>': 'Greater than',
|
|
180
|
+
'>=': 'Greater than or equal',
|
|
181
|
+
'<': 'Less than',
|
|
182
|
+
'<=': 'Less than or equal',
|
|
183
|
+
is: 'Is',
|
|
184
|
+
not: 'Is not',
|
|
185
|
+
after: 'After',
|
|
186
|
+
onOrAfter: 'On or after',
|
|
187
|
+
before: 'Before',
|
|
188
|
+
onOrBefore: 'On or before',
|
|
189
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tallest element this browser lays out. Browsers cap element heights: Chromium and Safari at
|
|
3
|
+
* about 33.5M px, Firefox at about 17.9M px. Firefox does not clamp an element that is taller — it
|
|
4
|
+
* collapses it, so a grid whose content is taller than the cap cannot be scrolled at all.
|
|
5
|
+
*
|
|
6
|
+
* Measured once per document with an invisible probe (one layout), from the largest candidate down.
|
|
7
|
+
* Returns `Infinity` where there is no layout engine (jsdom, SSR), so nothing is capped there.
|
|
8
|
+
*/
|
|
9
|
+
const CANDIDATES = [33_554_400, 17_895_000, 10_000_000, 5_000_000] as const;
|
|
10
|
+
|
|
11
|
+
const cache = new WeakMap<Document, number>();
|
|
12
|
+
|
|
13
|
+
function measure(doc: Document): number {
|
|
14
|
+
const body = doc.body;
|
|
15
|
+
if (!body) return Infinity;
|
|
16
|
+
const probe = doc.createElement('div');
|
|
17
|
+
probe.setAttribute('aria-hidden', 'true');
|
|
18
|
+
probe.style.cssText = 'position:absolute;top:0;left:-10000px;width:1px;visibility:hidden;pointer-events:none;';
|
|
19
|
+
body.appendChild(probe);
|
|
20
|
+
try {
|
|
21
|
+
probe.style.height = '100px';
|
|
22
|
+
// No layout engine: every element reads 0 tall.
|
|
23
|
+
if (Math.abs(probe.getBoundingClientRect().height - 100) > 1) return Infinity;
|
|
24
|
+
for (const height of CANDIDATES) {
|
|
25
|
+
probe.style.height = `${height}px`;
|
|
26
|
+
if (Math.abs(probe.getBoundingClientRect().height - height) < 2) return height;
|
|
27
|
+
}
|
|
28
|
+
return CANDIDATES[CANDIDATES.length - 1];
|
|
29
|
+
} finally {
|
|
30
|
+
probe.remove();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getMaxElementHeight(doc: Document | null | undefined): number {
|
|
35
|
+
if (!doc) return Infinity;
|
|
36
|
+
const known = cache.get(doc);
|
|
37
|
+
if (known !== undefined) return known;
|
|
38
|
+
const height = measure(doc);
|
|
39
|
+
cache.set(doc, height);
|
|
40
|
+
return height;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** How many of the unpinned rows end at or above `limit` px (the rows a user can scroll to). */
|
|
44
|
+
export function countReachableRows(cumulativeHeights: readonly number[], limit: number): number {
|
|
45
|
+
if (!Number.isFinite(limit)) return cumulativeHeights.length;
|
|
46
|
+
let lo = 0;
|
|
47
|
+
let hi = cumulativeHeights.length;
|
|
48
|
+
while (lo < hi) {
|
|
49
|
+
const mid = (lo + hi) >> 1;
|
|
50
|
+
if (cumulativeHeights[mid] <= limit) lo = mid + 1; else hi = mid;
|
|
51
|
+
}
|
|
52
|
+
return lo;
|
|
53
|
+
}
|