@opencorestack/opengridx 3.2.2 → 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 +22 -0
- package/README.md +12 -1
- package/dist/index.d.ts +173 -1
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +5472 -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/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/headerWrap.ts +23 -0
- package/llms.txt +17 -2
- package/package.json +7 -3
package/lib/types/index.ts
CHANGED
|
@@ -144,6 +144,12 @@ export interface GridColDef<R extends GridValidRowModel = GridRowModel> {
|
|
|
144
144
|
align?: GridAlignment;
|
|
145
145
|
/** Content alignment within the header cell. */
|
|
146
146
|
headerAlign?: GridAlignment;
|
|
147
|
+
/**
|
|
148
|
+
* Wraps this column's header title onto several lines instead of cutting it with an ellipsis.
|
|
149
|
+
* Overrides the grid's `wrapHeaderText`. The header keeps `headerHeight`: the title shows as many
|
|
150
|
+
* lines as fit and ends in an ellipsis if it is still longer. Raise `headerHeight` for more lines.
|
|
151
|
+
*/
|
|
152
|
+
wrapHeaderText?: boolean;
|
|
147
153
|
/** If false, sorting is disabled for this column. */
|
|
148
154
|
sortable?: boolean;
|
|
149
155
|
/** If false, filtering is disabled for this column. */
|
|
@@ -658,6 +664,68 @@ export interface GridPaginationModel {
|
|
|
658
664
|
|
|
659
665
|
export type GridRowSelectionModel = GridRowId[];
|
|
660
666
|
|
|
667
|
+
/** One cell, addressed by its row id and column field. @since v3.3 */
|
|
668
|
+
export interface GridCellCoordinates {
|
|
669
|
+
id: GridRowId;
|
|
670
|
+
field: string;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* A rectangle of cells between two corners. `anchor` is the active cell: it keeps focus, is the cell
|
|
675
|
+
* an edit starts in, and stays put while the range is extended. `head` is the corner that moves with
|
|
676
|
+
* Shift+arrows or the pointer, and the one scrolled into view. A single cell has `anchor` equal to `head`.
|
|
677
|
+
* @since v3.3
|
|
678
|
+
*/
|
|
679
|
+
export interface GridCellRange {
|
|
680
|
+
anchor: GridCellCoordinates;
|
|
681
|
+
head: GridCellCoordinates;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The cell selection: zero or one range in 3.3 (the array form leaves room for several ranges later).
|
|
686
|
+
* Corners are ids and fields, so the rectangle follows its corner rows through sorting and filtering.
|
|
687
|
+
* @since v3.3
|
|
688
|
+
*/
|
|
689
|
+
export type GridCellSelectionModel = readonly GridCellRange[];
|
|
690
|
+
|
|
691
|
+
/** What changed the cell selection. @since v3.3 */
|
|
692
|
+
export type GridCellSelectionReason = 'pointer' | 'keyboard' | 'selectAll' | 'api' | 'clear' | 'dataChange';
|
|
693
|
+
|
|
694
|
+
/** Second argument of `onCellSelectionModelChange`. @since v3.3 */
|
|
695
|
+
export interface GridCellSelectionChangeDetails {
|
|
696
|
+
reason: GridCellSelectionReason;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** One cell of the selected range, as returned by `apiRef.getSelectedCells()`. @since v3.3 */
|
|
700
|
+
export interface GridSelectedCell {
|
|
701
|
+
id: GridRowId;
|
|
702
|
+
field: string;
|
|
703
|
+
/** The cell's value, read through the column's `valueGetter`. */
|
|
704
|
+
value: unknown;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Props the grid passes to `slots.cellSelectionStats` (the status bar of `showCellSelectionStats`),
|
|
709
|
+
* before `slotProps.cellSelectionStats` is merged over them. Rendered only while more than one cell
|
|
710
|
+
* is selected.
|
|
711
|
+
* @since v3.3
|
|
712
|
+
*/
|
|
713
|
+
export interface GridCellSelectionStatsSlotProps {
|
|
714
|
+
apiRef: React.MutableRefObject<GridApi>;
|
|
715
|
+
/** Cells in the rectangle (rows × columns), synthetic rows and span-covered positions included. */
|
|
716
|
+
cellCount: number;
|
|
717
|
+
rowCount: number;
|
|
718
|
+
columnCount: number;
|
|
719
|
+
/** Non-empty cells of data rows. */
|
|
720
|
+
count: number;
|
|
721
|
+
/** Cells holding a number. */
|
|
722
|
+
numericCount: number;
|
|
723
|
+
/** Sum of the numeric cells, or `null` when there are none. */
|
|
724
|
+
sum: number | null;
|
|
725
|
+
/** Average of the numeric cells, or `null` when there are none. */
|
|
726
|
+
average: number | null;
|
|
727
|
+
}
|
|
728
|
+
|
|
661
729
|
export interface GridPinnedColumns {
|
|
662
730
|
left?: string[];
|
|
663
731
|
right?: string[];
|
|
@@ -745,6 +813,12 @@ export interface GridLocaleText {
|
|
|
745
813
|
paginationPage?: (page: number, pageCount: number) => string;
|
|
746
814
|
/** Empty-state label. When set, overrides the `noRowsLabel` prop on DataGrid. Default: `"No Data"` */
|
|
747
815
|
noRowsLabel?: string;
|
|
816
|
+
/**
|
|
817
|
+
* Screen-reader announcement after the cell range changes (`cellSelection`). Receives (cells, rows,
|
|
818
|
+
* columns). Default: `` (c, r, col) => `${c} cells selected, ${r} rows by ${col} columns` `` (singular for 1).
|
|
819
|
+
* @since v3.3
|
|
820
|
+
*/
|
|
821
|
+
cellSelectionAnnouncement?: (cellCount: number, rowCount: number, columnCount: number) => string;
|
|
748
822
|
}
|
|
749
823
|
|
|
750
824
|
/**
|
|
@@ -763,6 +837,13 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
|
|
|
763
837
|
rowHeight?: number;
|
|
764
838
|
/** Height of the header row in pixels. Defaults to 56. */
|
|
765
839
|
headerHeight?: number;
|
|
840
|
+
/**
|
|
841
|
+
* Wraps header titles onto several lines instead of cutting them with an ellipsis. The header
|
|
842
|
+
* keeps `headerHeight` (2 lines at the default 56px, 3 at 72px, 4 at 88px); a title that still
|
|
843
|
+
* does not fit ends in an ellipsis. `GridColDef.wrapHeaderText` overrides it per column.
|
|
844
|
+
* Defaults to `false`.
|
|
845
|
+
*/
|
|
846
|
+
wrapHeaderText?: boolean;
|
|
766
847
|
/** If true, the grid height will adjust to match the total height of its rows. */
|
|
767
848
|
autoHeight?: boolean;
|
|
768
849
|
/**
|
|
@@ -840,6 +921,24 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
|
|
|
840
921
|
*/
|
|
841
922
|
disableClipboardCopy?: boolean;
|
|
842
923
|
|
|
924
|
+
/**
|
|
925
|
+
* Lets users select a rectangle of cells (drag, Shift+click, Shift+arrows, Ctrl/Cmd+A) and copy it
|
|
926
|
+
* with Ctrl/Cmd+C as tab-separated text. While on, Ctrl/Cmd+C copies the cell range (the focused
|
|
927
|
+
* cell at minimum) instead of the selected rows; `apiRef.copySelectedRows()` still copies rows.
|
|
928
|
+
* Default: false. @since v3.3
|
|
929
|
+
*/
|
|
930
|
+
cellSelection?: boolean;
|
|
931
|
+
/** Controlled cell selection (zero or one range). Needs `cellSelection`. @since v3.3 */
|
|
932
|
+
cellSelectionModel?: GridCellSelectionModel;
|
|
933
|
+
/** Fired when the cell selection changes, with what changed it. @since v3.3 */
|
|
934
|
+
onCellSelectionModelChange?: (model: GridCellSelectionModel, details: GridCellSelectionChangeDetails) => void;
|
|
935
|
+
/**
|
|
936
|
+
* Shows a status bar under the grid with the count of non-empty cells and the sum and average of the
|
|
937
|
+
* numeric cells in the range, while more than one cell is selected. Replaceable through
|
|
938
|
+
* `slots.cellSelectionStats`. Needs `cellSelection`. Default: false. @since v3.3
|
|
939
|
+
*/
|
|
940
|
+
showCellSelectionStats?: boolean;
|
|
941
|
+
|
|
843
942
|
/** Loading state. With no rows the body shows skeleton rows (or `slots.loadingOverlay`); with rows, they stay and a progress bar (or `slots.loadingOverlay`) is shown over them. */
|
|
844
943
|
loading?: boolean;
|
|
845
944
|
/** Visual density of the grid. */
|
|
@@ -992,6 +1091,11 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
|
|
|
992
1091
|
* Receives `GridFooterSlotProps` (plus `slotProps.footer`).
|
|
993
1092
|
*/
|
|
994
1093
|
footer?: React.ComponentType<GridFooterSlotProps & Record<string, unknown>>;
|
|
1094
|
+
/**
|
|
1095
|
+
* Replaces the status bar of `showCellSelectionStats`. Receives `GridCellSelectionStatsSlotProps`
|
|
1096
|
+
* (plus `slotProps.cellSelectionStats`). @since v3.3
|
|
1097
|
+
*/
|
|
1098
|
+
cellSelectionStats?: React.ComponentType<GridCellSelectionStatsSlotProps & Record<string, unknown>>;
|
|
995
1099
|
};
|
|
996
1100
|
/** Properties passed directly to custom slots. */
|
|
997
1101
|
slotProps?: {
|
|
@@ -1007,6 +1111,8 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
|
|
|
1007
1111
|
loadingOverlay?: GridOverlaySlotProps;
|
|
1008
1112
|
/** Merged over the `GridFooterSlotProps` the grid passes to the footer slot. */
|
|
1009
1113
|
footer?: Partial<GridFooterSlotProps> & Record<string, unknown>;
|
|
1114
|
+
/** Merged over the `GridCellSelectionStatsSlotProps` the grid passes to the status bar. */
|
|
1115
|
+
cellSelectionStats?: Partial<GridCellSelectionStatsSlotProps> & Record<string, unknown>;
|
|
1010
1116
|
};
|
|
1011
1117
|
|
|
1012
1118
|
/** When true, renders the grid as a single-column list of cards. Perfect for mobile/responsive views. */
|
|
@@ -1198,4 +1304,28 @@ export interface GridApi<R extends GridValidRowModel = GridRowModel> {
|
|
|
1198
1304
|
* in which case nothing is written) and rejects when the clipboard write fails.
|
|
1199
1305
|
*/
|
|
1200
1306
|
copySelectedRows: () => Promise<void>;
|
|
1307
|
+
|
|
1308
|
+
/** The cell selection (`[]` when `cellSelection` is off or nothing is selected). @since v3.3 */
|
|
1309
|
+
getCellSelectionModel: () => GridCellSelectionModel;
|
|
1310
|
+
/**
|
|
1311
|
+
* Replaces the cell selection (only the first range is kept) and moves focus to its anchor.
|
|
1312
|
+
* Does nothing while `cellSelection` is off. @since v3.3
|
|
1313
|
+
*/
|
|
1314
|
+
setCellSelectionModel: (model: GridCellSelectionModel) => void;
|
|
1315
|
+
/** Selects the rectangle between two cells; `anchor` becomes the active cell. @since v3.3 */
|
|
1316
|
+
selectCellRange: (anchor: GridCellCoordinates, head: GridCellCoordinates) => void;
|
|
1317
|
+
/** Empties the cell selection. Focus stays where it is. @since v3.3 */
|
|
1318
|
+
clearCellSelection: () => void;
|
|
1319
|
+
/**
|
|
1320
|
+
* Cells of the current range in display order (row by row, columns in screen order), values read
|
|
1321
|
+
* through the column's `valueGetter`. Synthetic rows (group headers, subtotals, tree auto-parents, the
|
|
1322
|
+
* pivot Grand Total) and positions covered by a span are left out. @since v3.3
|
|
1323
|
+
*/
|
|
1324
|
+
getSelectedCells: () => GridSelectedCell[];
|
|
1325
|
+
/**
|
|
1326
|
+
* Copies the cell range (the focused cell when nothing is selected) as tab-separated text, formatted
|
|
1327
|
+
* the way the cells show it, with no header line. Resolves without writing when there is nothing to
|
|
1328
|
+
* copy; rejects when the clipboard write fails. @since v3.3
|
|
1329
|
+
*/
|
|
1330
|
+
copySelectedCells: () => Promise<void>;
|
|
1201
1331
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type AttemptResult<T> = { ok: true; value: T } | { ok: false; error: unknown };
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calls `fn` and contains a throw. Components and hooks use it instead of an inline `try` / `catch`
|
|
5
|
+
* around expressions with `?.`, `??`, `||` or `? :`: React Compiler cannot compile a function that has
|
|
6
|
+
* those inside a `try` statement yet and skips the whole component or hook.
|
|
7
|
+
*/
|
|
8
|
+
export function attempt<T>(fn: () => T): AttemptResult<T> {
|
|
9
|
+
try {
|
|
10
|
+
return { ok: true, value: fn() };
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return { ok: false, error };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { GridCellCoordinates, GridCellRange, GridCellSelectionModel, GridRowId } from '../types';
|
|
2
|
+
import { isSystemField } from './focus';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure helpers of cell range selection (`cellSelection`). A range is stored as two corner cells
|
|
6
|
+
* (ids and fields); everything here works it out against the current display order.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The synthetic row-grouping column. Like the system columns, it never joins a range. */
|
|
10
|
+
export const GROUPING_COLUMN_FIELD = '__group__';
|
|
11
|
+
|
|
12
|
+
/** Whether cells of this column can be part of a range: data columns only. */
|
|
13
|
+
export function isRangeColumnField(field: string): boolean {
|
|
14
|
+
return !isSystemField(field) && field !== GROUPING_COLUMN_FIELD;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const isSameCoordinates = (a: GridCellCoordinates | null | undefined, b: GridCellCoordinates | null | undefined): boolean =>
|
|
18
|
+
a === b || (!!a && !!b && a.id === b.id && a.field === b.field);
|
|
19
|
+
|
|
20
|
+
export const isSingleCellRange = (range: GridCellRange): boolean => isSameCoordinates(range.anchor, range.head);
|
|
21
|
+
|
|
22
|
+
/** A model holding the same range(s) as `b`. */
|
|
23
|
+
export function isSameCellSelectionModel(a: GridCellSelectionModel, b: GridCellSelectionModel): boolean {
|
|
24
|
+
if (a === b) return true;
|
|
25
|
+
if (a.length !== b.length) return false;
|
|
26
|
+
return a.every((range, i) => isSameCoordinates(range.anchor, b[i].anchor) && isSameCoordinates(range.head, b[i].head));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A rectangle in display indices: rows of the renderable rows, columns of the range columns. Inclusive. */
|
|
30
|
+
export interface CellRangeRect {
|
|
31
|
+
top: number;
|
|
32
|
+
bottom: number;
|
|
33
|
+
left: number;
|
|
34
|
+
right: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The rectangle of a range, plus where its two corners are. */
|
|
38
|
+
export interface ResolvedCellRange extends CellRangeRect {
|
|
39
|
+
anchorRow: number;
|
|
40
|
+
anchorCol: number;
|
|
41
|
+
headRow: number;
|
|
42
|
+
headCol: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Span information, from useGridSpanning. */
|
|
46
|
+
export interface CellRangeSpanLookup {
|
|
47
|
+
/** For a cell covered by a span, the origin cell of that span; `null` for any other cell. */
|
|
48
|
+
getSpanOrigin: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
|
|
49
|
+
/** The colSpan of a span origin (1 for other cells). */
|
|
50
|
+
getColSpan: (rowId: GridRowId, field: string) => number;
|
|
51
|
+
/** The rowSpan of a span origin (1 for other cells). */
|
|
52
|
+
getRowSpan: (rowId: GridRowId, field: string) => number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CellRangeContext {
|
|
56
|
+
/** Ids of the renderable rows (top-pinned, page rows, bottom-pinned), in display order. */
|
|
57
|
+
rowIds: readonly GridRowId[];
|
|
58
|
+
rowIndexById: ReadonlyMap<GridRowId, number>;
|
|
59
|
+
/** Range columns in screen order: left-pinned, centre, right-pinned. */
|
|
60
|
+
fields: readonly string[];
|
|
61
|
+
colIndexByField: ReadonlyMap<string, number>;
|
|
62
|
+
/** Present when the grid has column or row spans. */
|
|
63
|
+
spans?: CellRangeSpanLookup;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The merged area (span) that contains a cell, or the cell itself. */
|
|
67
|
+
function mergedArea(r: number, c: number, ctx: CellRangeContext): CellRangeRect {
|
|
68
|
+
const { rowIds, fields, rowIndexById, colIndexByField, spans } = ctx;
|
|
69
|
+
const single = { top: r, bottom: r, left: c, right: c };
|
|
70
|
+
if (!spans) return single;
|
|
71
|
+
const rowId = rowIds[r];
|
|
72
|
+
const field = fields[c];
|
|
73
|
+
const origin = spans.getSpanOrigin(rowId, field) ?? { rowId, field };
|
|
74
|
+
const originRow = rowIndexById.get(origin.rowId);
|
|
75
|
+
const originCol = colIndexByField.get(origin.field);
|
|
76
|
+
if (originRow === undefined || originCol === undefined) return single;
|
|
77
|
+
const rowSpan = Math.max(1, spans.getRowSpan(origin.rowId, origin.field));
|
|
78
|
+
const colSpan = Math.max(1, spans.getColSpan(origin.rowId, origin.field));
|
|
79
|
+
return {
|
|
80
|
+
top: originRow,
|
|
81
|
+
bottom: Math.min(rowIds.length - 1, originRow + rowSpan - 1),
|
|
82
|
+
left: originCol,
|
|
83
|
+
right: Math.min(fields.length - 1, originCol + colSpan - 1),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Grows a rectangle until no span crosses its edge, as Excel does with merged cells. A span that
|
|
89
|
+
* crosses the edge holds a cell on the edge, so only the edge cells are looked at.
|
|
90
|
+
*/
|
|
91
|
+
export function growRectToSpans(rect: CellRangeRect, ctx: CellRangeContext): CellRangeRect {
|
|
92
|
+
if (!ctx.spans) return rect;
|
|
93
|
+
let { top, bottom, left, right } = rect;
|
|
94
|
+
let changed = true;
|
|
95
|
+
const take = (area: CellRangeRect) => {
|
|
96
|
+
if (area.top < top) { top = area.top; changed = true; }
|
|
97
|
+
if (area.bottom > bottom) { bottom = area.bottom; changed = true; }
|
|
98
|
+
if (area.left < left) { left = area.left; changed = true; }
|
|
99
|
+
if (area.right > right) { right = area.right; changed = true; }
|
|
100
|
+
};
|
|
101
|
+
while (changed) {
|
|
102
|
+
changed = false;
|
|
103
|
+
for (let r = top; r <= bottom; r++) {
|
|
104
|
+
take(mergedArea(r, left, ctx));
|
|
105
|
+
if (right !== left) take(mergedArea(r, right, ctx));
|
|
106
|
+
}
|
|
107
|
+
for (let c = left + 1; c < right; c++) {
|
|
108
|
+
take(mergedArea(top, c, ctx));
|
|
109
|
+
if (bottom !== top) take(mergedArea(bottom, c, ctx));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { top, bottom, left, right };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where a range lies in the current display order, or `null` when one of its corners is not
|
|
117
|
+
* displayed (its row was filtered, paged or removed, or its column hidden).
|
|
118
|
+
*/
|
|
119
|
+
export function resolveCellRange(range: GridCellRange, ctx: CellRangeContext): ResolvedCellRange | null {
|
|
120
|
+
const anchorRow = ctx.rowIndexById.get(range.anchor.id);
|
|
121
|
+
const headRow = ctx.rowIndexById.get(range.head.id);
|
|
122
|
+
const anchorCol = ctx.colIndexByField.get(range.anchor.field);
|
|
123
|
+
const headCol = ctx.colIndexByField.get(range.head.field);
|
|
124
|
+
if (anchorRow === undefined || headRow === undefined || anchorCol === undefined || headCol === undefined) return null;
|
|
125
|
+
const rect = growRectToSpans({
|
|
126
|
+
top: Math.min(anchorRow, headRow),
|
|
127
|
+
bottom: Math.max(anchorRow, headRow),
|
|
128
|
+
left: Math.min(anchorCol, headCol),
|
|
129
|
+
right: Math.max(anchorCol, headCol),
|
|
130
|
+
}, ctx);
|
|
131
|
+
return { ...rect, anchorRow, anchorCol, headRow, headCol };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const rectCellCount = (rect: CellRangeRect): number => (rect.bottom - rect.top + 1) * (rect.right - rect.left + 1);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The new head for a Shift+navigation key, as display indices, or `null` when it does not move.
|
|
138
|
+
* Shift+Arrow moves one cell and does not wrap to the next row. Extending moves from the far edge
|
|
139
|
+
* of the rectangle, so the head steps out of a span it grew over.
|
|
140
|
+
*/
|
|
141
|
+
export function nextRangeHead(
|
|
142
|
+
resolved: ResolvedCellRange,
|
|
143
|
+
key: string,
|
|
144
|
+
withModifier: boolean,
|
|
145
|
+
pageSize: number,
|
|
146
|
+
lastRow: number,
|
|
147
|
+
lastCol: number,
|
|
148
|
+
): { row: number; col: number } | null {
|
|
149
|
+
const { headRow, headCol, anchorRow, anchorCol, top, bottom, left, right } = resolved;
|
|
150
|
+
let row = headRow;
|
|
151
|
+
let col = headCol;
|
|
152
|
+
switch (key) {
|
|
153
|
+
case 'ArrowRight': col = headCol >= anchorCol ? right + 1 : headCol + 1; break;
|
|
154
|
+
case 'ArrowLeft': col = headCol <= anchorCol ? left - 1 : headCol - 1; break;
|
|
155
|
+
case 'ArrowDown': row = headRow >= anchorRow ? bottom + 1 : headRow + 1; break;
|
|
156
|
+
case 'ArrowUp': row = headRow <= anchorRow ? top - 1 : headRow - 1; break;
|
|
157
|
+
case 'Home':
|
|
158
|
+
col = 0;
|
|
159
|
+
if (withModifier) row = 0;
|
|
160
|
+
break;
|
|
161
|
+
case 'End':
|
|
162
|
+
col = lastCol;
|
|
163
|
+
if (withModifier) row = lastRow;
|
|
164
|
+
break;
|
|
165
|
+
case 'PageUp': row = headRow - pageSize; break;
|
|
166
|
+
case 'PageDown': row = headRow + pageSize; break;
|
|
167
|
+
default: return null;
|
|
168
|
+
}
|
|
169
|
+
row = Math.min(Math.max(row, 0), lastRow);
|
|
170
|
+
col = Math.min(Math.max(col, 0), lastCol);
|
|
171
|
+
if (row === headRow && col === headCol) return null;
|
|
172
|
+
return { row, col };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The range flags one row passes to its cells, or `null` for a row outside the range. Rows outside
|
|
177
|
+
* keep `null`, so a drag only re-renders the rows it enters or leaves. `left` / `right` are data
|
|
178
|
+
* column indices (`columnIndexMap`).
|
|
179
|
+
*/
|
|
180
|
+
export interface GridRowCellRange {
|
|
181
|
+
left: number;
|
|
182
|
+
right: number;
|
|
183
|
+
isTop: boolean;
|
|
184
|
+
isBottom: boolean;
|
|
185
|
+
/** Rows from this one to the bottom of the range; only set (else 0) when the grid has row spans. */
|
|
186
|
+
bottomOffset: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function getRowCellRange(
|
|
190
|
+
range: { top: number; bottom: number; left: number; right: number } | null | undefined,
|
|
191
|
+
rowIndex: number,
|
|
192
|
+
hasRowSpan: boolean,
|
|
193
|
+
): GridRowCellRange | null {
|
|
194
|
+
if (!range || rowIndex < range.top || rowIndex > range.bottom) return null;
|
|
195
|
+
return {
|
|
196
|
+
left: range.left,
|
|
197
|
+
right: range.right,
|
|
198
|
+
isTop: rowIndex === range.top,
|
|
199
|
+
isBottom: rowIndex === range.bottom,
|
|
200
|
+
bottomOffset: hasRowSpan ? range.bottom - rowIndex : 0,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const isSameRowCellRange = (a: GridRowCellRange | null | undefined, b: GridRowCellRange | null | undefined): boolean =>
|
|
205
|
+
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);
|
|
207
|
+
|
|
208
|
+
/** Bit flags a cell receives for its place in the range (a number keeps memoised cells stable). */
|
|
209
|
+
export const RANGE_CELL = 1;
|
|
210
|
+
export const RANGE_TOP = 2;
|
|
211
|
+
export const RANGE_BOTTOM = 4;
|
|
212
|
+
export const RANGE_LEFT = 8;
|
|
213
|
+
export const RANGE_RIGHT = 16;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The flags of the cell at data column `colIndex` (spanning `colSpan` columns and `rowSpan` rows from
|
|
217
|
+
* this row), or 0 when it is outside the range.
|
|
218
|
+
*/
|
|
219
|
+
export function getCellRangeFlags(rowRange: GridRowCellRange | null | undefined, colIndex: number, colSpan: number, rowSpan: number): number {
|
|
220
|
+
if (!rowRange) return 0;
|
|
221
|
+
const lastCol = colIndex + Math.max(1, colSpan) - 1;
|
|
222
|
+
if (colIndex < rowRange.left || lastCol > rowRange.right) return 0;
|
|
223
|
+
let flags = RANGE_CELL;
|
|
224
|
+
if (rowRange.isTop) flags |= RANGE_TOP;
|
|
225
|
+
if (rowRange.isBottom || (rowSpan > 1 && rowSpan - 1 >= rowRange.bottomOffset)) flags |= RANGE_BOTTOM;
|
|
226
|
+
if (colIndex === rowRange.left) flags |= RANGE_LEFT;
|
|
227
|
+
if (lastCol === rowRange.right) flags |= RANGE_RIGHT;
|
|
228
|
+
return flags;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The default screen-reader text after a range change. */
|
|
232
|
+
export function defaultCellSelectionAnnouncement(cellCount: number, rowCount: number, columnCount: number): string {
|
|
233
|
+
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
234
|
+
return `${plural(cellCount, 'cell', 'cells')} selected, ${plural(rowCount, 'row', 'rows')} by ${plural(columnCount, 'column', 'columns')}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Above this many cells a copy warns (development only) that exportToCsv suits the job better. */
|
|
238
|
+
export const LARGE_RANGE_COPY_CELLS = 100_000;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line height of a wrapped header title in pixels. Must match `line-height` of
|
|
3
|
+
* `.ogx__header-cell--wrap .ogx__header-cell-title` in Header.css.
|
|
4
|
+
*/
|
|
5
|
+
export const HEADER_WRAP_LINE_HEIGHT = 16;
|
|
6
|
+
|
|
7
|
+
/** Vertical padding of a header cell (`--ogx-grid-cell-padding-y`, 8px by default), top plus bottom. */
|
|
8
|
+
const HEADER_VERTICAL_PADDING = 16;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How many lines a wrapped header title may take inside a header of `headerHeight` pixels: 2 at 56px,
|
|
12
|
+
* 3 at 72px, 4 at 88px. One line less when the aggregation label sits under the title. Never below 1.
|
|
13
|
+
*/
|
|
14
|
+
export function getHeaderLineClamp(headerHeight: number, hasAggregationLabel = false): number {
|
|
15
|
+
const available = Number.isFinite(headerHeight) ? headerHeight - HEADER_VERTICAL_PADDING : 0;
|
|
16
|
+
const lines = Math.floor(available / HEADER_WRAP_LINE_HEIGHT) - (hasAggregationLabel ? 1 : 0);
|
|
17
|
+
return Math.max(1, lines);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Whether a column's header title wraps: the column's own `wrapHeaderText` wins over the grid's. */
|
|
21
|
+
export function shouldWrapHeaderText(columnWrap: boolean | undefined, gridWrap: boolean | undefined): boolean {
|
|
22
|
+
return columnWrap ?? gridWrap ?? false;
|
|
23
|
+
}
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenGridX — AI Agent Context
|
|
2
2
|
# Package: @opencorestack/opengridx
|
|
3
|
-
# Version: 3.
|
|
3
|
+
# Version: 3.3.0
|
|
4
4
|
# Docs: https://opencorestack.github.io/OpenGridX/
|
|
5
5
|
# This file is machine-readable. AI developers: start here.
|
|
6
6
|
|
|
@@ -78,6 +78,7 @@ export default function App() {
|
|
|
78
78
|
| localeText | GridLocaleText | — | Override pager / no-rows strings: paginationRowsPerPage, paginationOf(from,to,count), paginationPage(page,count), noRowsLabel |
|
|
79
79
|
| rowHeight | number | 52 | Row height in pixels |
|
|
80
80
|
| headerHeight | number | 56 | Header height in pixels |
|
|
81
|
+
| wrapHeaderText | boolean | false | Wrap header titles within headerHeight (2 lines at 56px, 3 at 72, 4 at 88; ellipsis on the last line). Header never grows: raise headerHeight for more lines. Class ogx__header-cell--wrap. Per column: GridColDef.wrapHeaderText (3.3.0+) |
|
|
81
82
|
| loading | boolean | false | No rows: skeleton rows (or slots.loadingOverlay). Rows shown: they stay, a progress bar (.ogx__loading-bar) runs along the top, or slots.loadingOverlay is shown over them |
|
|
82
83
|
| noRowsLabel | string | 'No Data' | Empty state message |
|
|
83
84
|
| density | 'compact'/'standard'/'comfortable' | 'standard' | 32px / rowHeight / 72px rows |
|
|
@@ -92,6 +93,10 @@ export default function App() {
|
|
|
92
93
|
| onRowSelectionModelChange | (model: GridRowId[]) => void | Selection change callback. Also fires once with ids of removed rows pruned (client-owned rows) |
|
|
93
94
|
| disableRowSelectionOnClick | boolean | Prevent click toggling selection |
|
|
94
95
|
| disableClipboardCopy | boolean | Turn off the grid's Ctrl/Cmd+C copy (copySelectedRows still works) |
|
|
96
|
+
| cellSelection | boolean (3.3.0+) | Spreadsheet-style cell ranges: drag (auto-scrolls), Shift+click, Shift+arrows/Home/End/PageUp/PageDown, Ctrl/Cmd+A = every data cell of the page, Escape collapses. Ctrl/Cmd+C then copies the range as TSV (no header line) instead of the selected rows. Ranges never change the row selection |
|
|
97
|
+
| cellSelectionModel | GridCellSelectionModel | Controlled range: readonly { anchor: { id, field }, head: { id, field } }[] (0 or 1 range). Corners are ids/fields: the range follows sorting; cleared ('dataChange') when a corner is no longer displayed |
|
|
98
|
+
| onCellSelectionModelChange | (model, { reason }) => void | reason: 'pointer' | 'keyboard' | 'selectAll' | 'api' | 'clear' | 'dataChange' |
|
|
99
|
+
| showCellSelectionStats | boolean | Status bar: Count (non-empty), Sum and Average (numeric) of the range; slots.cellSelectionStats replaces it |
|
|
95
100
|
|
|
96
101
|
### Sorting
|
|
97
102
|
| Prop | Type | Description |
|
|
@@ -247,6 +252,7 @@ There are no getRowClassName / getCellClassName / theme props: use GridColDef.ce
|
|
|
247
252
|
| rowSpan | number / (params) => number | Cell row span (clamped to the row section) |
|
|
248
253
|
| align | 'left'/'center'/'right' | Cell content alignment |
|
|
249
254
|
| headerAlign | 'left'/'center'/'right' | Header content alignment |
|
|
255
|
+
| wrapHeaderText | boolean | Wrap this header title; overrides the grid prop (3.3.0+) |
|
|
250
256
|
| sortable | boolean (default true) | Enable sorting |
|
|
251
257
|
| sortComparator | (v1, v2, params1, params2) => number | Custom ascending comparator (3.1.0+). v1/v2 are valueGetter values incl. null/undefined; params { id, field, row, value }. Negated for desc; multi-sort tie-break chain; groups by grouping value; tree siblings; pivot row labels. Throw/NaN = 0. Ignored in server sortingMode |
|
|
252
258
|
| filterable | boolean (default true) | Enable filtering and quick search |
|
|
@@ -307,6 +313,14 @@ apiRef.current.autosizeColumns(fields?)
|
|
|
307
313
|
apiRef.current.copySelectedRows() // → Promise<void>; rejects if the write fails. Copies every selected row that passes
|
|
308
314
|
// the filter (all pages, collapsed groups, pinned rows), visible columns in screen order
|
|
309
315
|
|
|
316
|
+
// Cell range selection (cellSelection, 3.3.0+); inert while cellSelection is off
|
|
317
|
+
apiRef.current.selectCellRange({ id: 1, field: 'a' }, { id: 5, field: 'c' }) // anchor = active cell
|
|
318
|
+
apiRef.current.setCellSelectionModel(model)
|
|
319
|
+
apiRef.current.getCellSelectionModel() // → GridCellSelectionModel
|
|
320
|
+
apiRef.current.getSelectedCells() // → { id, field, value }[]; synthetic rows and span-covered cells left out
|
|
321
|
+
apiRef.current.copySelectedCells() // → Promise<void>; TSV, no header line
|
|
322
|
+
apiRef.current.clearCellSelection()
|
|
323
|
+
|
|
310
324
|
// Aggregation
|
|
311
325
|
apiRef.current.getAggregationResult() // → Record<string, unknown> | null (null in pivot mode)
|
|
312
326
|
apiRef.current.getAggregationModel() // → GridAggregationModel | null
|
|
@@ -530,7 +544,8 @@ Derived tokens (e.g. `--ogx-grid-cell-focus-border`, which defaults to the prima
|
|
|
530
544
|
Class names use the `ogx__` BEM prefix, e.g.:
|
|
531
545
|
- `ogx__row`, `ogx__row--selected`, `ogx__row--even`, `ogx__row--odd`
|
|
532
546
|
- `ogx__cell`, `ogx__cell--focused`, `ogx__cell--editing`
|
|
533
|
-
- `
|
|
547
|
+
- Cell ranges (3.3.0+): `ogx__cell--range`, `ogx__cell--range-top|bottom|left|right` (inset box-shadow outline), `ogx--range-dragging` on the root during a drag; variables `--ogx-range-background`, `--ogx-range-border` (theme keys `grid.rangeBackground`, `grid.rangeBorder`)
|
|
548
|
+
- `ogx__header-cell`, `ogx__header-cell--sorted`, `ogx__header-cell--wrap` (wrapHeaderText, 3.3.0+)
|
|
534
549
|
- Sticky wrappers: `ogx__sticky-top` (header + top-pinned rows), `ogx__sticky-bottom` (bottom-pinned rows + aggregation footer)
|
|
535
550
|
|
|
536
551
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencorestack/opengridx",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "OpenGridX: High-performance React data infrastructure. Unlock advanced Row Grouping, Excel Export, and Column Pinning without the usual \"Pro\" gatekeeping. Built for speed, scale, and complete architectural freedom. Fully open, virtualization-ready, and feature-complete.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/opengridx.umd.js",
|
|
@@ -55,7 +55,9 @@
|
|
|
55
55
|
"test:smoke": "node scripts/smoke.mjs",
|
|
56
56
|
"test:watch": "vitest --project unit",
|
|
57
57
|
"coverage": "vitest run --coverage",
|
|
58
|
-
"prepublishOnly": "npm run build:lib"
|
|
58
|
+
"prepublishOnly": "npm run build:lib",
|
|
59
|
+
"wiki:build": "node scripts/build-wiki.mjs",
|
|
60
|
+
"check:compiler": "node scripts/check-react-compiler.mjs"
|
|
59
61
|
},
|
|
60
62
|
"keywords": [
|
|
61
63
|
"react",
|
|
@@ -106,6 +108,7 @@
|
|
|
106
108
|
}
|
|
107
109
|
},
|
|
108
110
|
"devDependencies": {
|
|
111
|
+
"@babel/core": "^7.29.7",
|
|
109
112
|
"@eslint/js": "^9.39.1",
|
|
110
113
|
"@playwright/test": "^1.58.2",
|
|
111
114
|
"@testing-library/jest-dom": "^7.0.0",
|
|
@@ -117,8 +120,9 @@
|
|
|
117
120
|
"@vitejs/plugin-react": "^5.1.1",
|
|
118
121
|
"@vitest/browser-playwright": "^4.1.11",
|
|
119
122
|
"@vitest/coverage-v8": "^4.1.11",
|
|
123
|
+
"babel-plugin-react-compiler": "^1.0.0",
|
|
120
124
|
"eslint": "^9.39.1",
|
|
121
|
-
"eslint-plugin-react-hooks": "^7.
|
|
125
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
122
126
|
"eslint-plugin-react-refresh": "^0.4.24",
|
|
123
127
|
"exceljs": "^4.4.0",
|
|
124
128
|
"globals": "^16.5.0",
|