@opencorestack/opengridx 2.0.4 → 3.0.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.
Files changed (221) hide show
  1. package/CHANGELOG.md +408 -3
  2. package/README.md +93 -72
  3. package/dist/index.d.ts +611 -121
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7992 -5650
  6. package/dist/opengridx.es.js.map +1 -1
  7. package/dist/opengridx.umd.js +17 -23
  8. package/dist/opengridx.umd.js.map +1 -1
  9. package/docs/API_REFERENCE.md +348 -161
  10. package/docs/README.md +6 -1
  11. package/docs/architecture/datagrid-orchestration.md +81 -0
  12. package/docs/architecture/grid-row-meta.md +102 -0
  13. package/docs/architecture/use-grid-columns.md +128 -0
  14. package/docs/architecture/use-grid-controlled-state.md +157 -0
  15. package/docs/architecture/use-grid-row-pipeline.md +120 -0
  16. package/docs/architecture/use-grid-scroll-sync.md +111 -0
  17. package/docs/architecture/use-grid-state-snapshot.md +90 -0
  18. package/docs/architecture/use-grid-virtualization.md +115 -0
  19. package/docs/architecture/use-grid-visible-rows.md +85 -0
  20. package/docs/assets/banner-alt.png +0 -0
  21. package/docs/assets/banner.png +0 -0
  22. package/docs/assets/hero.png +0 -0
  23. package/docs/assets/logo.png +0 -0
  24. package/docs/components/aggregation-footer.md +15 -10
  25. package/docs/components/cell.md +16 -6
  26. package/docs/components/column-group-header.md +55 -28
  27. package/docs/components/column-resize.md +17 -13
  28. package/docs/components/column-visibility.md +45 -22
  29. package/docs/components/datagrid.md +14 -12
  30. package/docs/components/empty-state.md +2 -1
  31. package/docs/components/error-overlay.md +12 -7
  32. package/docs/components/filter-panel.md +28 -8
  33. package/docs/components/header.md +28 -9
  34. package/docs/components/pagination.md +14 -8
  35. package/docs/components/row.md +21 -5
  36. package/docs/components/toolbar.md +27 -9
  37. package/docs/components/tooltip.md +16 -7
  38. package/docs/customization/slots-api.md +100 -36
  39. package/docs/customization/theming.md +66 -18
  40. package/docs/features/aggregation-pivot.md +75 -9
  41. package/docs/features/cell-spanning.md +80 -125
  42. package/docs/features/clipboard.md +41 -16
  43. package/docs/features/custom-pagination.md +38 -18
  44. package/docs/features/data-source.md +36 -13
  45. package/docs/features/editing-reordering.md +125 -16
  46. package/docs/features/export-guide.md +147 -41
  47. package/docs/features/filtering.md +44 -6
  48. package/docs/features/infinite-scroll.md +54 -46
  49. package/docs/features/keyboard-navigation.md +83 -0
  50. package/docs/features/list-view.md +58 -5
  51. package/docs/features/loading-states.md +18 -20
  52. package/docs/features/master-detail.md +18 -5
  53. package/docs/features/pdf-export.md +56 -19
  54. package/docs/features/pinning.md +23 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +66 -14
  57. package/docs/features/state-persistence.md +78 -10
  58. package/docs/features/toolbar-customization.md +26 -17
  59. package/docs/features/tree-data-grouping.md +44 -9
  60. package/docs/features/virtualization.md +64 -15
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1222 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +16 -10
  65. package/lib/components/Cell/Cell.css +13 -1
  66. package/lib/components/Cell/Cell.tsx +130 -57
  67. package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
  68. package/lib/components/Cell/GridEditInputCell.tsx +114 -41
  69. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
  70. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +11 -0
  71. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
  72. package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
  73. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
  74. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
  75. package/lib/components/DataGrid/DataGrid.css +84 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +606 -872
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  79. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  80. package/lib/components/DataGrid/GridListView.tsx +166 -23
  81. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  82. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  83. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  84. package/lib/components/DataGrid/GridPinnedRows.tsx +65 -11
  85. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  86. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  87. package/lib/components/DataGrid/GridVirtualRows.tsx +48 -25
  88. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  89. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  90. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  91. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  92. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  93. package/lib/components/Header/ColumnMenu.tsx +91 -39
  94. package/lib/components/Header/Header.css +3 -3
  95. package/lib/components/Header/Header.tsx +179 -160
  96. package/lib/components/ListView/ListView.css +25 -0
  97. package/lib/components/ListView/ListViewRow.tsx +79 -10
  98. package/lib/components/Pagination/Pagination.tsx +15 -7
  99. package/lib/components/Row/Row.tsx +185 -75
  100. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  101. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  102. package/lib/components/Toolbar/GridToolbar.css +12 -11
  103. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  104. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  105. package/lib/components/Tooltip/Tooltip.css +20 -0
  106. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  107. package/lib/components/ui/Button.tsx +3 -0
  108. package/lib/components/ui/Checkbox.tsx +2 -1
  109. package/lib/components/ui/ExpandIcon.css +0 -9
  110. package/lib/components/ui/ExpandIcon.tsx +4 -1
  111. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  112. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  113. package/lib/hooks/core/useDataGrid.ts +115 -173
  114. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  115. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  116. package/lib/hooks/core/useGridApiRef.ts +15 -3
  117. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  118. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  119. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  120. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  121. package/lib/hooks/core/useGridColumns.tsx +284 -76
  122. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  123. package/lib/hooks/core/useGridControlledState.ts +99 -6
  124. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  125. package/lib/hooks/core/useGridDevWarnings.ts +123 -0
  126. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  127. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  128. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  129. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  130. package/lib/hooks/core/useGridKeyboardNavigation.ts +452 -250
  131. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  132. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  133. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  134. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  135. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  136. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  137. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  138. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  139. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  140. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  141. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  142. package/lib/hooks/core/useGridViewport.ts +38 -0
  143. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  144. package/lib/hooks/core/useGridVisibleRows.ts +20 -7
  145. package/lib/hooks/core/useLayout.ts +215 -195
  146. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  147. package/lib/hooks/core/useStableColumns.ts +34 -0
  148. package/lib/hooks/features/useAggregation.ts +86 -90
  149. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  150. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  151. package/lib/hooks/features/useGridClipboard.ts +133 -73
  152. package/lib/hooks/features/useGridDataSource.ts +354 -128
  153. package/lib/hooks/features/useGridEditing.ts +211 -51
  154. package/lib/hooks/features/useGridPivot.ts +92 -0
  155. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  156. package/lib/hooks/features/useGridSpanning.ts +410 -306
  157. package/lib/hooks/features/usePivot.ts +0 -0
  158. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  159. package/lib/hooks/useColumnReorder.ts +38 -44
  160. package/lib/hooks/useRowGrouping.ts +335 -214
  161. package/lib/hooks/useRowReorder.ts +62 -31
  162. package/lib/hooks/useTreeData.ts +330 -262
  163. package/lib/index.ts +30 -2
  164. package/lib/state/index.ts +1 -0
  165. package/lib/state/types.ts +7 -1
  166. package/lib/state/useGridStateStorage.ts +88 -29
  167. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  168. package/lib/theme/gridThemeContext.ts +5 -0
  169. package/lib/theme/index.ts +5 -0
  170. package/lib/theme/presets.ts +5 -4
  171. package/lib/theme/types.ts +40 -2
  172. package/lib/types/index.ts +311 -108
  173. package/lib/utils/aggregation/index.ts +157 -0
  174. package/lib/utils/aria/index.ts +40 -0
  175. package/lib/utils/columnGroups.ts +110 -0
  176. package/lib/utils/columnLookup.ts +33 -0
  177. package/lib/utils/columnWidth.ts +22 -0
  178. package/lib/utils/dataSource/index.ts +46 -0
  179. package/lib/utils/detailPanel.ts +66 -0
  180. package/lib/utils/dragData.ts +44 -0
  181. package/lib/utils/editing/index.ts +30 -0
  182. package/lib/utils/export/exportShared.ts +197 -0
  183. package/lib/utils/export/exportToExcelAdvanced.ts +344 -121
  184. package/lib/utils/export/exportToPdf.ts +143 -92
  185. package/lib/utils/export/groupLabel.ts +14 -0
  186. package/lib/utils/export/index.ts +278 -272
  187. package/lib/utils/export/pdfText.ts +50 -0
  188. package/lib/utils/filtering/index.ts +277 -148
  189. package/lib/utils/focus/index.ts +115 -0
  190. package/lib/utils/gridApiRows.ts +35 -0
  191. package/lib/utils/gridModes.ts +77 -0
  192. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  193. package/lib/utils/pagination/index.ts +56 -0
  194. package/lib/utils/pinning/index.ts +77 -33
  195. package/lib/utils/pivot/index.ts +368 -0
  196. package/lib/utils/scroll/index.ts +78 -0
  197. package/lib/utils/scroll/scrollEnd.ts +18 -0
  198. package/lib/utils/sorting/index.ts +154 -30
  199. package/lib/utils/syntheticRows.ts +12 -0
  200. package/lib/utils/values.ts +206 -0
  201. package/lib/utils/viewport.ts +14 -0
  202. package/llms.txt +272 -166
  203. package/package.json +18 -6
  204. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  205. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  206. package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
  207. package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
  208. package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
  209. package/lib/components/Header/Header.test.tsx +0 -135
  210. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  211. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  212. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  213. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  214. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  215. package/lib/hooks/features/useAggregation.test.ts +0 -175
  216. package/lib/hooks/features/usePivot.test.ts +0 -202
  217. package/lib/hooks/useRowGrouping.test.ts +0 -137
  218. package/lib/utils/export/exportToPdf.test.ts +0 -120
  219. package/lib/utils/export/groupedExport.test.ts +0 -144
  220. package/lib/utils/filtering/filtering.test.ts +0 -178
  221. package/lib/utils/sorting/sorting.test.ts +0 -88
@@ -1,18 +1,30 @@
1
1
  import React, { useMemo } from 'react';
2
- import { formatAggregationValue } from '../../hooks/features/useAggregation';
2
+ import { formatAggregateForColumn, isAggregationAllowed } from '../../utils/aggregation';
3
3
  import type { GridColDef, GridAggregationModel, GridAggregationResult, GridColumnPinning } from '../../types';
4
- import { calculatePinnedPositions, isColumnPinned } from '../../utils/pinning';
4
+ import { calculatePinnedPositions, getPinnedEdgeFields, isColumnPinned } from '../../utils/pinning';
5
+ import { getRenderedColumnWidth } from '../../utils/columnWidth';
6
+
7
+ const SYSTEM_COLUMN_WIDTH = 48;
5
8
 
6
9
  interface GridAggregationFooterProps {
10
+ /**
11
+ * The columns exactly as the rows lay them out: visible only, pinned-left first and pinned-right
12
+ * last, with the horizontal-virtualization spacers (`virtualization.virtualColumns`).
13
+ */
7
14
  columns: GridColDef[];
8
15
  aggregationModel: GridAggregationModel;
9
16
  aggregationResult: GridAggregationResult;
17
+ /** User resize overrides, as passed to the rows. */
10
18
  columnWidths: Record<string, number>;
11
19
  rowHeight: number;
12
20
  checkboxSelection: boolean;
13
21
  hasDetailPanel: boolean;
14
22
  rowReordering: boolean;
23
+ pinCheckboxColumn?: boolean;
24
+ pinExpandColumn?: boolean;
15
25
  pinnedColumns?: GridColumnPinning;
26
+ /** A server aggregation request is in flight. */
27
+ loading?: boolean;
16
28
  }
17
29
 
18
30
  export function GridAggregationFooter({
@@ -24,44 +36,68 @@ export function GridAggregationFooter({
24
36
  checkboxSelection,
25
37
  hasDetailPanel,
26
38
  rowReordering,
39
+ pinCheckboxColumn = true,
40
+ pinExpandColumn = true,
27
41
  pinnedColumns,
42
+ loading = false,
28
43
  }: GridAggregationFooterProps) {
29
- // Compute sticky left/right pixel offsets for each pinned column.
30
- // The footer always renders checkbox/detail/reorder spacers unconditionally,
31
- // so treat pinCheckboxColumn and pinExpandColumn as always true.
44
+ // Same offsets as Row, so pinned totals sit exactly under their pinned body cells.
32
45
  const pinnedOffsets = useMemo(
33
46
  () => calculatePinnedPositions(
34
47
  columns,
35
48
  columnWidths,
36
49
  pinnedColumns,
37
50
  checkboxSelection,
38
- true,
51
+ pinCheckboxColumn,
39
52
  hasDetailPanel,
40
- true,
53
+ pinExpandColumn,
41
54
  rowReordering,
42
55
  ),
43
- [columns, columnWidths, pinnedColumns, checkboxSelection, hasDetailPanel, rowReordering]
56
+ [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]
57
+ );
58
+
59
+ const { lastLeft: lastLeftField, firstRight: firstRightField } = useMemo(
60
+ () => getPinnedEdgeFields(columns, pinnedColumns),
61
+ [columns, pinnedColumns]
44
62
  );
45
63
 
46
- const lastLeftField = pinnedColumns?.left?.[pinnedColumns.left.length - 1];
47
- const firstRightField = pinnedColumns?.right?.[0];
64
+ // System-column spacers stick exactly where Row sticks its drag handle, expand and checkbox cells.
65
+ const expandLeft = rowReordering ? SYSTEM_COLUMN_WIDTH : 0;
66
+ const checkboxLeft = expandLeft + (hasDetailPanel && pinExpandColumn ? SYSTEM_COLUMN_WIDTH : 0);
67
+ const spacer = (key: string, pinned: boolean, left: number) => (
68
+ <div
69
+ key={key}
70
+ className={`ogx__aggregation-spacer${pinned ? ' ogx__aggregation-spacer--pinned' : ''}`}
71
+ style={pinned ? { left } : undefined}
72
+ aria-hidden="true"
73
+ />
74
+ );
48
75
 
49
76
  return (
50
77
  <div
51
- className="ogx__aggregation-footer"
78
+ className={`ogx__aggregation-footer${loading ? ' ogx__aggregation-footer--loading' : ''}`}
52
79
  role="row"
53
80
  aria-label="Aggregation totals"
54
81
  aria-live="polite"
82
+ aria-busy={loading || undefined}
55
83
  style={{ minHeight: `${rowHeight}px` }}
56
84
  >
57
- {checkboxSelection && <div style={{ width: 48, flexShrink: 0 }} />}
58
- {hasDetailPanel && <div style={{ width: 48, flexShrink: 0 }} />}
59
- {rowReordering && <div style={{ width: 48, flexShrink: 0 }} />}
85
+ {rowReordering && spacer('__reorder_col__', true, 0)}
86
+ {hasDetailPanel && spacer('__expand_col__', pinExpandColumn, expandLeft)}
87
+ {checkboxSelection && spacer('__checkbox_col__', pinCheckboxColumn, checkboxLeft)}
60
88
 
61
89
  {columns.map((col) => {
62
- const fnName = (aggregationModel as Record<string, string>)[col.field];
90
+ if (col.isSpacer) {
91
+ const width = typeof col.width === 'number' ? col.width : 0;
92
+ return <div key={col.field} style={{ width, minWidth: width, flexShrink: 0 }} aria-hidden="true" />;
93
+ }
94
+
95
+ // A model entry the column forbids (aggregable: false, or a function outside
96
+ // availableAggregationFunctions) shows nothing, as the engine computes nothing for it.
97
+ const modelFn = (aggregationModel as Record<string, string>)[col.field];
98
+ const fnName = modelFn && isAggregationAllowed(col, modelFn) ? modelFn : undefined;
63
99
  const rawValue = aggregationResult[col.field];
64
- const colWidth = columnWidths[col.field] ?? (typeof col.width === 'number' ? col.width : 120);
100
+ const colWidth = getRenderedColumnWidth(col, columnWidths, 120);
65
101
 
66
102
  const pinnedPosition = isColumnPinned(col.field, pinnedColumns);
67
103
  const pinnedOffset = pinnedPosition ? pinnedOffsets[col.field] : undefined;
@@ -94,7 +130,7 @@ export function GridAggregationFooter({
94
130
  <>
95
131
  <span className="ogx__aggregation-label">{fnName}</span>
96
132
  <span className="ogx__aggregation-value">
97
- {formatAggregationValue(rawValue, fnName)}
133
+ {formatAggregateForColumn(rawValue, fnName, col, aggregationResult)}
98
134
  </span>
99
135
  </>
100
136
  ) : null}
@@ -1,9 +1,15 @@
1
+ import type React from 'react';
2
+
1
3
  interface GridEmptyStateProps {
2
4
  noRowsLabel: string;
3
5
  width: number;
6
+ overlay?: React.ReactNode;
4
7
  }
5
8
 
6
- export function GridEmptyState({ noRowsLabel, width }: GridEmptyStateProps) {
9
+ export function GridEmptyState({ noRowsLabel, width, overlay }: GridEmptyStateProps) {
10
+ if (overlay) {
11
+ return <div className="ogx__empty" style={{ width }}>{overlay}</div>;
12
+ }
7
13
  return (
8
14
  <div className="ogx__empty" style={{ width }}>
9
15
  <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -1,14 +1,14 @@
1
+ import { getDataSourceErrorMessage } from '../../utils/dataSource';
2
+
1
3
  interface GridErrorOverlayProps {
2
4
  error: unknown;
5
+ /** Runs the failed request again. Without it, no Retry button is shown. */
6
+ onRetry?: () => void;
3
7
  }
4
8
 
5
- export function GridErrorOverlay({ error }: GridErrorOverlayProps) {
9
+ export function GridErrorOverlay({ error, onRetry }: GridErrorOverlayProps) {
6
10
  if (!error) return null;
7
- const message = error instanceof Error
8
- ? error.message
9
- : typeof error === 'string'
10
- ? error
11
- : 'An unexpected error occurred while loading the data.';
11
+ const message = getDataSourceErrorMessage(error);
12
12
  return (
13
13
  <div className="ogx-error-overlay" aria-live="assertive" role="alert">
14
14
  <svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--ogx-color-error)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -22,12 +22,15 @@ export function GridErrorOverlay({ error }: GridErrorOverlayProps) {
22
22
  <div className="ogx-error-overlay__message">
23
23
  {message}
24
24
  </div>
25
- <button
26
- onClick={() => window.location.reload()}
27
- className="ogx-button ogx-button--primary"
28
- >
29
- Retry
30
- </button>
25
+ {onRetry && (
26
+ <button
27
+ type="button"
28
+ onClick={onRetry}
29
+ className="ogx-button ogx-button--primary"
30
+ >
31
+ Retry
32
+ </button>
33
+ )}
31
34
  </div>
32
35
  );
33
36
  }
@@ -1,85 +1,217 @@
1
1
  import React from 'react';
2
2
  import { Pagination } from '../Pagination/Pagination';
3
3
  import { ListViewRow } from '../ListView/ListViewRow';
4
+ import { getPageCount, normalizePageSize } from '../../utils/pagination';
5
+ import { useRowsScrollEndLatch } from '../../hooks/core/useRowsScrollEndLatch';
4
6
  import type {
7
+ GridColDef,
5
8
  GridRowModel,
6
9
  GridRowId,
10
+ GridRowMeta,
7
11
  GridRowParams,
8
12
  GridListViewColDef,
9
13
  GridPaginationModel,
10
- GridDataSource,
11
14
  GridLocaleText,
15
+ GridRowScrollEndParams,
16
+ GridSlots,
12
17
  } from '../../types';
13
18
 
14
19
  export interface GridListViewProps<R extends GridRowModel> {
15
20
  ariaLabel?: string;
21
+ /** Pinned-top rows, the current page and pinned-bottom rows: what the grid view renders. */
16
22
  allRenderableRows: R[];
17
- filteredRows: R[];
23
+ /** Filtered data rows (no group, subtotal or auto-created parent rows): the "N items" count. */
24
+ dataRowCount: number;
25
+ /** Number of rows at the start of allRenderableRows that are pinned to the top. */
26
+ pinnedTopRowCount: number;
27
+ /** Number of rows at the end of allRenderableRows that are pinned to the bottom. */
28
+ pinnedBottomRowCount: number;
29
+ /** Filtered, unpinned rows across all pages (the rows pagination splits into pages). */
30
+ unpinnedRowCount: number;
18
31
  pagination: boolean;
19
32
  effectivePaginationModel: GridPaginationModel;
33
+ /** The page actually shown (the requested page clamped to the last page). */
34
+ currentPage: number;
20
35
  pageSizeOptions: number[];
21
36
  selectedRowIds: Set<GridRowId>;
22
37
  listViewColumn: GridListViewColDef<R>;
38
+ /** Grid columns by field; a list field that names one gets its valueGetter / valueFormatter. */
39
+ columnsByField: ReadonlyMap<string, GridColDef>;
40
+ rowMetaMap: Map<GridRowId, GridRowMeta>;
23
41
  noRowsLabel: string;
24
42
  rowHeight: number;
25
43
  checkboxSelection: boolean;
26
44
  paginationMode: 'client' | 'server' | 'infinite';
27
- dataSource?: GridDataSource<R>;
45
+ /** The grid's pagination total; used in server mode. */
28
46
  serverRowCount: number;
29
- paginationSlot?: React.ComponentType<Record<string, unknown>>;
47
+ paginationSlot?: GridSlots['pagination'];
30
48
  paginationSlotProps?: Record<string, unknown>;
31
49
  localeText?: Pick<GridLocaleText, 'paginationRowsPerPage' | 'paginationOf' | 'paginationPage'>;
32
50
  onRowClick: (params: GridRowParams<R>) => void;
51
+ onRowDoubleClick?: (params: GridRowParams<R>) => void;
33
52
  onSelectionChange: (rowId: GridRowId, isSelected: boolean) => void;
34
53
  onPaginationModelChange: (model: GridPaginationModel) => void;
54
+ onRowsScrollEnd?: (params: GridRowScrollEndParams) => void;
55
+ /** Resolves a row's id; defaults to `row.id`. */
56
+ getRowId?: (row: R) => GridRowId;
57
+ /** Whether several rows can be selected (aria-multiselectable). */
58
+ multiselectable?: boolean;
59
+ /** The grid is loading (the `loading` prop or a data source fetch). */
60
+ loading?: boolean;
61
+ /** Rendered `slots.loadingOverlay`, shown instead of the rows while loading with no rows. */
62
+ loadingOverlay?: React.ReactNode;
63
+ /** Rendered `slots.noRowsOverlay`, shown instead of the default empty state. */
64
+ noRowsOverlay?: React.ReactNode;
65
+ /** False when `slots.footer` replaces the pagination area. Defaults to true. */
66
+ showPaginationControls?: boolean;
67
+ /** Whether rows can be selected (Shift+Space toggles the focused row). */
68
+ selectable?: boolean;
69
+ /** Expands / collapses a tree-data parent or group row (Enter, Space, Alt+ArrowRight / Alt+ArrowLeft). */
70
+ onToggleExpansion?: (id: GridRowId) => void;
35
71
  }
36
72
 
73
+ const defaultGetRowId = <R extends GridRowModel>(row: R): GridRowId => row.id;
74
+
37
75
  export function GridListView<R extends GridRowModel>({
38
76
  ariaLabel,
39
77
  allRenderableRows,
40
- filteredRows,
78
+ dataRowCount,
79
+ pinnedTopRowCount,
80
+ pinnedBottomRowCount,
81
+ unpinnedRowCount,
41
82
  pagination,
42
83
  effectivePaginationModel,
84
+ currentPage,
43
85
  pageSizeOptions,
44
86
  selectedRowIds,
45
87
  listViewColumn,
88
+ columnsByField,
89
+ rowMetaMap,
46
90
  noRowsLabel,
47
91
  rowHeight,
48
92
  checkboxSelection,
49
93
  paginationMode,
50
- dataSource,
51
94
  serverRowCount,
52
95
  paginationSlot,
53
96
  paginationSlotProps,
54
97
  localeText,
55
98
  onRowClick,
99
+ onRowDoubleClick,
56
100
  onSelectionChange,
57
101
  onPaginationModelChange,
102
+ onRowsScrollEnd,
103
+ getRowId = defaultGetRowId,
104
+ multiselectable = false,
105
+ loading = false,
106
+ loadingOverlay,
107
+ noRowsOverlay,
108
+ showPaginationControls = true,
109
+ selectable = false,
110
+ onToggleExpansion,
58
111
  }: GridListViewProps<R>) {
59
112
  const PaginationComponent = paginationSlot || Pagination;
60
113
 
61
- const totalRowCount = (paginationMode === 'server' && dataSource)
62
- ? (serverRowCount || 0)
63
- : filteredRows.length;
114
+ // Roving tab stop: the list is one Tab stop, on the last focused row (the first row until then,
115
+ // or when that row is no longer rendered).
116
+ const rowsRef = React.useRef<HTMLDivElement>(null);
117
+ const [focusedRowId, setFocusedRowId] = React.useState<GridRowId | null>(null);
118
+ const focusedIndex = focusedRowId === null ? -1 : allRenderableRows.findIndex(row => getRowId(row) === focusedRowId);
119
+ const tabStopIndex = focusedIndex === -1 ? 0 : focusedIndex;
120
+
121
+ const focusRow = (index: number) => {
122
+ const row = allRenderableRows[index];
123
+ if (!row) return;
124
+ setFocusedRowId(getRowId(row));
125
+ rowsRef.current?.querySelector<HTMLElement>(`:scope > [role="row"][data-rowindex="${index}"]`)?.focus();
126
+ };
127
+
128
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
129
+ const target = event.target as HTMLElement;
130
+ // Keys pressed in content rendered inside a row (inputs, buttons) belong to that content.
131
+ if (target.getAttribute('role') !== 'row' || target.parentElement !== rowsRef.current) return;
132
+ const index = Number(target.getAttribute('data-rowindex'));
133
+ const row = allRenderableRows[index];
134
+ if (!row) return;
135
+ const id = getRowId(row);
136
+ const last = allRenderableRows.length - 1;
137
+ const meta = rowMetaMap.get(id);
138
+ const canToggle = Boolean(meta?.hasChildren && onToggleExpansion);
139
+ if (event.altKey && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
140
+ event.preventDefault();
141
+ if (canToggle && (event.key === 'ArrowRight') !== (meta?.isExpanded === true)) onToggleExpansion?.(id);
142
+ return;
143
+ }
144
+ switch (event.key) {
145
+ case 'ArrowDown': event.preventDefault(); focusRow(Math.min(index + 1, last)); return;
146
+ case 'ArrowUp': event.preventDefault(); focusRow(Math.max(index - 1, 0)); return;
147
+ case 'Home': event.preventDefault(); focusRow(0); return;
148
+ case 'End': event.preventDefault(); focusRow(last); return;
149
+ case 'PageDown': event.preventDefault(); focusRow(Math.min(index + 10, last)); return;
150
+ case 'PageUp': event.preventDefault(); focusRow(Math.max(index - 10, 0)); return;
151
+ case 'Enter':
152
+ case ' ':
153
+ event.preventDefault();
154
+ if (event.key === ' ' && event.shiftKey) {
155
+ // Synthetic group rows are not data, so they are not selectable.
156
+ if (selectable && !meta?.isGroupRow) onSelectionChange(id, !selectedRowIds.has(id));
157
+ return;
158
+ }
159
+ // A row with children (tree-data parent or group row) expands / collapses, as Enter does in
160
+ // the grid view; any other row acts as clicked (onRowClick and click selection).
161
+ if (canToggle) onToggleExpansion?.(id);
162
+ else onRowClick({ row, id, rowIndex: index });
163
+ return;
164
+ default:
165
+ }
166
+ };
167
+
168
+ // Under server pagination only the current page is loaded; the server reports the total.
169
+ const isServerPaged = paginationMode === 'server';
170
+ // Rows split into pages (pinned rows stay on every page, as in the grid view).
171
+ const pagedRowCount = isServerPaged ? (serverRowCount || 0) : unpinnedRowCount;
172
+ const itemCount = isServerPaged ? (serverRowCount || 0) : dataRowCount;
173
+ const pageSize = normalizePageSize(effectivePaginationModel.pageSize);
174
+ const pageCount = getPageCount(pagedRowCount, pageSize);
175
+
176
+ // aria-rowindex is 1-based over the whole data set (the list view has no header row):
177
+ // pinned-top rows, then the unpinned rows across all pages, then pinned-bottom rows.
178
+ const pageOffset = pagination ? currentPage * pageSize : 0;
179
+ const centerRowsOnPage = allRenderableRows.length - pinnedTopRowCount - pinnedBottomRowCount;
180
+ const ariaRowIndex = (idx: number): number => {
181
+ if (idx < pinnedTopRowCount) return idx + 1;
182
+ const centerIdx = idx - pinnedTopRowCount;
183
+ if (centerIdx < centerRowsOnPage) return pinnedTopRowCount + pageOffset + centerIdx + 1;
184
+ return pinnedTopRowCount + pagedRowCount + (centerIdx - centerRowsOnPage) + 1;
185
+ };
186
+
187
+ const handleScroll = useRowsScrollEndLatch(onRowsScrollEnd, allRenderableRows.length);
64
188
 
65
189
  return (
66
190
  <div
67
191
  className="ogx-list-view"
68
192
  role="grid"
69
193
  aria-label={ariaLabel || 'Data grid list view'}
70
- aria-rowcount={allRenderableRows.length + 1}
194
+ aria-rowcount={itemCount}
195
+ aria-multiselectable={multiselectable}
71
196
  >
72
197
  <div className="ogx-list-view__toolbar">
73
198
  <span>
74
- {filteredRows.length} {filteredRows.length === 1 ? 'item' : 'items'}
75
- {pagination ? ` · page ${effectivePaginationModel.page + 1} of ${Math.ceil(filteredRows.length / effectivePaginationModel.pageSize) || 1}` : ''}
199
+ {itemCount} {itemCount === 1 ? 'item' : 'items'}
200
+ {pagination ? ` · page ${currentPage + 1} of ${pageCount}` : ''}
76
201
  {selectedRowIds.size > 0 ? ` · ${selectedRowIds.size} selected` : ''}
77
202
  </span>
78
203
  </div>
79
204
 
80
- <div className="ogx-list-view__rows">
81
- {allRenderableRows.length === 0 ? (
82
- <div className="ogx-list-view__empty" aria-live="polite" role="status">
205
+ <div className="ogx-list-view__rows" onScroll={handleScroll} onKeyDown={handleKeyDown} ref={rowsRef}>
206
+ {allRenderableRows.length === 0 && loading ? (
207
+ // The grid's live region announces loading; the list shows no "No Data" meanwhile.
208
+ <div className="ogx-list-view__loading">
209
+ {loadingOverlay ?? <div className="ogx__loading-bar" role="progressbar" aria-label="Loading data" />}
210
+ </div>
211
+ ) : allRenderableRows.length === 0 && noRowsOverlay ? (
212
+ <div className="ogx-list-view__empty">{noRowsOverlay}</div>
213
+ ) : allRenderableRows.length === 0 ? (
214
+ <div className="ogx-list-view__empty">
83
215
  <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
84
216
  <rect x="3" y="3" width="18" height="18" rx="2" />
85
217
  <path d="M3 9h18M9 21V9" />
@@ -87,27 +219,38 @@ export function GridListView<R extends GridRowModel>({
87
219
  {noRowsLabel}
88
220
  </div>
89
221
  ) : (
90
- allRenderableRows.map((row, idx) => (
222
+ allRenderableRows.map((row, idx) => {
223
+ const id = getRowId(row);
224
+ return (
91
225
  <ListViewRow<R>
92
- key={row.id}
226
+ key={id}
93
227
  row={row}
228
+ rowId={id}
94
229
  rowIndex={idx}
230
+ ariaRowIndex={ariaRowIndex(idx)}
95
231
  listViewColumn={listViewColumn}
96
- isSelected={selectedRowIds.has(row.id)}
232
+ gridColumn={columnsByField.get(listViewColumn.field) as GridColDef<R> | undefined}
233
+ rowMeta={rowMetaMap.get(id)}
234
+ isSelected={selectedRowIds.has(id)}
97
235
  checkboxSelection={checkboxSelection}
98
236
  rowHeight={rowHeight}
99
- onRowClick={(r) => onRowClick({ row: r, id: r.id, rowIndex: idx })}
237
+ onRowClick={(r) => onRowClick({ row: r, id, rowIndex: idx })}
238
+ onRowDoubleClick={onRowDoubleClick ? (r) => onRowDoubleClick({ row: r, id, rowIndex: idx }) : undefined}
100
239
  onSelectionChange={onSelectionChange}
240
+ tabIndex={idx === tabStopIndex ? 0 : -1}
241
+ onToggleExpansion={onToggleExpansion}
242
+ onFocus={(rowIndex) => setFocusedRowId(getRowId(allRenderableRows[rowIndex]))}
101
243
  />
102
- ))
244
+ );
245
+ })
103
246
  )}
104
247
  </div>
105
248
 
106
- {pagination && (
249
+ {pagination && showPaginationControls && (
107
250
  <PaginationComponent
108
- page={effectivePaginationModel.page}
251
+ page={currentPage}
109
252
  pageSize={effectivePaginationModel.pageSize}
110
- rowCount={totalRowCount}
253
+ rowCount={pagedRowCount}
111
254
  pageSizeOptions={pageSizeOptions}
112
255
  onPageChange={(newPage: number) => onPaginationModelChange({ ...effectivePaginationModel, page: newPage })}
113
256
  onPageSizeChange={(newPageSize: number) => onPaginationModelChange({ ...effectivePaginationModel, pageSize: newPageSize, page: 0 })}
@@ -0,0 +1,31 @@
1
+ import { getDataSourceErrorMessage } from '../../utils/dataSource';
2
+ import type { GridFilterModel } from '../../types';
3
+
4
+ interface GridLiveRegionProps {
5
+ loading: boolean;
6
+ /** The dataSource error, if any. */
7
+ error: unknown;
8
+ noRowsLabel: string;
9
+ /** Rows that pass the filter (grid rows, group rows included). */
10
+ filteredRowCount: number;
11
+ /** Data rows that pass the filter: the "n rows found" count. */
12
+ dataRowCount: number;
13
+ filterModel: GridFilterModel | undefined;
14
+ }
15
+
16
+ /** Polite status announcements: loading, a load error, no rows, or the row count of an active filter. */
17
+ export function GridLiveRegion({ loading, error, noRowsLabel, filteredRowCount, dataRowCount, filterModel }: GridLiveRegionProps) {
18
+ return (
19
+ <div className="ogx-aria-live-status" role="status" aria-live="polite">
20
+ {loading ? 'Loading data...' : ''}
21
+ {error ? `Error: ${getDataSourceErrorMessage(error)}` : ''}
22
+ {!loading && !error && (
23
+ filteredRowCount === 0
24
+ ? noRowsLabel
25
+ : (filterModel && ((filterModel.quickFilterValues?.length || 0) > 0 || (filterModel.items?.length || 0) > 0))
26
+ ? `${dataRowCount} ${dataRowCount === 1 ? 'row' : 'rows'} found`
27
+ : ''
28
+ )}
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+
3
+ interface GridLoadingOverlayProps {
4
+ /** `slots.loadingOverlay`, rendered centred over the rows on a dimmed backdrop. */
5
+ overlay?: React.ReactNode;
6
+ }
7
+
8
+ /**
9
+ * Shown while the grid is loading but already has rows (a reload, a server sort or filter): the
10
+ * rows stay visible and interactive, and a progress bar runs along the top of the grid. A custom
11
+ * `slots.loadingOverlay` is shown centred instead. With no rows yet, the body shows skeleton rows
12
+ * or the loading overlay in place of the rows, so this is not rendered.
13
+ */
14
+ export function GridLoadingOverlay({ overlay }: GridLoadingOverlayProps) {
15
+ if (overlay) {
16
+ return (
17
+ <div className="ogx__loading-overlay ogx__loading-overlay--over-rows ogx__loading-overlay--custom">
18
+ {overlay}
19
+ </div>
20
+ );
21
+ }
22
+ return (
23
+ <div className="ogx__loading-overlay ogx__loading-overlay--over-rows">
24
+ <div className="ogx__loading-bar" role="progressbar" aria-label="Loading data" />
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { Pagination } from '../Pagination/Pagination';
3
+ import { pickPaginationLocaleText } from '../../utils/pagination';
4
+ import type { GridLocaleText, GridPaginationModel, GridPaginationSlotProps } from '../../types';
5
+
6
+ interface GridPaginationAreaProps {
7
+ /** `slots.pagination`; the built-in `Pagination` otherwise. */
8
+ paginationSlot?: React.ComponentType<GridPaginationSlotProps & Record<string, unknown>>;
9
+ /** `slotProps.pagination`, merged over the grid's props. */
10
+ paginationSlotProps?: Partial<GridPaginationSlotProps> & Record<string, unknown>;
11
+ /** The page shown (clamped by the row pipeline). */
12
+ page: number;
13
+ paginationModel: GridPaginationModel;
14
+ rowCount: number;
15
+ pageSizeOptions: number[];
16
+ onPaginationModelChange: (model: GridPaginationModel) => void;
17
+ localeText?: GridLocaleText;
18
+ }
19
+
20
+ /** The grid's pager below the viewport: a page change keeps the page size, a page-size change returns to page 0. */
21
+ export function GridPaginationArea({
22
+ paginationSlot,
23
+ paginationSlotProps,
24
+ page,
25
+ paginationModel,
26
+ rowCount,
27
+ pageSizeOptions,
28
+ onPaginationModelChange,
29
+ localeText,
30
+ }: GridPaginationAreaProps) {
31
+ const PaginationComponent = paginationSlot || Pagination;
32
+ return (
33
+ <PaginationComponent
34
+ page={page}
35
+ pageSize={paginationModel.pageSize}
36
+ rowCount={rowCount}
37
+ pageSizeOptions={pageSizeOptions}
38
+ onPageChange={(newPage: number) => onPaginationModelChange({ ...paginationModel, page: newPage })}
39
+ onPageSizeChange={(newPageSize: number) => onPaginationModelChange({ ...paginationModel, pageSize: newPageSize, page: 0 })}
40
+ localeText={pickPaginationLocaleText(localeText)}
41
+ {...paginationSlotProps}
42
+ />
43
+ );
44
+ }