@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
@@ -2,158 +2,296 @@ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef,
2
2
  import type {
3
3
  GridFilterModel,
4
4
  GridFilterItem,
5
+ GridFilterGroup,
5
6
  GridColDef,
6
7
  GridFilterOperator
7
8
  } from '../../types';
8
- import { getOperatorsForType } from '../../utils/filtering';
9
+ import { getOperatorsForType, NO_VALUE_OPERATORS } from '../../utils/filtering';
10
+ import { toLocalDateString } from '../../utils/values';
9
11
 
10
12
 
11
- interface FilterPanelProps {
13
+ export interface FilterPanelProps {
12
14
  filterModel: GridFilterModel;
13
15
  columns: GridColDef[];
14
16
  onFilterModelChange: (model: GridFilterModel) => void;
15
17
  }
16
18
 
17
- const NO_VALUE_OPERATORS = new Set(['isEmpty', 'isNotEmpty']);
19
+ type FilterEntry = GridFilterItem | GridFilterGroup;
18
20
 
19
- function getActiveFilter(items: GridFilterItem[], field: string): GridFilterItem | undefined {
20
- return items.find(item => 'field' in item && item.field === field) as GridFilterItem | undefined;
21
+ const DEBOUNCE_MS = 300;
22
+
23
+ function isFilterItem(entry: FilterEntry): entry is GridFilterItem {
24
+ return 'field' in entry && !('items' in entry);
25
+ }
26
+
27
+ /** The root-level item the panel shows and edits for a column: the first one for that field. */
28
+ function getActiveFilter(items: readonly FilterEntry[], field: string): GridFilterItem | undefined {
29
+ return items.find((entry): entry is GridFilterItem => isFilterItem(entry) && entry.field === field);
30
+ }
31
+
32
+ function sameValue(a: unknown, b: unknown): boolean {
33
+ if (Object.is(a, b)) return true;
34
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
35
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => sameValue(v, b[i]));
36
+ return false;
37
+ }
38
+
39
+ function sameEntry(a: FilterEntry, b: FilterEntry): boolean {
40
+ if (a === b) return true;
41
+ if (isFilterItem(a) && isFilterItem(b)) {
42
+ return a.id === b.id && a.field === b.field && a.operator === b.operator && sameValue(a.value, b.value);
43
+ }
44
+ if (!isFilterItem(a) && !isFilterItem(b)) {
45
+ return a.id === b.id && a.logicOperator === b.logicOperator && sameEntries(a.items, b.items);
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function sameEntries(a: readonly FilterEntry[], b: readonly FilterEntry[]): boolean {
51
+ if (a === b) return true;
52
+ return a.length === b.length && a.every((entry, i) => sameEntry(entry, b[i]));
53
+ }
54
+
55
+ interface ValueOption {
56
+ key: string;
57
+ value: unknown;
58
+ label: string;
59
+ }
60
+
61
+ function getValueOptions(col: GridColDef): ValueOption[] {
62
+ return (col.valueOptions ?? []).map((option, index) => (
63
+ option !== null && typeof option === 'object'
64
+ ? { key: String(index), value: option.value, label: option.label }
65
+ : { key: String(index), value: option, label: String(option) }
66
+ ));
67
+ }
68
+
69
+ function matchesOption(option: ValueOption, value: unknown): boolean {
70
+ if (Object.is(option.value, value)) return true;
71
+ return value != null && String(option.value).toLowerCase() === String(value).toLowerCase();
72
+ }
73
+
74
+ /** Text shown in the value input for a committed value. Never used to emit anything. */
75
+ function toInputText(value: unknown, col: GridColDef): string {
76
+ if (value == null) return '';
77
+ if (col.type === 'date' || value instanceof Date) {
78
+ const day = toLocalDateString(value);
79
+ return day || (typeof value === 'string' ? value : '');
80
+ }
81
+ if (Array.isArray(value)) return value.map(v => String(v)).join(', ');
82
+ return String(value);
83
+ }
84
+
85
+ /** The item value for typed text. isAnyOf takes a comma-separated list. */
86
+ function fromInputText(text: string, operator: GridFilterOperator): unknown {
87
+ if (operator === 'isAnyOf') return text.split(',').map(part => part.trim()).filter(part => part !== '');
88
+ return text;
89
+ }
90
+
91
+ /** Carry a value over to a newly picked operator: lists for isAnyOf, scalars for the rest. */
92
+ function convertValueForOperator(value: unknown, operator: GridFilterOperator): unknown {
93
+ if (NO_VALUE_OPERATORS.has(operator)) return undefined;
94
+ if (operator === 'isAnyOf') {
95
+ if (value == null || value === '') return [];
96
+ return Array.isArray(value) ? value : [value];
97
+ }
98
+ if (Array.isArray(value)) return value.length > 0 ? value[0] : '';
99
+ return value ?? '';
21
100
  }
22
101
 
23
102
  const FilterRow: React.FC<{
24
103
  col: GridColDef;
25
104
  item: GridFilterItem | undefined;
105
+ /** Changes when the filter model is changed from outside the panel. */
106
+ resetToken: number;
26
107
  onChange: (item: GridFilterItem | null) => void;
27
- }> = ({ col, item, onChange }) => {
108
+ }> = ({ col, item, resetToken, onChange }) => {
28
109
  const filterId = useId();
29
- const operators = getOperatorsForType(col.type);
110
+ const typeOperators = getOperatorsForType(col.type);
111
+ const defaultOperator = typeOperators[0];
112
+ const currentOperator: GridFilterOperator = item?.operator ?? defaultOperator;
113
+ // An operator set programmatically that the column type does not list is still shown, so the
114
+ // select reflects the filter that is really applied.
115
+ const operators = typeOperators.includes(currentOperator) ? typeOperators : [currentOperator, ...typeOperators];
30
116
  const isActive = !!item;
31
- const currentOperator = item?.operator ?? operators[0];
117
+ const label = col.headerName || col.field;
118
+
119
+ // Text typed but not committed yet (debounced). null while the input shows the committed value,
120
+ // so opening the panel never re-emits (and never stringifies) an existing value.
121
+ const [draft, setDraft] = useState<string | null>(null);
122
+
123
+ // An outside change (Clear all, programmatic reset) discards pending typing.
124
+ const [seenResetToken, setSeenResetToken] = useState(resetToken);
125
+ if (seenResetToken !== resetToken) {
126
+ setSeenResetToken(resetToken);
127
+ setDraft(null);
128
+ }
32
129
 
33
- // Local input state to prevent full filter-panel re-render on every keystroke
34
- const [localValue, setLocalValue] = useState<string>(item?.value != null ? String(item.value) : '');
35
130
  const onChangeRef = useRef(onChange);
36
131
  const itemRef = useRef(item);
37
- const colFieldRef = useRef(col.field);
38
- const operatorRef = useRef(currentOperator);
132
+ const colRef = useRef(col);
39
133
  useLayoutEffect(() => {
40
134
  onChangeRef.current = onChange;
41
135
  itemRef.current = item;
42
- colFieldRef.current = col.field;
43
- operatorRef.current = currentOperator;
136
+ colRef.current = col;
44
137
  });
45
138
 
46
- // Sync when the external item changes (e.g. operator change, clear)
47
- const prevItemValue = useRef(item?.value);
48
- useEffect(() => {
49
- if (item?.value !== prevItemValue.current) {
50
- prevItemValue.current = item?.value;
51
- setLocalValue(item?.value != null ? String(item.value) : '');
52
- }
53
- }, [item?.value]);
139
+ const buildItem = useCallback((operator: GridFilterOperator, value: unknown): GridFilterItem => ({
140
+ id: itemRef.current?.id ?? `${col.field}-${Date.now()}`,
141
+ field: col.field,
142
+ operator,
143
+ value,
144
+ }), [col.field]);
145
+
146
+ // Commits typed text unless it matches what the item already holds.
147
+ const commitDraft = useCallback((text: string) => {
148
+ const current = itemRef.current;
149
+ const unchanged = current ? text === toInputText(current.value, colRef.current) : text.trim() === '';
150
+ if (unchanged) return;
151
+ const operator = current?.operator ?? defaultOperator;
152
+ onChangeRef.current(buildItem(operator, fromInputText(text, operator)));
153
+ }, [defaultOperator, buildItem]);
154
+
155
+ // Text still waiting for the debounce; flushed when the panel closes (unmounts) meanwhile.
156
+ const pendingDraftRef = useRef<string | null>(null);
157
+ const commitDraftRef = useRef(commitDraft);
158
+ useLayoutEffect(() => {
159
+ pendingDraftRef.current = draft;
160
+ commitDraftRef.current = commitDraft;
161
+ });
162
+ useEffect(() => () => {
163
+ const pending = pendingDraftRef.current;
164
+ pendingDraftRef.current = null;
165
+ if (pending !== null) commitDraftRef.current(pending);
166
+ }, []);
54
167
 
55
- // Debounce propagating the value upward (300ms)
56
168
  useEffect(() => {
57
- const handler = setTimeout(() => {
58
- const currentItem = itemRef.current;
59
- const currentValue = currentItem?.value ?? '';
60
- if (localValue !== currentValue) {
61
- onChangeRef.current({
62
- id: currentItem?.id ?? `${colFieldRef.current}-${Date.now()}`,
63
- field: colFieldRef.current,
64
- operator: operatorRef.current as GridFilterOperator,
65
- value: localValue,
66
- });
67
- }
68
- }, 300);
69
- return () => clearTimeout(handler);
70
- }, [localValue]);
71
-
72
- const handleOperatorChange = (op: string) => {
73
- const noValue = NO_VALUE_OPERATORS.has(op);
74
- const newItem: GridFilterItem = {
75
- id: item?.id ?? `${col.field}-${Date.now()}`,
76
- field: col.field,
77
- operator: op as GridFilterOperator,
78
- value: noValue ? undefined : (item?.value ?? (col.type === 'boolean' ? 'true' : '')),
79
- };
80
- onChange(newItem);
169
+ if (draft === null) return;
170
+ const handle = setTimeout(() => {
171
+ pendingDraftRef.current = null;
172
+ commitDraft(draft);
173
+ setDraft(null);
174
+ }, DEBOUNCE_MS);
175
+ return () => clearTimeout(handle);
176
+ }, [draft, commitDraft]);
177
+
178
+ const handleOperatorChange = (op: GridFilterOperator) => {
179
+ // Keep what the user typed even if the debounce has not committed it yet.
180
+ const value = draft !== null ? fromInputText(draft, op) : item?.value;
181
+ setDraft(null);
182
+ onChange(buildItem(op, convertValueForOperator(value, op)));
81
183
  };
82
184
 
83
185
  const handleClear = () => {
84
- setLocalValue('');
186
+ setDraft(null);
85
187
  onChange(null);
86
188
  };
87
189
 
88
- const isNoValue = NO_VALUE_OPERATORS.has(String(currentOperator));
89
- const isBoolean = col.type === 'boolean';
90
- const showValue = !isNoValue;
190
+ const showValue = !NO_VALUE_OPERATORS.has(currentOperator);
191
+ const valueOptions = getValueOptions(col);
192
+ const valueClassName = `ogx-filter__value-input${isActive ? ' ogx-filter__value-input--active' : ''}`;
193
+ const valueLabel = `Filter value for ${label}`;
194
+
195
+ let valueControl: React.ReactNode = null;
196
+ if (showValue && col.type === 'boolean') {
197
+ valueControl = (
198
+ <select
199
+ id={`${filterId}-val`}
200
+ name={`filter-val-${col.field}`}
201
+ className={`${valueClassName} ogx-filter__value-select`}
202
+ value={item?.value == null ? '' : String(item.value)}
203
+ onChange={(e) => {
204
+ if (e.target.value === '') onChange(null);
205
+ else onChange(buildItem(currentOperator, e.target.value));
206
+ }}
207
+ aria-label={valueLabel}
208
+ >
209
+ <option value="">Any</option>
210
+ <option value="true">true</option>
211
+ <option value="false">false</option>
212
+ </select>
213
+ );
214
+ } else if (showValue && col.type === 'singleSelect' && valueOptions.length > 0 && currentOperator === 'isAnyOf') {
215
+ const selected = Array.isArray(item?.value) ? item.value : item?.value == null || item.value === '' ? [] : [item.value];
216
+ valueControl = (
217
+ <select
218
+ id={`${filterId}-val`}
219
+ name={`filter-val-${col.field}`}
220
+ className={`${valueClassName} ogx-filter__value-multiselect`}
221
+ multiple
222
+ size={Math.min(valueOptions.length, 4)}
223
+ value={valueOptions.filter(o => selected.some(v => matchesOption(o, v))).map(o => o.key)}
224
+ onChange={(e) => {
225
+ const keys = new Set(Array.from(e.target.selectedOptions, option => option.value));
226
+ onChange(buildItem('isAnyOf', valueOptions.filter(o => keys.has(o.key)).map(o => o.value)));
227
+ }}
228
+ aria-label={valueLabel}
229
+ >
230
+ {valueOptions.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
231
+ </select>
232
+ );
233
+ } else if (showValue && col.type === 'singleSelect' && valueOptions.length > 0 && (currentOperator === 'is' || currentOperator === 'not')) {
234
+ const current = valueOptions.find(o => matchesOption(o, item?.value));
235
+ valueControl = (
236
+ <select
237
+ id={`${filterId}-val`}
238
+ name={`filter-val-${col.field}`}
239
+ className={`${valueClassName} ogx-filter__value-select`}
240
+ value={current?.key ?? ''}
241
+ onChange={(e) => {
242
+ const option = valueOptions.find(o => o.key === e.target.value);
243
+ onChange(buildItem(currentOperator, option ? option.value : ''));
244
+ }}
245
+ aria-label={valueLabel}
246
+ >
247
+ <option value="">Any</option>
248
+ {valueOptions.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
249
+ </select>
250
+ );
251
+ } else if (showValue) {
252
+ valueControl = (
253
+ <input
254
+ id={`${filterId}-val`}
255
+ name={`filter-val-${col.field}`}
256
+ className={valueClassName}
257
+ type={col.type === 'date' ? 'date' : 'text'}
258
+ value={draft ?? toInputText(item?.value, col)}
259
+ onChange={(e) => setDraft(e.target.value)}
260
+ placeholder={col.type === 'number' ? 'Number…' : 'Value…'}
261
+ aria-label={valueLabel}
262
+ />
263
+ );
264
+ }
91
265
 
92
266
  return (
93
267
  <div className={`ogx-filter__row${isActive ? ' ogx-filter__row--active' : ''}`}>
94
- { }
95
268
  <span className="ogx-filter__col-label" title={col.field}>
96
- {col.headerName || col.field}
269
+ {label}
97
270
  </span>
98
271
 
99
- { }
100
272
  <div className="ogx-filter__controls">
101
- { }
102
273
  <select
103
274
  id={`${filterId}-op`}
104
275
  name={`filter-op-${col.field}`}
105
276
  className={`ogx-filter__op-select${isActive ? ' ogx-filter__op-select--active' : ''}`}
106
- value={String(currentOperator)}
107
- onChange={(e) => handleOperatorChange(e.target.value)}
108
- aria-label={`Filter operator for ${col.headerName || col.field}`}
277
+ value={currentOperator}
278
+ onChange={(e) => handleOperatorChange(e.target.value as GridFilterOperator)}
279
+ aria-label={`Filter operator for ${label}`}
109
280
  >
110
281
  {operators.map(op => (
111
282
  <option key={op} value={op}>{op}</option>
112
283
  ))}
113
284
  </select>
114
285
 
115
- { }
116
- {showValue && isBoolean && (
117
- <select
118
- id={`${filterId}-val`}
119
- name={`filter-val-${col.field}`}
120
- className={`ogx-filter__value-input ogx-filter__value-select${isActive ? ' ogx-filter__value-input--active' : ''}`}
121
- value={String(item?.value ?? 'true')}
122
- onChange={(e) => {
123
- setLocalValue(e.target.value);
124
- onChange({
125
- id: item?.id ?? `${col.field}-${Date.now()}`,
126
- field: col.field,
127
- operator: currentOperator as GridFilterOperator,
128
- value: e.target.value,
129
- });
130
- }}
131
- aria-label={`Filter value for ${col.headerName || col.field}`}
132
- >
133
- <option value="true">true</option>
134
- <option value="false">false</option>
135
- </select>
136
- )}
137
-
138
- {showValue && !isBoolean && (
139
- <input
140
- id={`${filterId}-val`}
141
- name={`filter-val-${col.field}`}
142
- className={`ogx-filter__value-input${isActive ? ' ogx-filter__value-input--active' : ''}`}
143
- type="text"
144
- value={localValue}
145
- onChange={(e) => setLocalValue(e.target.value)}
146
- placeholder={col.type === 'number' ? 'Number…' : 'Value…'}
147
- aria-label={`Filter value for ${col.headerName || col.field}`}
148
- />
149
- )}
286
+ {valueControl}
150
287
 
151
288
  {/* Clear button — only when active */}
152
289
  {isActive && (
153
290
  <button
291
+ type="button"
154
292
  className="ogx-filter__row-clear"
155
293
  onClick={handleClear}
156
- aria-label={`Clear filter for ${col.headerName || col.field}`}
294
+ aria-label={`Clear filter for ${label}`}
157
295
  title="Clear"
158
296
  >
159
297
  ×
@@ -167,12 +305,36 @@ const FilterRow: React.FC<{
167
305
  // ── Root panel ────────────────────────────────────────────────────────────────
168
306
 
169
307
  export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns, onFilterModelChange }) => {
170
- const items = useMemo<GridFilterItem[]>(() => (filterModel.items ?? []) as GridFilterItem[], [filterModel.items]);
308
+ const items = useMemo<FilterEntry[]>(() => filterModel.items ?? [], [filterModel.items]);
171
309
  const logicOperator = filterModel.logicOperator ?? 'and';
172
310
 
311
+ // Detect changes made outside the panel (Clear all, a programmatic model) so rows can drop
312
+ // typing that has not been committed yet. The panel's own edits are recognised by content,
313
+ // so a parent that re-creates an equal model object does not count as a change.
314
+ const [lastEmittedItems, setLastEmittedItems] = useState<FilterEntry[] | null>(null);
315
+ const [seenItems, setSeenItems] = useState(items);
316
+ const [resetToken, setResetToken] = useState(0);
317
+ if (seenItems !== items) {
318
+ setSeenItems(items);
319
+ const changed = !sameEntries(seenItems, items);
320
+ const own = lastEmittedItems !== null && sameEntries(lastEmittedItems, items);
321
+ if (changed && !own) setResetToken(token => token + 1);
322
+ }
323
+
324
+ // Replace only the item the row displays. Groups and other items (including further
325
+ // conditions on the same field) pass through untouched.
173
326
  const handleItemChange = useCallback((field: string, newItem: GridFilterItem | null) => {
174
- const rest = items.filter(i => 'field' in i && (i as GridFilterItem).field !== field);
175
- const next: GridFilterItem[] = newItem ? [...rest, newItem] : rest;
327
+ const index = items.findIndex(entry => isFilterItem(entry) && entry.field === field);
328
+ let next: FilterEntry[];
329
+ if (index === -1) {
330
+ if (!newItem) return;
331
+ next = [...items, newItem];
332
+ } else if (newItem) {
333
+ next = items.map((entry, i) => (i === index ? newItem : entry));
334
+ } else {
335
+ next = items.filter((_, i) => i !== index);
336
+ }
337
+ setLastEmittedItems(next);
176
338
  onFilterModelChange({ ...filterModel, items: next });
177
339
  }, [items, filterModel, onFilterModelChange]);
178
340
 
@@ -181,14 +343,16 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns,
181
343
  }, [filterModel, onFilterModelChange]);
182
344
 
183
345
  const filterableColumns = columns.filter(col => col.filterable !== false);
346
+ const shownCount = filterableColumns.filter(col => getActiveFilter(items, col.field)).length;
347
+ const notShownCount = items.length - shownCount;
184
348
 
185
349
  return (
186
350
  <>
187
- { }
188
351
  {items.length > 1 && (
189
352
  <div className="ogx-filter__logic-bar">
190
353
  <span className="ogx-filter__logic-label">Match:</span>
191
354
  <button
355
+ type="button"
192
356
  className={`ogx-filter__logic-pill${logicOperator === 'and' ? ' ogx-filter__logic-pill--active' : ''}`}
193
357
  onClick={() => handleLogicChange('and')}
194
358
  aria-pressed={logicOperator === 'and'}
@@ -196,6 +360,7 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns,
196
360
  ALL (AND)
197
361
  </button>
198
362
  <button
363
+ type="button"
199
364
  className={`ogx-filter__logic-pill${logicOperator === 'or' ? ' ogx-filter__logic-pill--active' : ''}`}
200
365
  onClick={() => handleLogicChange('or')}
201
366
  aria-pressed={logicOperator === 'or'}
@@ -205,7 +370,14 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns,
205
370
  </div>
206
371
  )}
207
372
 
208
- { }
373
+ {notShownCount > 0 && (
374
+ <div className="ogx-filter__hidden-note" role="note">
375
+ {notShownCount === 1
376
+ ? '1 more condition (a filter group or a second condition on a column) is applied and not shown here.'
377
+ : `${notShownCount} more conditions (filter groups or further conditions on a column) are applied and not shown here.`}
378
+ </div>
379
+ )}
380
+
209
381
  {filterableColumns.length === 0 ? (
210
382
  <div className="ogx-filter__empty">No filterable columns.</div>
211
383
  ) : (
@@ -214,6 +386,7 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns,
214
386
  key={col.field}
215
387
  col={col}
216
388
  item={getActiveFilter(items, col.field)}
389
+ resetToken={resetToken}
217
390
  onChange={(item) => handleItemChange(col.field, item)}
218
391
  />
219
392
  ))