@opencorestack/opengridx 2.1.0 → 3.0.1

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 (225) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/README.md +95 -72
  3. package/dist/index.d.ts +590 -131
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7891 -5631
  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 -166
  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 +6 -7
  29. package/docs/components/datagrid.md +15 -13
  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 +90 -38
  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 +126 -39
  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 +72 -19
  54. package/docs/features/pinning.md +24 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +64 -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 +36 -8
  60. package/docs/features/virtualization.md +37 -14
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1233 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +5 -5
  65. package/lib/components/Cell/Cell.css +17 -1
  66. package/lib/components/Cell/Cell.tsx +129 -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 +15 -2
  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 +85 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +582 -876
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  79. package/lib/components/DataGrid/GridListView.tsx +165 -24
  80. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  81. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  82. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  83. package/lib/components/DataGrid/GridPinnedRows.tsx +62 -11
  84. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  85. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  86. package/lib/components/DataGrid/GridVirtualRows.tsx +40 -24
  87. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  88. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  89. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  90. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  91. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  92. package/lib/components/Header/ColumnMenu.tsx +91 -39
  93. package/lib/components/Header/Header.css +6 -3
  94. package/lib/components/Header/Header.tsx +181 -160
  95. package/lib/components/ListView/ListView.css +25 -0
  96. package/lib/components/ListView/ListViewRow.tsx +76 -10
  97. package/lib/components/Pagination/Pagination.tsx +15 -7
  98. package/lib/components/Row/Row.tsx +177 -77
  99. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  100. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  101. package/lib/components/Toolbar/GridToolbar.css +12 -11
  102. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  103. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  104. package/lib/components/Tooltip/Tooltip.css +20 -0
  105. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  106. package/lib/components/ui/Button.tsx +3 -0
  107. package/lib/components/ui/Checkbox.tsx +2 -1
  108. package/lib/components/ui/ExpandIcon.css +0 -9
  109. package/lib/components/ui/ExpandIcon.tsx +4 -1
  110. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  111. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  112. package/lib/hooks/core/useDataGrid.ts +115 -173
  113. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  114. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  115. package/lib/hooks/core/useGridApiRef.ts +19 -4
  116. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  117. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  118. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  119. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  120. package/lib/hooks/core/useGridColumns.tsx +279 -76
  121. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  122. package/lib/hooks/core/useGridControlledState.ts +99 -6
  123. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  124. package/lib/hooks/core/useGridDevWarnings.ts +104 -5
  125. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  126. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  127. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  128. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  129. package/lib/hooks/core/useGridKeyboardNavigation.ts +447 -243
  130. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  131. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  132. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  133. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  134. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  135. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  136. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  137. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  138. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  139. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  140. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  141. package/lib/hooks/core/useGridViewport.ts +38 -0
  142. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  143. package/lib/hooks/core/useGridVisibleRows.ts +17 -5
  144. package/lib/hooks/core/useLayout.ts +215 -195
  145. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  146. package/lib/hooks/core/useStableColumns.ts +34 -0
  147. package/lib/hooks/features/useAggregation.ts +82 -51
  148. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  149. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  150. package/lib/hooks/features/useGridClipboard.ts +133 -73
  151. package/lib/hooks/features/useGridDataSource.ts +354 -128
  152. package/lib/hooks/features/useGridEditing.ts +211 -51
  153. package/lib/hooks/features/useGridPivot.ts +92 -0
  154. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  155. package/lib/hooks/features/useGridSpanning.ts +410 -306
  156. package/lib/hooks/features/usePivot.ts +0 -0
  157. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  158. package/lib/hooks/useColumnReorder.ts +38 -44
  159. package/lib/hooks/useRowGrouping.ts +298 -160
  160. package/lib/hooks/useRowReorder.ts +62 -31
  161. package/lib/hooks/useTreeData.ts +287 -220
  162. package/lib/index.ts +28 -2
  163. package/lib/state/index.ts +1 -0
  164. package/lib/state/types.ts +7 -1
  165. package/lib/state/useGridStateStorage.ts +88 -29
  166. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  167. package/lib/theme/gridThemeContext.ts +5 -0
  168. package/lib/theme/index.ts +5 -0
  169. package/lib/theme/presets.ts +5 -4
  170. package/lib/theme/types.ts +40 -2
  171. package/lib/types/index.ts +313 -116
  172. package/lib/utils/aggregation/index.ts +119 -24
  173. package/lib/utils/aria/index.ts +40 -0
  174. package/lib/utils/columnGroups.ts +110 -0
  175. package/lib/utils/columnLookup.ts +33 -0
  176. package/lib/utils/columnWidth.ts +22 -0
  177. package/lib/utils/dataSource/index.ts +46 -0
  178. package/lib/utils/detailPanel.ts +66 -0
  179. package/lib/utils/dragData.ts +44 -0
  180. package/lib/utils/editing/index.ts +30 -0
  181. package/lib/utils/export/exportShared.ts +197 -0
  182. package/lib/utils/export/exportToExcelAdvanced.ts +278 -139
  183. package/lib/utils/export/exportToPdf.ts +143 -92
  184. package/lib/utils/export/groupLabel.ts +14 -0
  185. package/lib/utils/export/index.ts +278 -272
  186. package/lib/utils/export/pdfText.ts +50 -0
  187. package/lib/utils/filtering/index.ts +277 -148
  188. package/lib/utils/focus/index.ts +115 -0
  189. package/lib/utils/gridApiRows.ts +35 -0
  190. package/lib/utils/gridModes.ts +77 -0
  191. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  192. package/lib/utils/pagination/index.ts +56 -0
  193. package/lib/utils/pinning/index.ts +77 -33
  194. package/lib/utils/pivot/index.ts +368 -0
  195. package/lib/utils/scroll/index.ts +55 -5
  196. package/lib/utils/scroll/scrollEnd.ts +18 -0
  197. package/lib/utils/sorting/index.ts +154 -30
  198. package/lib/utils/syntheticRows.ts +12 -0
  199. package/lib/utils/values.ts +206 -0
  200. package/lib/utils/viewport.ts +14 -0
  201. package/llms.txt +261 -164
  202. package/package.json +13 -5
  203. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  204. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  205. package/lib/components/DataGrid/DataGrid.defects.test.tsx +0 -103
  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/DataGrid/DataGrid.virtualization.browser.test.tsx +0 -183
  210. package/lib/components/Header/Header.test.tsx +0 -135
  211. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  212. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  213. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  214. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  215. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  216. package/lib/hooks/features/useAggregation.test.ts +0 -175
  217. package/lib/hooks/features/usePivot.test.ts +0 -202
  218. package/lib/hooks/useRowGrouping.test.ts +0 -298
  219. package/lib/hooks/useTreeData.test.ts +0 -83
  220. package/lib/utils/aggregation/aggregation.test.ts +0 -16
  221. package/lib/utils/export/exportToExcelAdvanced.test.ts +0 -109
  222. package/lib/utils/export/exportToPdf.test.ts +0 -120
  223. package/lib/utils/export/groupedExport.test.ts +0 -144
  224. package/lib/utils/filtering/filtering.test.ts +0 -178
  225. package/lib/utils/sorting/sorting.test.ts +0 -88
@@ -31,12 +31,15 @@
31
31
  ─────────────────────────────────────────────────────────────────────────── */
32
32
  .ogx-global-search--expanded {
33
33
  width: 220px;
34
- border-color: var(--ogx-toolbar-input-focus-border, var(--ogx-color-primary, #3b82f6));
34
+ border-color: var(--ogx-toolbar-input-border, var(--ogx-color-gray-300, #cbd5e1));
35
35
  background: var(--ogx-toolbar-input-bg, var(--ogx-grid-background, #fff));
36
- box-shadow: var(--ogx-toolbar-input-focus-shadow, 0 0 0 3px rgba(59, 130, 246, 0.12));
37
36
  cursor: default;
38
37
  overflow: hidden;
39
38
  }
39
+ .ogx-global-search--expanded:focus-within {
40
+ border-color: var(--ogx-toolbar-input-focus-border, var(--ogx-color-primary, #3b82f6));
41
+ box-shadow: var(--ogx-toolbar-input-focus-shadow, 0 0 0 3px rgba(59, 130, 246, 0.12));
42
+ }
40
43
 
41
44
  /* ─── Icon wrapper ────────────────────────────────────────────────────────── */
42
45
  .ogx-global-search__icon-wrapper {
@@ -70,7 +73,7 @@
70
73
  background: transparent;
71
74
  font-family: var(--ogx-font-family, system-ui, sans-serif);
72
75
  font-size: var(--ogx-font-size-sm, 13px);
73
- color: var(--ogx-grid-row-text, #1e293b);
76
+ color: var(--ogx-toolbar-input-text, var(--ogx-grid-row-text, #1e293b));
74
77
  outline: none;
75
78
  opacity: 0;
76
79
  pointer-events: none;
@@ -26,6 +26,11 @@ function CloseIcon() {
26
26
  );
27
27
  }
28
28
 
29
+ /** Search text reduced to its terms, so 'john london ' and 'john london' are the same search. */
30
+ function normalizeSearch(text: string): string {
31
+ return text.trim().split(/\s+/).filter(Boolean).join(' ');
32
+ }
33
+
29
34
  export function GlobalSearch({ value = '', onChange, placeholder = 'Search...', debounceMs = 250 }: GlobalSearchProps) {
30
35
  const [isExpanded, setIsExpanded] = useState(() => value.length > 0);
31
36
  // Fully uncontrolled: localValue is the single source of truth.
@@ -39,37 +44,22 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
39
44
  const lastEmittedRef = useRef(value);
40
45
  // Track the last incoming prop value to detect external resets
41
46
  const lastIncomingValueRef = useRef(value);
42
- // Track whether input was focused BEFORE a re-render so we can restore it
43
- const wasFocusedRef = useRef(false);
44
-
45
- // ── Focus preservation across re-renders ─────────────────────────────────
46
- // When the parent updates filterModel (triggered by our debounce), React
47
- // re-renders the entire tree. Even though the input element stays mounted,
48
- // the browser can lose focus during heavy reconciliation. We detect this
49
- // via useLayoutEffect (runs synchronously after DOM mutations, before paint)
50
- // and immediately restore focus without any visual flicker.
51
- useLayoutEffect(() => {
52
- if (wasFocusedRef.current && document.activeElement !== inputRef.current) {
53
- inputRef.current?.focus();
54
- }
55
- });
47
+
48
+ // The input stays mounted across re-renders, so React keeps its focus; nothing here moves
49
+ // focus except the explicit expand and clear actions.
56
50
 
57
51
  // ── External reset detection ──────────────────────────────────────────────
58
52
  // Only responds when the PARENT programmatically changes the value
59
- // (e.g. "Clear all filters" button) — not from our own typing.
53
+ // (e.g. "Clear all filters" button) — not from our own typing. The parent may hand back our
54
+ // own search with whitespace normalised (the toolbar splits it into terms), which is not a reset.
60
55
  useEffect(() => {
61
56
  if (value === lastIncomingValueRef.current) return;
62
57
  lastIncomingValueRef.current = value;
63
58
 
64
- if (value !== lastEmittedRef.current) {
59
+ if (normalizeSearch(value) !== normalizeSearch(lastEmittedRef.current)) {
65
60
  setLocalValue(value);
66
61
  lastEmittedRef.current = value;
67
- if (!value) {
68
- wasFocusedRef.current = false;
69
- setIsExpanded(false);
70
- } else {
71
- setIsExpanded(true);
72
- }
62
+ setIsExpanded(value.length > 0);
73
63
  }
74
64
  }, [value]);
75
65
 
@@ -84,6 +74,18 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
84
74
  return () => clearTimeout(handler);
85
75
  }, [localValue, debounceMs]);
86
76
 
77
+ // Typing still waiting for the debounce is sent when the search unmounts (the toolbar is
78
+ // hidden, the grid switches view), instead of being dropped.
79
+ const localValueRef = useRef(localValue);
80
+ useLayoutEffect(() => { localValueRef.current = localValue; });
81
+ useEffect(() => () => {
82
+ const pending = localValueRef.current;
83
+ if (pending !== lastEmittedRef.current) {
84
+ lastEmittedRef.current = pending;
85
+ onChangeRef.current(pending);
86
+ }
87
+ }, []);
88
+
87
89
  const handleExpand = useCallback(() => {
88
90
  setIsExpanded(true);
89
91
  requestAnimationFrame(() => inputRef.current?.focus());
@@ -98,22 +100,19 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
98
100
  }, []);
99
101
 
100
102
  const handleFocus = useCallback(() => {
101
- wasFocusedRef.current = true;
102
103
  setIsExpanded(true);
103
104
  }, []);
104
105
 
105
106
  // Blur: defer 150ms to allow focus to move within the container first
106
107
  // (e.g. clicking the clear button) before deciding to collapse.
107
108
  const handleBlur = useCallback(() => {
108
- wasFocusedRef.current = false;
109
109
  setTimeout(() => {
110
110
  if (
111
111
  containerRef.current &&
112
112
  document.activeElement &&
113
113
  containerRef.current.contains(document.activeElement)
114
114
  ) {
115
- // Focus is still inside — mark as focused again and abort
116
- wasFocusedRef.current = true;
115
+ // Focus is still inside the search box — keep it expanded
117
116
  return;
118
117
  }
119
118
  if (!localValue) {
@@ -156,7 +155,7 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
156
155
  autoComplete="off"
157
156
  />
158
157
  {localValue && (
159
- <button className="ogx-global-search__clear" onClick={handleClear} aria-label="Clear search">
158
+ <button type="button" className="ogx-global-search__clear" onClick={handleClear} aria-label="Clear search">
160
159
  <CloseIcon />
161
160
  </button>
162
161
  )}
@@ -3,8 +3,9 @@
3
3
  align-items: center;
4
4
  gap: var(--ogx-spacing-sm);
5
5
  padding: var(--ogx-spacing-sm) var(--ogx-spacing-sm);
6
- border-bottom: var(--ogx-border-width-thin) solid var(--ogx-grid-border-color);
7
- background: var(--ogx-grid-header-background);
6
+ border-bottom: var(--ogx-border-width-thin) solid var(--ogx-toolbar-border, var(--ogx-grid-border-color));
7
+ background: var(--ogx-toolbar-background, var(--ogx-grid-header-background));
8
+ color: var(--ogx-toolbar-text, var(--ogx-grid-header-text));
8
9
  min-height: 44px;
9
10
  position: relative;
10
11
  z-index: 100;
@@ -37,17 +38,17 @@
37
38
  padding: 0;
38
39
  border: none;
39
40
  border-radius: 8px;
40
- background: transparent;
41
- color: var(--ogx-grid-header-text);
41
+ background: var(--ogx-toolbar-btn-bg, transparent);
42
+ color: var(--ogx-toolbar-btn-text, var(--ogx-grid-header-text));
42
43
  cursor: pointer;
43
44
  transition: background 0.15s, color 0.15s;
44
45
  position: relative;
45
46
  }
46
47
  .ogx-toolbar__icon-btn:hover {
47
- background: var(--ogx-grid-header-hover-background);
48
+ background: var(--ogx-toolbar-btn-hover, var(--ogx-grid-header-hover-background));
48
49
  }
49
50
  .ogx-toolbar__icon-btn--active {
50
- background: var(--ogx-grid-header-hover-background);
51
+ background: var(--ogx-toolbar-btn-hover, var(--ogx-grid-header-hover-background));
51
52
  outline: none;
52
53
  }
53
54
  .ogx-toolbar__icon-btn:focus {
@@ -106,7 +107,7 @@
106
107
  border: var(--ogx-border-width-thin) solid var(--ogx-border-color);
107
108
  border-radius: var(--ogx-border-radius-sm);
108
109
  background: transparent;
109
- color: var(--ogx-color-error, #ef4444);
110
+ color: var(--ogx-toolbar-btn-danger-text, var(--ogx-color-error, #ef4444));
110
111
  font-family: var(--ogx-font-family);
111
112
  font-size: var(--ogx-font-size-xs);
112
113
  font-weight: 500;
@@ -114,8 +115,8 @@
114
115
  transition: background 0.15s, border-color 0.15s;
115
116
  }
116
117
  .ogx-toolbar__clear-btn:hover {
117
- background: var(--ogx-toolbar-btn-danger-bg, rgba(239, 68, 68, 0.08));
118
- border-color: var(--ogx-color-error, #ef4444);
118
+ background: var(--ogx-toolbar-btn-danger-hover, rgba(239, 68, 68, 0.15));
119
+ border-color: var(--ogx-toolbar-btn-danger-text, var(--ogx-color-error, #ef4444));
119
120
  }
120
121
  .ogx-toolbar__close-btn {
121
122
  padding: 2px 10px;
@@ -189,8 +190,8 @@
189
190
  padding: 3px 6px;
190
191
  border: var(--ogx-border-width-thin) solid var(--ogx-overlay-border, var(--ogx-border-color));
191
192
  border-radius: 999px;
192
- background: transparent;
193
- color: var(--ogx-grid-row-text);
193
+ background: var(--ogx-toolbar-chip-bg, transparent);
194
+ color: var(--ogx-toolbar-chip-text, var(--ogx-grid-row-text));
194
195
  font-family: var(--ogx-font-family-mono, monospace);
195
196
  font-size: 10px;
196
197
  font-weight: 500;
@@ -1,6 +1,10 @@
1
- import React, { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
1
+ import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useContext } from 'react';
2
2
  import ReactDOM from 'react-dom';
3
3
  import type { GridColDef, GridAggregationModel, GridPivotModel, GridFilterModel } from '../../types';
4
+ import { AGGREGATION_FUNCTIONS } from '../../utils/aggregation';
5
+ import type { BuiltInAggFn } from '../../utils/aggregation';
6
+ import { getViewportWidth } from '../../utils/viewport';
7
+ import { GridToolbarHostContext } from '../../hooks/core/gridToolbarHostContext';
4
8
  import { PivotPanel, PivotIcon } from './PivotPanel';
5
9
  import { GlobalSearch } from './GlobalSearch';
6
10
  import { ColumnVisibilityPanel } from '../ColumnVisibilityPanel/ColumnVisibilityPanel';
@@ -78,7 +82,23 @@ export interface GridToolbarProps {
78
82
  showNonHideableColumns?: boolean;
79
83
  }
80
84
 
81
- const AGGREGATION_FUNCTIONS = ['none', 'sum', 'avg', 'count', 'min', 'max'] as const;
85
+ const BUILT_IN_AGGREGATION_FUNCTIONS = Object.keys(AGGREGATION_FUNCTIONS) as BuiltInAggFn[];
86
+
87
+ function isBuiltInAggregationFunction(fn: string): fn is BuiltInAggFn {
88
+ return Object.prototype.hasOwnProperty.call(AGGREGATION_FUNCTIONS, fn);
89
+ }
90
+
91
+ /**
92
+ * The summary functions offered for a column: its `availableAggregationFunctions` when set,
93
+ * otherwise every built-in one. Names the aggregation engine does not implement are dropped,
94
+ * because choosing one would show the label in the header but never compute a value.
95
+ */
96
+ function getAggregationFunctions(col: GridColDef): BuiltInAggFn[] {
97
+ if (!col.availableAggregationFunctions) return BUILT_IN_AGGREGATION_FUNCTIONS;
98
+ return Array.from(new Set(col.availableAggregationFunctions.filter(isBuiltInAggregationFunction)));
99
+ }
100
+
101
+ type ToolbarPanel = 'summaries' | 'pivot' | 'columns' | 'filters';
82
102
 
83
103
  function SigmaIcon() {
84
104
  return (
@@ -135,7 +155,7 @@ function AggregationPanel({
135
155
  }) {
136
156
  const panelRef = useRef<HTMLDivElement>(null);
137
157
  const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
138
- const [portalContainer, setPortalContainer] = useState<Element>(document.body);
158
+ const [portalContainer, setPortalContainer] = useState<Element | null>(null);
139
159
 
140
160
  useLayoutEffect(() => {
141
161
  setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
@@ -146,7 +166,7 @@ function AggregationPanel({
146
166
  const rect = anchorRef.current.getBoundingClientRect();
147
167
  return {
148
168
  top: rect.bottom + 6,
149
- right: window.innerWidth - rect.right,
169
+ right: getViewportWidth() - rect.right,
150
170
  };
151
171
  }, [anchorRef]);
152
172
 
@@ -195,6 +215,7 @@ function AggregationPanel({
195
215
  </span>
196
216
  {activeCount > 0 && (
197
217
  <button
218
+ type="button"
198
219
  className="ogx-toolbar__clear-btn"
199
220
  onClick={onClearAll}
200
221
  title="Clear all summaries"
@@ -219,8 +240,9 @@ function AggregationPanel({
219
240
  {col.headerName || col.field}
220
241
  </span>
221
242
  <div className="ogx-toolbar__agg-pills">
222
- {AGGREGATION_FUNCTIONS.map((fn) => (
243
+ {['none', ...getAggregationFunctions(col)].map((fn) => (
223
244
  <button
245
+ type="button"
224
246
  key={fn}
225
247
  className={`ogx-toolbar__pill${currentFn === fn ? ' ogx-toolbar__pill--active' : ''}`}
226
248
  onClick={() => onFunctionChange(col.field, fn)}
@@ -238,7 +260,7 @@ function AggregationPanel({
238
260
  </div>
239
261
  );
240
262
 
241
- return ReactDOM.createPortal(panel, portalContainer);
263
+ return portalContainer ? ReactDOM.createPortal(panel, portalContainer) : null;
242
264
  }
243
265
 
244
266
  function ColumnsPanelWrapper({
@@ -266,7 +288,7 @@ function ColumnsPanelWrapper({
266
288
  }) {
267
289
  const panelRef = useRef<HTMLDivElement>(null);
268
290
  const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
269
- const [portalContainer, setPortalContainer] = useState<Element>(document.body);
291
+ const [portalContainer, setPortalContainer] = useState<Element | null>(null);
270
292
 
271
293
  useLayoutEffect(() => {
272
294
  setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
@@ -277,7 +299,7 @@ function ColumnsPanelWrapper({
277
299
  const rect = anchorRef.current.getBoundingClientRect();
278
300
  return {
279
301
  top: rect.bottom + 6,
280
- right: window.innerWidth - rect.right,
302
+ right: getViewportWidth() - rect.right,
281
303
  };
282
304
  }, [anchorRef]);
283
305
 
@@ -305,6 +327,12 @@ function ColumnsPanelWrapper({
305
327
  return () => document.removeEventListener('mousedown', handleClick);
306
328
  }, [anchorRef, onClose]);
307
329
 
330
+ useEffect(() => {
331
+ function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); }
332
+ document.addEventListener('keydown', onKey);
333
+ return () => document.removeEventListener('keydown', onKey);
334
+ }, [onClose]);
335
+
308
336
  const panel = (
309
337
  <div
310
338
  ref={panelRef}
@@ -326,7 +354,7 @@ function ColumnsPanelWrapper({
326
354
  </div>
327
355
  );
328
356
 
329
- return ReactDOM.createPortal(panel, portalContainer);
357
+ return portalContainer ? ReactDOM.createPortal(panel, portalContainer) : null;
330
358
  }
331
359
 
332
360
  function FilterPanelWrapper({
@@ -349,7 +377,7 @@ function FilterPanelWrapper({
349
377
  // which could fire the close callback during the re-register window.
350
378
  const onCloseRef = useRef(onClose);
351
379
  useLayoutEffect(() => { onCloseRef.current = onClose; }, [onClose]);
352
- const [portalContainer, setPortalContainer] = useState<Element>(document.body);
380
+ const [portalContainer, setPortalContainer] = useState<Element | null>(null);
353
381
 
354
382
  useLayoutEffect(() => {
355
383
  setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
@@ -360,7 +388,7 @@ function FilterPanelWrapper({
360
388
  const rect = anchorRef.current.getBoundingClientRect();
361
389
  return {
362
390
  top: rect.bottom + 6,
363
- right: window.innerWidth - rect.right,
391
+ right: getViewportWidth() - rect.right,
364
392
  };
365
393
  }, [anchorRef]);
366
394
 
@@ -412,6 +440,7 @@ function FilterPanelWrapper({
412
440
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
413
441
  {activeFilterCount > 0 && (
414
442
  <button
443
+ type="button"
415
444
  className="ogx-toolbar__clear-btn"
416
445
  onClick={handleClearAll}
417
446
  title="Clear all filters"
@@ -420,6 +449,7 @@ function FilterPanelWrapper({
420
449
  </button>
421
450
  )}
422
451
  <button
452
+ type="button"
423
453
  className="ogx-toolbar__close-btn"
424
454
  onClick={() => onCloseRef.current()}
425
455
  title="Close filters"
@@ -440,7 +470,7 @@ function FilterPanelWrapper({
440
470
  </div>
441
471
  );
442
472
 
443
- return ReactDOM.createPortal(panel, portalContainer);
473
+ return portalContainer ? ReactDOM.createPortal(panel, portalContainer) : null;
444
474
  }
445
475
 
446
476
  const EMPTY_PIVOT: GridPivotModel = { rowFields: [], columnFields: [], valueFields: [] };
@@ -471,20 +501,35 @@ export function GridToolbar({
471
501
  className,
472
502
  showNonHideableColumns,
473
503
  }: GridToolbarProps) {
474
- const [aggOpen, setAggOpen] = useState(false);
475
- const [pivotOpen, setPivotOpen] = useState(false);
476
- const [colsOpen, setColsOpen] = useState(false);
477
- const [filterOpen, setFilterOpen] = useState(false);
504
+ // At most one panel is open. Every way the Columns panel closes (its button, a custom
505
+ // button, another panel opening, click-outside, Escape) reports onColumnsPanelClose, so the
506
+ // grid can ask for it again with forceColumnsOpen.
507
+ const [openPanel, setOpenPanel] = useState<ToolbarPanel | null>(null);
508
+ const aggOpen = openPanel === 'summaries';
509
+ const pivotOpen = openPanel === 'pivot';
510
+ const colsOpen = openPanel === 'columns';
511
+ const filterOpen = openPanel === 'filters';
512
+
513
+ const switchPanel = (next: ToolbarPanel | null) => {
514
+ if (openPanel === 'columns' && next !== 'columns') onColumnsPanelClose?.();
515
+ setOpenPanel(next);
516
+ };
517
+ const togglePanel = (panel: ToolbarPanel) => switchPanel(openPanel === panel ? null : panel);
518
+ const closePanel = (panel: ToolbarPanel) => { if (openPanel === panel) switchPanel(null); };
478
519
 
479
520
  useEffect(() => {
480
- if (forceColumnsOpen) {
481
- setColsOpen(true);
482
- setAggOpen(false);
483
- setPivotOpen(false);
484
- setFilterOpen(false);
485
- }
521
+ if (forceColumnsOpen) setOpenPanel('columns');
486
522
  }, [forceColumnsOpen]);
487
523
 
524
+ // Tell the grid this toolbar shows the Columns panel for the column menu; it can only do so
525
+ // when the grid's forceColumnsOpen reaches it and the Columns panel is enabled.
526
+ const toolbarHost = useContext(GridToolbarHostContext);
527
+ const hostsColumnsPanel = forceColumnsOpen !== undefined && Boolean(onColumnVisibilityModelChange);
528
+ useEffect(() => {
529
+ if (!toolbarHost || !hostsColumnsPanel) return undefined;
530
+ return toolbarHost.registerColumnsPanel();
531
+ }, [toolbarHost, hostsColumnsPanel]);
532
+
488
533
  const aggButtonRef = useRef<HTMLButtonElement>(null);
489
534
  const pivotButtonRef = useRef<HTMLButtonElement>(null);
490
535
  const colsButtonRef = useRef<HTMLButtonElement>(null);
@@ -501,8 +546,13 @@ export function GridToolbar({
501
546
  || currentPivotModel.columnFields.length > 0
502
547
  || currentPivotModel.valueFields.length > 0;
503
548
 
549
+ // aggregationModel keys name source columns, so generated pivot columns (absent from baseColumns)
550
+ // are never offered: a summary on them would do nothing and leave a stray key in the model.
551
+ const baseFields = baseColumns ? new Set(baseColumns.map((c) => c.field)) : null;
504
552
  const aggregableColumns = columns.filter(
505
553
  (c) => c.aggregable !== false && (c.type === 'number' || c.aggregable === true)
554
+ && getAggregationFunctions(c).length > 0
555
+ && (!baseFields || baseFields.has(c.field))
506
556
  );
507
557
 
508
558
  const activeCount = Object.keys(aggregationModel).filter(
@@ -530,7 +580,9 @@ export function GridToolbar({
530
580
  const handleSearchChange = useCallback((value: string) => {
531
581
  if (!onFilterModelChange) return;
532
582
 
533
- const values = value ? [value] : [];
583
+ // Each whitespace-separated word is its own term; a row matches when every term is found
584
+ // in some column, so "john london" finds first = John, city = London.
585
+ const values = value.trim().split(/\s+/).filter(Boolean);
534
586
  const nextModel = {
535
587
  ...(filterModel || {}),
536
588
  quickFilterValues: values,
@@ -539,7 +591,7 @@ export function GridToolbar({
539
591
  onFilterModelChange(nextModel);
540
592
  }, [filterModel, onFilterModelChange]);
541
593
 
542
- const searchValue = filterModel?.quickFilterValues?.[0] || '';
594
+ const searchValue = (filterModel?.quickFilterValues ?? []).join(' ');
543
595
  const activeFilterCount = (filterModel?.items?.length || 0);
544
596
 
545
597
  // Column Visibility Logic
@@ -604,22 +656,20 @@ export function GridToolbar({
604
656
  <div ref={colsWrapperRef} className="ogx-toolbar__dropdown-wrapper" style={{ marginRight: 4 }}>
605
657
  {renderColumnsButton
606
658
  ? renderColumnsButton({
607
- onClick: () => { setColsOpen(o => !o); setAggOpen(false); setPivotOpen(false); setFilterOpen(false); },
659
+ onClick: () => togglePanel('columns'),
608
660
  isOpen: colsOpen,
609
661
  activeCount: columns.length - visibleColumns.size,
610
662
  })
611
663
  : (
612
664
  <GridTooltip title="Columns">
613
665
  <button
666
+ type="button"
614
667
  ref={colsButtonRef}
615
668
  className={`ogx-toolbar__icon-btn${colsOpen ? ' ogx-toolbar__icon-btn--active' : ''}`}
616
669
  aria-label="Manage columns"
617
- onClick={() => {
618
- setColsOpen(!colsOpen);
619
- setAggOpen(false);
620
- setPivotOpen(false);
621
- setFilterOpen(false);
622
- }}
670
+ aria-haspopup="dialog"
671
+ aria-expanded={colsOpen}
672
+ onClick={() => togglePanel('columns')}
623
673
  >
624
674
  <ViewColumnIcon />
625
675
  </button>
@@ -636,7 +686,7 @@ export function GridToolbar({
636
686
  onHideAll={handleHideAllColumns}
637
687
  onColumnReorder={onColumnReorder}
638
688
  onColumnOrderReset={onColumnOrderReset}
639
- onClose={() => { setColsOpen(false); onColumnsPanelClose?.(); }}
689
+ onClose={() => closePanel('columns')}
640
690
  showNonHideableColumns={showNonHideableColumns}
641
691
  />
642
692
  )}
@@ -648,22 +698,20 @@ export function GridToolbar({
648
698
  <div ref={filterWrapperRef} className="ogx-toolbar__dropdown-wrapper" style={{ marginRight: 4 }}>
649
699
  {renderFilterButton
650
700
  ? renderFilterButton({
651
- onClick: () => { setFilterOpen(o => !o); setAggOpen(false); setPivotOpen(false); setColsOpen(false); },
701
+ onClick: () => togglePanel('filters'),
652
702
  isOpen: filterOpen,
653
703
  activeCount: activeFilterCount,
654
704
  })
655
705
  : (
656
706
  <GridTooltip title="Filters">
657
707
  <button
708
+ type="button"
658
709
  ref={filterButtonRef}
659
710
  className={`ogx-toolbar__icon-btn${filterOpen ? ' ogx-toolbar__icon-btn--active' : ''}`}
660
711
  aria-label="Advanced filters"
661
- onClick={() => {
662
- setFilterOpen(!filterOpen);
663
- setAggOpen(false);
664
- setPivotOpen(false);
665
- setColsOpen(false);
666
- }}
712
+ aria-haspopup="dialog"
713
+ aria-expanded={filterOpen}
714
+ onClick={() => togglePanel('filters')}
667
715
  >
668
716
  <FilterIcon />
669
717
  {activeFilterCount > 0 && (
@@ -679,7 +727,7 @@ export function GridToolbar({
679
727
  columns={columns}
680
728
  filterModel={filterModel || { items: [] }}
681
729
  onFilterModelChange={onFilterModelChange}
682
- onClose={() => setFilterOpen(false)}
730
+ onClose={() => closePanel('filters')}
683
731
  />
684
732
  )}
685
733
  </div>
@@ -690,16 +738,13 @@ export function GridToolbar({
690
738
  <div className="ogx-toolbar__dropdown-wrapper" style={{ marginRight: 4 }}>
691
739
  <GridTooltip title="Pivot">
692
740
  <button
741
+ type="button"
693
742
  ref={pivotButtonRef}
694
743
  className={`ogx-toolbar__icon-btn${pivotOpen ? ' ogx-toolbar__icon-btn--active' : ''}`}
695
744
  aria-label="Configure pivot"
745
+ aria-haspopup="dialog"
696
746
  aria-expanded={pivotOpen}
697
- onClick={() => {
698
- setPivotOpen(!pivotOpen);
699
- setAggOpen(false);
700
- setColsOpen(false);
701
- setFilterOpen(false);
702
- }}
747
+ onClick={() => togglePanel('pivot')}
703
748
  >
704
749
  <PivotIcon />
705
750
  {pivotActive && (
@@ -714,55 +759,54 @@ export function GridToolbar({
714
759
  columns={baseColumns ?? columns}
715
760
  model={currentPivotModel}
716
761
  onChange={(m) => { onPivotModelChange?.(m); }}
717
- onClose={() => setPivotOpen(false)}
762
+ onClose={() => closePanel('pivot')}
718
763
  />
719
764
  )}
720
765
  </div>
721
766
  )}
722
767
 
723
768
  {/* Summaries button */}
724
- <div ref={aggWrapperRef} className="ogx-toolbar__dropdown-wrapper" style={{ marginRight: 4 }}>
725
- {renderAggregationButton
726
- ? renderAggregationButton({
727
- onClick: () => { setAggOpen(o => !o); setPivotOpen(false); setColsOpen(false); setFilterOpen(false); },
728
- isOpen: aggOpen,
729
- activeCount,
730
- })
731
- : (
732
- <GridTooltip title="Summaries">
733
- <button
734
- ref={aggButtonRef}
735
- className={`ogx-toolbar__icon-btn${aggOpen ? ' ogx-toolbar__icon-btn--active' : ''}`}
736
- aria-label="Configure summaries"
737
- aria-expanded={aggOpen}
738
- onClick={() => {
739
- setAggOpen(!aggOpen);
740
- setPivotOpen(false);
741
- setColsOpen(false);
742
- setFilterOpen(false);
743
- }}
744
- >
745
- <SigmaIcon />
746
- {activeCount > 0 && (
747
- <span className="ogx-toolbar__dot" aria-label={`${activeCount} active summaries`} />
748
- )}
749
- </button>
750
- </GridTooltip>
751
- )
752
- }
753
-
754
- {aggOpen && (
755
- <AggregationPanel
756
- anchorRef={renderAggregationButton ? aggWrapperRef : aggButtonRef}
757
- aggregationModel={aggregationModel}
758
- aggregableColumns={aggregableColumns}
759
- onFunctionChange={handleFunctionChange}
760
- onClearAll={clearAllAgg}
761
- onClose={() => setAggOpen(false)}
762
- activeCount={activeCount}
763
- />
764
- )}
765
- </div>
769
+ {onAggregationModelChange && (
770
+ <div ref={aggWrapperRef} className="ogx-toolbar__dropdown-wrapper" style={{ marginRight: 4 }}>
771
+ {renderAggregationButton
772
+ ? renderAggregationButton({
773
+ onClick: () => togglePanel('summaries'),
774
+ isOpen: aggOpen,
775
+ activeCount,
776
+ })
777
+ : (
778
+ <GridTooltip title="Summaries">
779
+ <button
780
+ type="button"
781
+ ref={aggButtonRef}
782
+ className={`ogx-toolbar__icon-btn${aggOpen ? ' ogx-toolbar__icon-btn--active' : ''}`}
783
+ aria-label="Configure summaries"
784
+ aria-haspopup="dialog"
785
+ aria-expanded={aggOpen}
786
+ onClick={() => togglePanel('summaries')}
787
+ >
788
+ <SigmaIcon />
789
+ {activeCount > 0 && (
790
+ <span className="ogx-toolbar__dot" aria-label={`${activeCount} active summaries`} />
791
+ )}
792
+ </button>
793
+ </GridTooltip>
794
+ )
795
+ }
796
+
797
+ {aggOpen && (
798
+ <AggregationPanel
799
+ anchorRef={renderAggregationButton ? aggWrapperRef : aggButtonRef}
800
+ aggregationModel={aggregationModel}
801
+ aggregableColumns={aggregableColumns}
802
+ onFunctionChange={handleFunctionChange}
803
+ onClearAll={clearAllAgg}
804
+ onClose={() => closePanel('summaries')}
805
+ activeCount={activeCount}
806
+ />
807
+ )}
808
+ </div>
809
+ )}
766
810
 
767
811
  {/* Export button slot */}
768
812
  {renderExportButton && renderExportButton()}