@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.
- package/CHANGELOG.md +64 -0
- package/README.md +10 -1
- package/dist/ai.cjs +2 -0
- package/dist/ai.cjs.map +1 -0
- package/dist/ai.d.ts +366 -0
- package/dist/ai.es.js +2 -0
- package/dist/ai.es.js.map +1 -0
- package/dist/index.d.ts +396 -6
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +8029 -6090
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -14
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +182 -4
- package/docs/README.md +5 -1
- package/docs/architecture/datagrid-orchestration.md +19 -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 +12 -1
- package/docs/features/ai-toolkit.md +213 -0
- package/docs/features/cell-selection.md +8 -2
- package/docs/features/clipboard.md +88 -0
- package/docs/features/editing-reordering.md +50 -0
- 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 +31 -2
- package/docs/features/undo-redo.md +79 -0
- package/docs/roadmap.md +3 -4
- package/lib/ai/agentTools.ts +160 -0
- package/lib/ai/coerce.ts +90 -0
- package/lib/ai/columns.ts +90 -0
- package/lib/ai/index.ts +33 -0
- package/lib/ai/promptHandler.ts +109 -0
- package/lib/ai/schema.ts +214 -0
- package/lib/ai/types.ts +152 -0
- package/lib/ai/validate.ts +296 -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 +33 -0
- package/lib/components/Cell/Cell.tsx +6 -2
- package/lib/components/Cell/GridEditInputCell.css +12 -4
- package/lib/components/Cell/GridEditInputCell.tsx +19 -12
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.tsx +138 -5
- 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.css +14 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +5 -11
- 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/ListView/ListView.css +2 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Toolbar/GlobalSearch.css +16 -0
- package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
- package/lib/components/Toolbar/GridToolbar.tsx +51 -0
- package/lib/components/ui/Checkbox.tsx +16 -5
- package/lib/components/ui/Input.css +43 -0
- package/lib/components/ui/Input.tsx +24 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +16 -1
- package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
- package/lib/hooks/core/useDataGrid.ts +27 -0
- package/lib/hooks/core/useGridApiMethods.ts +39 -0
- package/lib/hooks/core/useGridAriaRows.ts +5 -3
- package/lib/hooks/core/useGridCellSelection.ts +107 -13
- package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
- package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
- package/lib/hooks/core/useGridControlledState.ts +27 -0
- package/lib/hooks/core/useGridFillHandle.ts +323 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
- package/lib/hooks/core/useGridToolbarProps.ts +19 -0
- package/lib/hooks/core/useGridUndoRedo.ts +220 -0
- package/lib/hooks/features/useGridAiAssistant.ts +284 -0
- package/lib/hooks/features/useGridBatchEdit.ts +217 -0
- package/lib/hooks/features/useGridEditing.ts +24 -45
- package/lib/index.ts +10 -0
- package/lib/styles/opengridx.css +4 -1
- package/lib/theme/DataGridThemeProvider.tsx +2 -0
- package/lib/theme/types.ts +2 -0
- package/lib/types/index.ts +343 -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/commit.ts +69 -0
- package/lib/utils/editing/fill.ts +238 -0
- package/lib/utils/editing/targets.ts +109 -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/parsing.ts +252 -0
- package/llms.txt +97 -3
- 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
|
-
|
|
195
|
-
|
|
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
|
|
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
|
+
}
|
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;
|