@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.
- package/CHANGELOG.md +33 -0
- package/README.md +6 -0
- 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 +7104 -5726
- 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 +9 -4
- 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/roadmap.md +2 -3
- 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 +76 -3
- 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/useGridFillHandle.ts +323 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
- package/lib/hooks/core/useGridToolbarProps.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/llms.txt +44 -4
- 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
|
-
|
|
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,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;
|