@opencorestack/opengridx 3.2.1 → 3.3.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 +35 -0
- package/README.md +12 -1
- package/dist/index.d.ts +173 -1
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +5478 -4841
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +15 -14
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +34 -2
- package/docs/README.md +1 -0
- package/docs/architecture/datagrid-orchestration.md +13 -5
- package/docs/components/header.md +25 -0
- package/docs/customization/theming.md +3 -1
- package/docs/features/cell-selection.md +131 -0
- package/docs/features/clipboard.md +2 -0
- package/docs/features/keyboard-navigation.md +14 -2
- package/docs/roadmap.md +64 -7
- package/lib/components/Cell/Cell.css +30 -0
- package/lib/components/Cell/Cell.tsx +25 -13
- package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
- package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
- package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
- package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
- package/lib/components/DataGrid/DataGrid.tsx +61 -1
- package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
- package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +2 -1
- package/lib/components/Header/Header.css +12 -0
- package/lib/components/Header/Header.tsx +17 -4
- package/lib/components/Row/Row.tsx +38 -1
- package/lib/components/Toolbar/GridToolbar.tsx +10 -4
- package/lib/components/ui/Checkbox.tsx +10 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +6 -0
- package/lib/hooks/core/useGridCellSelection.ts +603 -0
- package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
- package/lib/hooks/core/useGridClipboardApi.ts +8 -2
- package/lib/hooks/core/useGridControlledState.ts +38 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
- package/lib/hooks/core/useGridRowInteractions.ts +8 -4
- package/lib/hooks/core/useGridThemeDimensions.ts +1 -1
- package/lib/hooks/features/useAggregation.ts +8 -6
- package/lib/hooks/features/useGridClipboard.ts +47 -2
- package/lib/hooks/features/useGridEditing.ts +16 -7
- package/lib/index.ts +9 -0
- package/lib/styles/base/variables.css +6 -1
- package/lib/styles/opengridx.css +1 -0
- package/lib/theme/DataGridThemeProvider.tsx +4 -0
- package/lib/theme/presets.ts +8 -0
- package/lib/theme/types.ts +4 -0
- package/lib/types/index.ts +130 -0
- package/lib/utils/attempt.ts +14 -0
- package/lib/utils/cellSelection.ts +238 -0
- package/lib/utils/columnAutosize.ts +1 -1
- package/lib/utils/export/exportShared.ts +11 -2
- package/lib/utils/headerWrap.ts +23 -0
- package/llms.txt +17 -2
- package/package.json +11 -3
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { RefObject } from 'react';
|
|
3
|
+
import { scrollColumnIntoView, scrollRowIntoView } from '../../utils/scroll';
|
|
4
|
+
import { isSyntheticRowId } from '../../utils/syntheticRows';
|
|
5
|
+
import { isTextEntryElement } from '../../utils/focus';
|
|
6
|
+
import type { FocusedCell } from '../../utils/focus';
|
|
7
|
+
import { getCellValue } from '../../utils/values';
|
|
8
|
+
import { AGGREGATION_FUNCTIONS } from '../../utils/aggregation';
|
|
9
|
+
import {
|
|
10
|
+
LARGE_RANGE_COPY_CELLS,
|
|
11
|
+
defaultCellSelectionAnnouncement,
|
|
12
|
+
isRangeColumnField,
|
|
13
|
+
isSameCellSelectionModel,
|
|
14
|
+
isSameCoordinates,
|
|
15
|
+
nextRangeHead,
|
|
16
|
+
rectCellCount,
|
|
17
|
+
resolveCellRange,
|
|
18
|
+
} from '../../utils/cellSelection';
|
|
19
|
+
import type { CellRangeContext, CellRangeRect, CellRangeSpanLookup, ResolvedCellRange } from '../../utils/cellSelection';
|
|
20
|
+
import { buildRangeTsv } from '../features/useGridClipboard';
|
|
21
|
+
import type { GridSpanningResult } from '../features/useGridSpanning';
|
|
22
|
+
import type { LayoutResult } from './useLayout';
|
|
23
|
+
import type { GridKeyboardCellSelection } from './useGridKeyboardNavigation';
|
|
24
|
+
import type {
|
|
25
|
+
GridCellCoordinates,
|
|
26
|
+
GridCellSelectionModel,
|
|
27
|
+
GridCellSelectionReason,
|
|
28
|
+
GridColDef,
|
|
29
|
+
GridLocaleText,
|
|
30
|
+
GridRowId,
|
|
31
|
+
GridRowMeta,
|
|
32
|
+
GridRowModel,
|
|
33
|
+
GridSelectedCell,
|
|
34
|
+
} from '../../types';
|
|
35
|
+
|
|
36
|
+
/** Root class while a pointer drag selects cells: no text selection meanwhile. Public from v3.3. */
|
|
37
|
+
export const RANGE_DRAGGING_CLASS = 'ogx--range-dragging';
|
|
38
|
+
|
|
39
|
+
/** Pointer distance (px) from a body edge at which a drag starts scrolling. */
|
|
40
|
+
const AUTO_SCROLL_EDGE = 32;
|
|
41
|
+
const AUTO_SCROLL_MAX_STEP = 40;
|
|
42
|
+
const ANNOUNCEMENT_DELAY_MS = 300;
|
|
43
|
+
|
|
44
|
+
/** The rectangle in the coordinates rows render with: row indices of the renderable rows, data column indices. */
|
|
45
|
+
export interface GridDisplayCellRange {
|
|
46
|
+
top: number;
|
|
47
|
+
bottom: number;
|
|
48
|
+
/** `columnIndexMap` index of the first column. */
|
|
49
|
+
left: number;
|
|
50
|
+
right: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Totals of the status bar. */
|
|
54
|
+
export interface GridCellSelectionStats {
|
|
55
|
+
cellCount: number;
|
|
56
|
+
rowCount: number;
|
|
57
|
+
columnCount: number;
|
|
58
|
+
count: number;
|
|
59
|
+
numericCount: number;
|
|
60
|
+
sum: number | null;
|
|
61
|
+
average: number | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type RangeColumn<R extends GridRowModel> = Pick<GridColDef<R>, 'field'> & Partial<GridColDef<R>>;
|
|
65
|
+
|
|
66
|
+
export interface UseGridCellSelectionParams<R extends GridRowModel> {
|
|
67
|
+
enabled: boolean;
|
|
68
|
+
model: GridCellSelectionModel;
|
|
69
|
+
onModelChange: (model: GridCellSelectionModel, reason: GridCellSelectionReason) => void;
|
|
70
|
+
/** The model passed to onModelChange since the last commit (not rendered yet), or null. */
|
|
71
|
+
getPendingModel: () => GridCellSelectionModel | null;
|
|
72
|
+
allRenderableRows: R[];
|
|
73
|
+
getRowId: (row: R) => GridRowId;
|
|
74
|
+
/** System columns and the rendered data columns, in render order (useGridColumns). */
|
|
75
|
+
navigationColumns: ReadonlyArray<RangeColumn<R>>;
|
|
76
|
+
columnIndexMap: Map<string, number>;
|
|
77
|
+
spanning: GridSpanningResult;
|
|
78
|
+
rowMetaMap: Map<GridRowId, GridRowMeta>;
|
|
79
|
+
layout: LayoutResult<R>;
|
|
80
|
+
rowHeight: number;
|
|
81
|
+
pinnedTopRowCount: number;
|
|
82
|
+
viewportRef: RefObject<HTMLDivElement | null>;
|
|
83
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
84
|
+
localeText?: GridLocaleText;
|
|
85
|
+
showStats: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface UseGridCellSelectionReturn {
|
|
89
|
+
enabled: boolean;
|
|
90
|
+
/** The first range of the model. */
|
|
91
|
+
range: GridCellSelectionModel[number] | null;
|
|
92
|
+
/** Where the range lies on screen, or null when it is empty or a corner is not displayed. */
|
|
93
|
+
resolved: ResolvedCellRange | null;
|
|
94
|
+
/** The rectangle to draw (more than one cell selected), in render coordinates. */
|
|
95
|
+
displayRange: GridDisplayCellRange | null;
|
|
96
|
+
hasRowSpan: boolean;
|
|
97
|
+
/** Viewport mousedown: click, Shift+click and drag. Undefined while off. */
|
|
98
|
+
handleMouseDown: ((event: React.MouseEvent<HTMLElement>) => void) | undefined;
|
|
99
|
+
/** Viewport click (capture): swallows the click that ends a drag or a Shift+click. Undefined while off. */
|
|
100
|
+
handleClickCapture: ((event: React.MouseEvent<HTMLElement>) => void) | undefined;
|
|
101
|
+
/** Shift+navigation key from the keyboard hook. Returns whether the key was used. */
|
|
102
|
+
extendSelection: (key: string, withModifier: boolean, pageSize: number, from: FocusedCell) => boolean;
|
|
103
|
+
/** Escape: collapses a multi-cell range to its anchor. Returns whether it did. */
|
|
104
|
+
collapseSelection: () => boolean;
|
|
105
|
+
/** Ctrl/Cmd+A: every data cell of the page. Returns whether anything was selected. */
|
|
106
|
+
selectAllCells: () => boolean;
|
|
107
|
+
/** The three keyboard handlers for useGridKeyboardNavigation (stable), undefined while off. */
|
|
108
|
+
keyboard: GridKeyboardCellSelection | undefined;
|
|
109
|
+
/** The text Ctrl/Cmd+C copies: the range, else the focused cell; null when there is nothing. */
|
|
110
|
+
getCopyText: () => string | null;
|
|
111
|
+
getSelectedCells: () => GridSelectedCell[];
|
|
112
|
+
/** Live-region text, debounced. Empty when nothing is to be said. */
|
|
113
|
+
announcement: string;
|
|
114
|
+
stats: GridCellSelectionStats | null;
|
|
115
|
+
/** Lets the API hook (after the keyboard hook) report the focus position the copy falls back to. */
|
|
116
|
+
reportFocus: (cell: FocusedCell | null) => void;
|
|
117
|
+
/** Sets the model, keeping only the first range, unless it is unchanged. */
|
|
118
|
+
setModel: (model: GridCellSelectionModel, reason: GridCellSelectionReason) => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface DragState {
|
|
122
|
+
anchor: GridCellCoordinates;
|
|
123
|
+
head: GridCellCoordinates;
|
|
124
|
+
x: number;
|
|
125
|
+
y: number;
|
|
126
|
+
frame: number;
|
|
127
|
+
moved: boolean;
|
|
128
|
+
stop: () => void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The body cell of `viewport` holding `target`: its row index and field. Not cells of nested grids or detail panels. */
|
|
132
|
+
function cellFromTarget(target: EventTarget | null, viewport: HTMLElement): { rowIndex: number; field: string; element: HTMLElement } | null {
|
|
133
|
+
if (!target || (target as Node).nodeType !== 1) return null;
|
|
134
|
+
const element = (target as Element).closest<HTMLElement>('.ogx__cell[data-field]');
|
|
135
|
+
if (!element || element.closest('.ogx__viewport') !== viewport) return null;
|
|
136
|
+
const panel = element.closest('.ogx__detail-panel');
|
|
137
|
+
if (panel && viewport.contains(panel)) return null;
|
|
138
|
+
const rowElement = element.closest('[role="row"][data-rowindex]');
|
|
139
|
+
if (!rowElement) return null;
|
|
140
|
+
const rowIndex = Number(rowElement.getAttribute('data-rowindex'));
|
|
141
|
+
const field = element.dataset.field;
|
|
142
|
+
if (!Number.isInteger(rowIndex) || !field) return null;
|
|
143
|
+
return { rowIndex, field, element };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function clearTextSelection(doc: Document): void {
|
|
147
|
+
const selection = doc.getSelection();
|
|
148
|
+
if (selection && selection.rangeCount > 0) selection.removeAllRanges();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
|
152
|
+
const autoScrollStep = (over: number) => Math.min(AUTO_SCROLL_MAX_STEP, Math.max(2, Math.round(over / 2)));
|
|
153
|
+
|
|
154
|
+
function warnLargeCopy(cellCount: number): void {
|
|
155
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
156
|
+
console.warn(
|
|
157
|
+
`[OpenGridX] Copying ${cellCount} cells. For ranges this large, exportToCsv or exportToExcel is the better tool.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Cell range selection (`cellSelection`): resolves the model's range against the current display
|
|
163
|
+
* order (growing it over spans), handles pointer drag with auto-scroll, Shift+click and the
|
|
164
|
+
* Shift+navigation keys the keyboard hook hands over, and builds the copied text, the status-bar
|
|
165
|
+
* totals and the live-region text. The API methods and the focus ↔ anchor sync are installed by
|
|
166
|
+
* useGridCellSelectionApi, after the keyboard hook.
|
|
167
|
+
*/
|
|
168
|
+
export function useGridCellSelection<R extends GridRowModel>(params: UseGridCellSelectionParams<R>): UseGridCellSelectionReturn {
|
|
169
|
+
const {
|
|
170
|
+
enabled, model, allRenderableRows, getRowId, navigationColumns, columnIndexMap, spanning,
|
|
171
|
+
rowMetaMap, localeText, showStats,
|
|
172
|
+
} = params;
|
|
173
|
+
|
|
174
|
+
const range = enabled && model.length > 0 ? model[0] : null;
|
|
175
|
+
|
|
176
|
+
const rangeColumns = useMemo(
|
|
177
|
+
() => (enabled ? navigationColumns.filter(c => isRangeColumnField(c.field)) : []),
|
|
178
|
+
[enabled, navigationColumns]
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const spanLookup = useMemo<CellRangeSpanLookup | undefined>(() => {
|
|
182
|
+
if (!spanning.hasColSpan && !spanning.hasRowSpan) return undefined;
|
|
183
|
+
const { colspanMap, rowSpanningCaches, getSpanOrigin } = spanning;
|
|
184
|
+
return {
|
|
185
|
+
getSpanOrigin,
|
|
186
|
+
getColSpan: (rowId, field) => {
|
|
187
|
+
const info = colspanMap.get(rowId)?.[field];
|
|
188
|
+
return info && !info.spannedByColSpan ? info.cellProps.colSpan : 1;
|
|
189
|
+
},
|
|
190
|
+
getRowSpan: (rowId, field) => rowSpanningCaches.spannedCells[rowId]?.[field] ?? 1,
|
|
191
|
+
};
|
|
192
|
+
}, [spanning]);
|
|
193
|
+
|
|
194
|
+
const context = useMemo<CellRangeContext | null>(() => {
|
|
195
|
+
if (!enabled) return null;
|
|
196
|
+
const rowIds = allRenderableRows.map(getRowId);
|
|
197
|
+
const rowIndexById = new Map<GridRowId, number>();
|
|
198
|
+
rowIds.forEach((id, index) => { if (!rowIndexById.has(id)) rowIndexById.set(id, index); });
|
|
199
|
+
const fields = rangeColumns.map(c => c.field);
|
|
200
|
+
return {
|
|
201
|
+
rowIds,
|
|
202
|
+
rowIndexById,
|
|
203
|
+
fields,
|
|
204
|
+
colIndexByField: new Map(fields.map((field, index) => [field, index])),
|
|
205
|
+
spans: spanLookup,
|
|
206
|
+
};
|
|
207
|
+
}, [enabled, allRenderableRows, getRowId, rangeColumns, spanLookup]);
|
|
208
|
+
|
|
209
|
+
const resolved = useMemo(
|
|
210
|
+
() => (range && context ? resolveCellRange(range, context) : null),
|
|
211
|
+
[range, context]
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const isMultiCell = resolved !== null && rectCellCount(resolved) > 1;
|
|
215
|
+
|
|
216
|
+
const displayRange = useMemo<GridDisplayCellRange | null>(() => {
|
|
217
|
+
if (!resolved || !isMultiCell || !context) return null;
|
|
218
|
+
const left = columnIndexMap.get(context.fields[resolved.left]);
|
|
219
|
+
const right = columnIndexMap.get(context.fields[resolved.right]);
|
|
220
|
+
if (left === undefined || right === undefined) return null;
|
|
221
|
+
return { top: resolved.top, bottom: resolved.bottom, left, right };
|
|
222
|
+
}, [resolved, isMultiCell, context, columnIndexMap]);
|
|
223
|
+
|
|
224
|
+
const isSynthetic = useCallback((id: GridRowId) => isSyntheticRowId(id, rowMetaMap), [rowMetaMap]);
|
|
225
|
+
|
|
226
|
+
/** Visits the cells of `rect` of data rows, in display order; `covered` marks span-covered positions. */
|
|
227
|
+
const forEachCell = useCallback((
|
|
228
|
+
rect: CellRangeRect,
|
|
229
|
+
visitRow: (row: R, id: GridRowId) => void,
|
|
230
|
+
visitCell: (row: R, id: GridRowId, col: RangeColumn<R>, covered: boolean) => void,
|
|
231
|
+
) => {
|
|
232
|
+
if (!context) return;
|
|
233
|
+
for (let r = rect.top; r <= rect.bottom; r++) {
|
|
234
|
+
const row = allRenderableRows[r];
|
|
235
|
+
const id = context.rowIds[r];
|
|
236
|
+
if (!row || isSynthetic(id)) continue;
|
|
237
|
+
visitRow(row, id);
|
|
238
|
+
for (let c = rect.left; c <= rect.right; c++) {
|
|
239
|
+
const col = rangeColumns[c];
|
|
240
|
+
const origin = spanLookup?.getSpanOrigin(id, col.field);
|
|
241
|
+
visitCell(row, id, col, origin !== null && origin !== undefined && (origin.rowId !== id || origin.field !== col.field));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}, [context, allRenderableRows, rangeColumns, spanLookup, isSynthetic]);
|
|
245
|
+
|
|
246
|
+
// Latest values for event handlers and API methods, which keep a stable identity.
|
|
247
|
+
const latest = { ...params, range, resolved, context, rangeColumns, forEachCell };
|
|
248
|
+
const latestRef = useRef(latest);
|
|
249
|
+
useLayoutEffect(() => {
|
|
250
|
+
latestRef.current = latest;
|
|
251
|
+
});
|
|
252
|
+
const focusRef = useRef<FocusedCell | null>(null);
|
|
253
|
+
const reportFocus = useCallback((cell: FocusedCell | null) => { focusRef.current = cell; }, []);
|
|
254
|
+
|
|
255
|
+
const currentModel = useCallback((): GridCellSelectionModel => {
|
|
256
|
+
const { getPendingModel, model: rendered } = latestRef.current;
|
|
257
|
+
return getPendingModel() ?? rendered;
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
/** The current range (pending one included) and where it lies, read at call time. */
|
|
261
|
+
const currentRange = useCallback((): { range: GridCellSelectionModel[number] | null; resolved: ResolvedCellRange | null } => {
|
|
262
|
+
const { context: ctx, range: rendered, resolved: renderedResolved, enabled: on } = latestRef.current;
|
|
263
|
+
const first = on ? currentModel()[0] ?? null : null;
|
|
264
|
+
if (!first || !ctx) return { range: null, resolved: null };
|
|
265
|
+
if (first === rendered) return { range: first, resolved: renderedResolved };
|
|
266
|
+
return { range: first, resolved: resolveCellRange(first, ctx) };
|
|
267
|
+
}, [currentModel]);
|
|
268
|
+
|
|
269
|
+
const setModel = useCallback((next: GridCellSelectionModel, reason: GridCellSelectionReason) => {
|
|
270
|
+
if (!latestRef.current.enabled) return;
|
|
271
|
+
const trimmed = next.length > 1 ? next.slice(0, 1) : next;
|
|
272
|
+
if (isSameCellSelectionModel(trimmed, currentModel())) return;
|
|
273
|
+
latestRef.current.onModelChange(trimmed, reason);
|
|
274
|
+
}, [currentModel]);
|
|
275
|
+
|
|
276
|
+
const setRange = useCallback((anchor: GridCellCoordinates, head: GridCellCoordinates, reason: GridCellSelectionReason) => {
|
|
277
|
+
setModel([{ anchor, head }], reason);
|
|
278
|
+
}, [setModel]);
|
|
279
|
+
|
|
280
|
+
const scrollHeadIntoView = useCallback((row: number, field: string) => {
|
|
281
|
+
const { viewportRef, layout, rowHeight, pinnedTopRowCount } = latestRef.current;
|
|
282
|
+
const el = viewportRef.current;
|
|
283
|
+
if (!el) return;
|
|
284
|
+
scrollRowIntoView(el, row - pinnedTopRowCount, layout.cumulativeHeights, layout.pinnedBottomHeight, rowHeight);
|
|
285
|
+
scrollColumnIntoView(el, layout.unpinnedColsWithWidth.findIndex(c => c.field === field), layout);
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
const extendSelection = useCallback((key: string, withModifier: boolean, pageSize: number, from: FocusedCell): boolean => {
|
|
289
|
+
const { context: ctx } = latestRef.current;
|
|
290
|
+
const { range: current, resolved: currentResolved } = currentRange();
|
|
291
|
+
if (!ctx || from.id === null || !isRangeColumnField(from.field)) return false;
|
|
292
|
+
const fromRow = ctx.rowIndexById.get(from.id);
|
|
293
|
+
const fromCol = ctx.colIndexByField.get(from.field);
|
|
294
|
+
if (fromRow === undefined || fromCol === undefined) return false;
|
|
295
|
+
// The range extends from the focused cell: the anchor, unless focus moved off it.
|
|
296
|
+
const base = current && currentResolved && isSameCoordinates(current.anchor, { id: from.id, field: from.field })
|
|
297
|
+
? { range: current, resolved: currentResolved }
|
|
298
|
+
: null;
|
|
299
|
+
const anchor = base ? base.range.anchor : { id: from.id, field: from.field };
|
|
300
|
+
const resolvedBase = base?.resolved ?? resolveCellRange({ anchor, head: anchor }, ctx);
|
|
301
|
+
if (!resolvedBase) return false;
|
|
302
|
+
const next = nextRangeHead(resolvedBase, key, withModifier, pageSize, ctx.rowIds.length - 1, ctx.fields.length - 1);
|
|
303
|
+
if (!next) return true;
|
|
304
|
+
const head = { id: ctx.rowIds[next.row], field: ctx.fields[next.col] };
|
|
305
|
+
setRange(anchor, head, 'keyboard');
|
|
306
|
+
scrollHeadIntoView(next.row, head.field);
|
|
307
|
+
return true;
|
|
308
|
+
}, [currentRange, setRange, scrollHeadIntoView]);
|
|
309
|
+
|
|
310
|
+
const collapseSelection = useCallback((): boolean => {
|
|
311
|
+
const { range: current, resolved: currentResolved } = currentRange();
|
|
312
|
+
if (!current || !currentResolved || rectCellCount(currentResolved) <= 1) return false;
|
|
313
|
+
setRange(current.anchor, current.anchor, 'keyboard');
|
|
314
|
+
return true;
|
|
315
|
+
}, [currentRange, setRange]);
|
|
316
|
+
|
|
317
|
+
const selectAllCells = useCallback((): boolean => {
|
|
318
|
+
const { context: ctx } = latestRef.current;
|
|
319
|
+
if (!ctx || ctx.rowIds.length === 0 || ctx.fields.length === 0) return false;
|
|
320
|
+
setRange(
|
|
321
|
+
{ id: ctx.rowIds[0], field: ctx.fields[0] },
|
|
322
|
+
{ id: ctx.rowIds[ctx.rowIds.length - 1], field: ctx.fields[ctx.fields.length - 1] },
|
|
323
|
+
'selectAll',
|
|
324
|
+
);
|
|
325
|
+
return true;
|
|
326
|
+
}, [setRange]);
|
|
327
|
+
|
|
328
|
+
/** The rectangle to copy or read: the range, else the focused data cell. */
|
|
329
|
+
const getTargetRect = useCallback((): CellRangeRect | null => {
|
|
330
|
+
const { context: ctx } = latestRef.current;
|
|
331
|
+
const { range: target, resolved: current } = currentRange();
|
|
332
|
+
if (!ctx) return null;
|
|
333
|
+
if (current) return current;
|
|
334
|
+
if (target) return null;
|
|
335
|
+
const focus = focusRef.current;
|
|
336
|
+
if (!focus || focus.id === null || !isRangeColumnField(focus.field)) return null;
|
|
337
|
+
const single = resolveCellRange({ anchor: { id: focus.id, field: focus.field }, head: { id: focus.id, field: focus.field } }, ctx);
|
|
338
|
+
return single;
|
|
339
|
+
}, [currentRange]);
|
|
340
|
+
|
|
341
|
+
const getCopyText = useCallback((): string | null => {
|
|
342
|
+
const rect = getTargetRect();
|
|
343
|
+
if (!rect) return null;
|
|
344
|
+
const { rangeColumns: cols, forEachCell: visit } = latestRef.current;
|
|
345
|
+
const cellCount = rectCellCount(rect);
|
|
346
|
+
if (cellCount > LARGE_RANGE_COPY_CELLS) warnLargeCopy(cellCount);
|
|
347
|
+
const rows: R[] = [];
|
|
348
|
+
visit(rect, (row) => { rows.push(row); }, () => {});
|
|
349
|
+
if (rows.length === 0) return null;
|
|
350
|
+
const columns = cols.slice(rect.left, rect.right + 1) as GridColDef[];
|
|
351
|
+
const spans = latestRef.current.context?.spans;
|
|
352
|
+
const getId = latestRef.current.getRowId;
|
|
353
|
+
const isCovered = spans
|
|
354
|
+
? (row: GridRowModel, col: GridColDef) => {
|
|
355
|
+
const id = getId(row as R);
|
|
356
|
+
const origin = spans.getSpanOrigin(id, col.field);
|
|
357
|
+
return origin !== null && (origin.rowId !== id || origin.field !== col.field);
|
|
358
|
+
}
|
|
359
|
+
: undefined;
|
|
360
|
+
return buildRangeTsv(rows as GridRowModel[], columns, isCovered);
|
|
361
|
+
}, [getTargetRect]);
|
|
362
|
+
|
|
363
|
+
const getSelectedCells = useCallback((): GridSelectedCell[] => {
|
|
364
|
+
const { forEachCell: visit } = latestRef.current;
|
|
365
|
+
const { resolved: current } = currentRange();
|
|
366
|
+
if (!current) return [];
|
|
367
|
+
const cells: GridSelectedCell[] = [];
|
|
368
|
+
visit(current, () => {}, (row, id, col, covered) => {
|
|
369
|
+
if (!covered) cells.push({ id, field: col.field, value: getCellValue(row, col.field, col as GridColDef<R>) });
|
|
370
|
+
});
|
|
371
|
+
return cells;
|
|
372
|
+
}, [currentRange]);
|
|
373
|
+
|
|
374
|
+
// ── Pointer: click, Shift+click and drag ────────────────────────────────
|
|
375
|
+
const dragRef = useRef<DragState | null>(null);
|
|
376
|
+
const suppressClickRef = useRef(false);
|
|
377
|
+
|
|
378
|
+
useEffect(() => () => dragRef.current?.stop(), []);
|
|
379
|
+
|
|
380
|
+
const headAtPoint = useCallback((viewport: HTMLElement, x: number, y: number): GridCellCoordinates | null => {
|
|
381
|
+
const ctx = latestRef.current.context;
|
|
382
|
+
if (!ctx) return null;
|
|
383
|
+
const doc = viewport.ownerDocument;
|
|
384
|
+
// jsdom has no elementFromPoint.
|
|
385
|
+
if (typeof doc.elementFromPoint !== 'function') return null;
|
|
386
|
+
const hit = cellFromTarget(doc.elementFromPoint(x, y), viewport);
|
|
387
|
+
if (!hit) return null;
|
|
388
|
+
const id = ctx.rowIds[hit.rowIndex];
|
|
389
|
+
if (id === undefined) return null;
|
|
390
|
+
// Over the system or grouping column (left of the data): the first data column.
|
|
391
|
+
const field = isRangeColumnField(hit.field) ? hit.field : ctx.fields[0];
|
|
392
|
+
return field === undefined ? null : { id, field };
|
|
393
|
+
}, []);
|
|
394
|
+
|
|
395
|
+
// The auto-scroll loop schedules the next frame through this ref: a callback cannot name itself.
|
|
396
|
+
const runDragFrameRef = useRef<(allowScroll: boolean) => void>(() => {});
|
|
397
|
+
const runDragFrame = useCallback((allowScroll: boolean) => {
|
|
398
|
+
const drag = dragRef.current;
|
|
399
|
+
const viewport = latestRef.current.viewportRef.current;
|
|
400
|
+
if (!drag || !viewport) return;
|
|
401
|
+
drag.frame = 0;
|
|
402
|
+
const { layout } = latestRef.current;
|
|
403
|
+
const box = viewport.getBoundingClientRect();
|
|
404
|
+
const width = viewport.clientWidth;
|
|
405
|
+
const height = viewport.clientHeight;
|
|
406
|
+
const stickyTop = viewport.querySelector(':scope > .ogx__content > .ogx__sticky-top');
|
|
407
|
+
const stickyBottom = viewport.querySelector(':scope > .ogx__content > .ogx__sticky-bottom');
|
|
408
|
+
const headerWrap = stickyTop?.querySelector(':scope > .ogx__header-wrap');
|
|
409
|
+
const bodyTop = stickyTop ? stickyTop.getBoundingClientRect().bottom : box.top;
|
|
410
|
+
const bodyBottom = Math.min(box.top + height, stickyBottom ? stickyBottom.getBoundingClientRect().top : box.top + height);
|
|
411
|
+
const headerBottom = headerWrap ? headerWrap.getBoundingClientRect().bottom : bodyTop;
|
|
412
|
+
const bodyLeft = box.left + layout.pinnedSystemColumnsWidth + layout.leftWidth;
|
|
413
|
+
const bodyRight = box.left + width - layout.rightWidth;
|
|
414
|
+
|
|
415
|
+
// The cell under the pointer; outside the viewport (or over the header) the nearest body cell.
|
|
416
|
+
const x = clamp(drag.x, box.left + 1, box.left + width - 2);
|
|
417
|
+
let y = drag.y;
|
|
418
|
+
if (y < box.top || y > box.top + height || y < headerBottom) y = clamp(y, bodyTop + 1, Math.max(bodyTop + 1, bodyBottom - 2));
|
|
419
|
+
const head = headAtPoint(viewport, x, y);
|
|
420
|
+
if (head && !isSameCoordinates(head, drag.head)) {
|
|
421
|
+
drag.head = head;
|
|
422
|
+
if (!drag.moved && !isSameCoordinates(head, drag.anchor)) {
|
|
423
|
+
drag.moved = true;
|
|
424
|
+
latestRef.current.containerRef.current?.classList.add(RANGE_DRAGGING_CLASS);
|
|
425
|
+
clearTextSelection(viewport.ownerDocument);
|
|
426
|
+
}
|
|
427
|
+
setRange(drag.anchor, head, 'pointer');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!allowScroll) return;
|
|
431
|
+
const outside = drag.y < bodyTop || drag.y > bodyBottom || drag.x < box.left || drag.x > box.left + width;
|
|
432
|
+
if (!drag.moved && !outside) return;
|
|
433
|
+
let dy = 0;
|
|
434
|
+
let dx = 0;
|
|
435
|
+
if (drag.y < bodyTop + AUTO_SCROLL_EDGE) dy = -autoScrollStep(bodyTop + AUTO_SCROLL_EDGE - drag.y);
|
|
436
|
+
else if (drag.y > bodyBottom - AUTO_SCROLL_EDGE) dy = autoScrollStep(drag.y - (bodyBottom - AUTO_SCROLL_EDGE));
|
|
437
|
+
if (drag.x < bodyLeft + AUTO_SCROLL_EDGE) dx = -autoScrollStep(bodyLeft + AUTO_SCROLL_EDGE - drag.x);
|
|
438
|
+
else if (drag.x > bodyRight - AUTO_SCROLL_EDGE) dx = autoScrollStep(drag.x - (bodyRight - AUTO_SCROLL_EDGE));
|
|
439
|
+
const canScrollY = (dy < 0 && viewport.scrollTop > 0) || (dy > 0 && viewport.scrollTop + height < viewport.scrollHeight - 1);
|
|
440
|
+
const canScrollX = (dx < 0 && viewport.scrollLeft > 0) || (dx > 0 && viewport.scrollLeft + width < viewport.scrollWidth - 1);
|
|
441
|
+
if (!canScrollY && !canScrollX) return;
|
|
442
|
+
if (canScrollY) viewport.scrollTop += dy;
|
|
443
|
+
if (canScrollX) viewport.scrollLeft += dx;
|
|
444
|
+
// Keep going while the pointer rests in the edge zone; the next frame picks the new head.
|
|
445
|
+
drag.frame = requestAnimationFrame(() => runDragFrameRef.current(true));
|
|
446
|
+
}, [headAtPoint, setRange]);
|
|
447
|
+
useLayoutEffect(() => {
|
|
448
|
+
runDragFrameRef.current = runDragFrame;
|
|
449
|
+
}, [runDragFrame]);
|
|
450
|
+
|
|
451
|
+
const startDrag = useCallback((anchor: GridCellCoordinates, head: GridCellCoordinates, x: number, y: number, doc: Document) => {
|
|
452
|
+
dragRef.current?.stop();
|
|
453
|
+
const onMove = (event: MouseEvent) => {
|
|
454
|
+
const drag = dragRef.current;
|
|
455
|
+
if (!drag) return;
|
|
456
|
+
if (event.buttons === 0) {
|
|
457
|
+
// Released outside the window.
|
|
458
|
+
drag.stop();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
drag.x = event.clientX;
|
|
462
|
+
drag.y = event.clientY;
|
|
463
|
+
// The native text selection started by the press would otherwise follow the pointer out
|
|
464
|
+
// of the grid (WebKit), and a page text selection makes Ctrl/Cmd+C leave the copy to the browser.
|
|
465
|
+
if (drag.moved) {
|
|
466
|
+
event.preventDefault();
|
|
467
|
+
clearTextSelection(doc);
|
|
468
|
+
}
|
|
469
|
+
if (!drag.frame) drag.frame = requestAnimationFrame(() => runDragFrame(true));
|
|
470
|
+
};
|
|
471
|
+
const onUp = (event: MouseEvent) => {
|
|
472
|
+
const drag = dragRef.current;
|
|
473
|
+
if (!drag) return;
|
|
474
|
+
drag.x = event.clientX;
|
|
475
|
+
drag.y = event.clientY;
|
|
476
|
+
runDragFrame(false);
|
|
477
|
+
const moved = drag.moved;
|
|
478
|
+
drag.stop();
|
|
479
|
+
if (moved) clearTextSelection(doc);
|
|
480
|
+
if (moved) {
|
|
481
|
+
// The click that follows lands on the common ancestor of both cells (often the row):
|
|
482
|
+
// it must not select the row or move focus.
|
|
483
|
+
suppressClickRef.current = true;
|
|
484
|
+
setTimeout(() => { suppressClickRef.current = false; }, 0);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
const view = doc.defaultView;
|
|
488
|
+
const onBlur = () => dragRef.current?.stop();
|
|
489
|
+
const drag: DragState = {
|
|
490
|
+
anchor, head, x, y, frame: 0, moved: false,
|
|
491
|
+
stop: () => {
|
|
492
|
+
if (drag.frame) cancelAnimationFrame(drag.frame);
|
|
493
|
+
drag.frame = 0;
|
|
494
|
+
doc.removeEventListener('mousemove', onMove, true);
|
|
495
|
+
doc.removeEventListener('mouseup', onUp, true);
|
|
496
|
+
view?.removeEventListener('blur', onBlur);
|
|
497
|
+
latestRef.current.containerRef.current?.classList.remove(RANGE_DRAGGING_CLASS);
|
|
498
|
+
if (dragRef.current === drag) dragRef.current = null;
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
dragRef.current = drag;
|
|
502
|
+
doc.addEventListener('mousemove', onMove, true);
|
|
503
|
+
doc.addEventListener('mouseup', onUp, true);
|
|
504
|
+
view?.addEventListener('blur', onBlur);
|
|
505
|
+
}, [runDragFrame]);
|
|
506
|
+
|
|
507
|
+
const handleMouseDown = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
|
508
|
+
if (event.button !== 0 || event.defaultPrevented) return;
|
|
509
|
+
const { context: ctx } = latestRef.current;
|
|
510
|
+
if (!ctx) return;
|
|
511
|
+
const viewport = event.currentTarget;
|
|
512
|
+
const hit = cellFromTarget(event.target, viewport);
|
|
513
|
+
if (!hit || !isRangeColumnField(hit.field)) return;
|
|
514
|
+
// Typing targets and open editors keep the pointer to themselves.
|
|
515
|
+
if (hit.element.classList.contains('ogx__cell--editing') || isTextEntryElement(event.target as Element)) return;
|
|
516
|
+
const id = ctx.rowIds[hit.rowIndex];
|
|
517
|
+
if (id === undefined) return;
|
|
518
|
+
const cell = { id, field: hit.field };
|
|
519
|
+
|
|
520
|
+
let anchor: GridCellCoordinates = cell;
|
|
521
|
+
if (event.shiftKey) {
|
|
522
|
+
const current = currentModel()[0];
|
|
523
|
+
const focus = focusRef.current;
|
|
524
|
+
if (current && ctx.rowIndexById.has(current.anchor.id) && ctx.colIndexByField.has(current.anchor.field)) anchor = current.anchor;
|
|
525
|
+
else if (focus && focus.id !== null && isRangeColumnField(focus.field)) anchor = { id: focus.id, field: focus.field };
|
|
526
|
+
// Shift+click extends the range: no text selection, no focus move and no click (row
|
|
527
|
+
// selection, onCellClick) afterwards.
|
|
528
|
+
event.preventDefault();
|
|
529
|
+
suppressClickRef.current = true;
|
|
530
|
+
const doc = viewport.ownerDocument;
|
|
531
|
+
if (!viewport.contains(doc.activeElement)) viewport.focus({ preventScroll: true });
|
|
532
|
+
}
|
|
533
|
+
setRange(anchor, cell, 'pointer');
|
|
534
|
+
startDrag(anchor, cell, event.clientX, event.clientY, viewport.ownerDocument);
|
|
535
|
+
if (event.shiftKey) {
|
|
536
|
+
const drag = dragRef.current;
|
|
537
|
+
if (drag && !isSameCoordinates(anchor, cell)) drag.moved = true;
|
|
538
|
+
}
|
|
539
|
+
}, [currentModel, setRange, startDrag]);
|
|
540
|
+
|
|
541
|
+
const handleClickCapture = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
|
542
|
+
if (!suppressClickRef.current) return;
|
|
543
|
+
suppressClickRef.current = false;
|
|
544
|
+
event.stopPropagation();
|
|
545
|
+
event.preventDefault();
|
|
546
|
+
}, []);
|
|
547
|
+
|
|
548
|
+
// ── Live region and status bar ──────────────────────────────────────────
|
|
549
|
+
const columnCount = resolved ? resolved.right - resolved.left + 1 : 0;
|
|
550
|
+
const rowCount = resolved ? resolved.bottom - resolved.top + 1 : 0;
|
|
551
|
+
const announcementFn = localeText?.cellSelectionAnnouncement ?? defaultCellSelectionAnnouncement;
|
|
552
|
+
const announcementText = isMultiCell ? announcementFn(rowCount * columnCount, rowCount, columnCount) : '';
|
|
553
|
+
const [announcement, setAnnouncement] = useState('');
|
|
554
|
+
useEffect(() => {
|
|
555
|
+
if (!enabled) return;
|
|
556
|
+
const timer = setTimeout(() => setAnnouncement(announcementText), ANNOUNCEMENT_DELAY_MS);
|
|
557
|
+
return () => clearTimeout(timer);
|
|
558
|
+
}, [enabled, announcementText]);
|
|
559
|
+
|
|
560
|
+
const stats = useMemo<GridCellSelectionStats | null>(() => {
|
|
561
|
+
if (!showStats || !resolved || !isMultiCell) return null;
|
|
562
|
+
const values: unknown[] = [];
|
|
563
|
+
forEachCell(resolved, () => {}, (row, _id, col, covered) => {
|
|
564
|
+
if (!covered) values.push(getCellValue(row, col.field, col as GridColDef<R>));
|
|
565
|
+
});
|
|
566
|
+
const numbers = values.filter((v): v is number => typeof v === 'number' && !Number.isNaN(v));
|
|
567
|
+
return {
|
|
568
|
+
cellCount: rectCellCount(resolved),
|
|
569
|
+
rowCount: resolved.bottom - resolved.top + 1,
|
|
570
|
+
columnCount: resolved.right - resolved.left + 1,
|
|
571
|
+
count: AGGREGATION_FUNCTIONS.count(values) as number,
|
|
572
|
+
numericCount: numbers.length,
|
|
573
|
+
sum: numbers.length ? AGGREGATION_FUNCTIONS.sum(numbers) as number : null,
|
|
574
|
+
average: numbers.length ? AGGREGATION_FUNCTIONS.avg(numbers) as number : null,
|
|
575
|
+
};
|
|
576
|
+
}, [showStats, resolved, isMultiCell, forEachCell]);
|
|
577
|
+
|
|
578
|
+
const keyboard = useMemo<GridKeyboardCellSelection | undefined>(
|
|
579
|
+
() => (enabled ? { extendSelection, collapseSelection, selectAllCells } : undefined),
|
|
580
|
+
[enabled, extendSelection, collapseSelection, selectAllCells]
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
enabled,
|
|
585
|
+
range,
|
|
586
|
+
resolved,
|
|
587
|
+
displayRange,
|
|
588
|
+
hasRowSpan: spanning.hasRowSpan,
|
|
589
|
+
handleMouseDown: enabled ? handleMouseDown : undefined,
|
|
590
|
+
handleClickCapture: enabled ? handleClickCapture : undefined,
|
|
591
|
+
extendSelection,
|
|
592
|
+
collapseSelection,
|
|
593
|
+
selectAllCells,
|
|
594
|
+
keyboard,
|
|
595
|
+
getCopyText,
|
|
596
|
+
getSelectedCells,
|
|
597
|
+
announcement: enabled ? announcement : '',
|
|
598
|
+
stats,
|
|
599
|
+
reportFocus,
|
|
600
|
+
setModel,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|