@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,14 +1,34 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
- import type { GridRenderCellParams } from '../../types';
2
+ import type { KeyboardEvent } from 'react';
3
+ import type { GridColDef, GridRenderEditCellParams } from '../../types';
3
4
 
4
- export interface GridEditInputCellProps extends GridRenderCellParams {
5
- onValueChange: (value: unknown) => void;
6
- onCommit: () => void;
7
- onCancel: () => void;
5
+ export type GridEditInputCellProps = GridRenderEditCellParams;
6
+
7
+ type ValueOption = NonNullable<GridColDef['valueOptions']>[number];
8
+
9
+ /** The accessible name of an editor: its column header, or the field when there is none. */
10
+ function editorLabel(colDef: GridColDef): string {
11
+ return colDef.headerName || colDef.field;
12
+ }
13
+
14
+ /** An IME is composing (keyCode 229 covers Safari and older engines): Enter/Escape belong to the IME. */
15
+ function isComposing(e: KeyboardEvent): boolean {
16
+ return e.nativeEvent.isComposing || e.keyCode === 229;
17
+ }
18
+
19
+ function makeKeyDownHandler(onCommit: () => void, onCancel: () => void, commitOnEnter = true) {
20
+ return (e: KeyboardEvent) => {
21
+ if (isComposing(e)) {
22
+ e.stopPropagation();
23
+ return;
24
+ }
25
+ if (commitOnEnter && e.key === 'Enter') { e.stopPropagation(); onCommit(); }
26
+ if (e.key === 'Escape') { e.stopPropagation(); onCancel(); }
27
+ };
8
28
  }
9
29
 
10
30
  // ─── Text / String ────────────────────────────────────────────────────────────
11
- function TextEditor({ value, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
31
+ function TextEditor({ value, colDef, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
12
32
  const [local, setLocal] = useState(String(value ?? ''));
13
33
  const ref = useRef<HTMLInputElement>(null);
14
34
  useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
@@ -18,19 +38,17 @@ function TextEditor({ value, onValueChange, onCommit, onCancel }: GridEditInputC
18
38
  ref={ref}
19
39
  type="text"
20
40
  className="ogx__edit-input"
41
+ aria-label={editorLabel(colDef)}
21
42
  value={local}
22
43
  onChange={e => { setLocal(e.target.value); onValueChange(e.target.value); }}
23
44
  onBlur={onCommit}
24
- onKeyDown={e => {
25
- if (e.key === 'Enter') { e.stopPropagation(); onCommit(); }
26
- if (e.key === 'Escape') { e.stopPropagation(); onCancel(); }
27
- }}
45
+ onKeyDown={makeKeyDownHandler(onCommit, onCancel)}
28
46
  />
29
47
  );
30
48
  }
31
49
 
32
50
  // ─── Number ───────────────────────────────────────────────────────────────────
33
- function NumberEditor({ value, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
51
+ function NumberEditor({ value, colDef, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
34
52
  const [local, setLocal] = useState<string | number>(typeof value === 'number' ? value : '');
35
53
  const ref = useRef<HTMLInputElement>(null);
36
54
  useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
@@ -40,6 +58,7 @@ function NumberEditor({ value, onValueChange, onCommit, onCancel }: GridEditInpu
40
58
  ref={ref}
41
59
  type="number"
42
60
  className="ogx__edit-input ogx__edit-input--number"
61
+ aria-label={editorLabel(colDef)}
43
62
  value={local}
44
63
  onChange={e => {
45
64
  const v = e.target.value === '' ? '' : Number(e.target.value);
@@ -47,24 +66,20 @@ function NumberEditor({ value, onValueChange, onCommit, onCancel }: GridEditInpu
47
66
  onValueChange(v === '' ? null : v);
48
67
  }}
49
68
  onBlur={onCommit}
50
- onKeyDown={e => {
51
- if (e.key === 'Enter') { e.stopPropagation(); onCommit(); }
52
- if (e.key === 'Escape') { e.stopPropagation(); onCancel(); }
53
- }}
69
+ onKeyDown={makeKeyDownHandler(onCommit, onCancel)}
54
70
  />
55
71
  );
56
72
  }
57
73
 
58
74
  // ─── Boolean ──────────────────────────────────────────────────────────────────
59
- function BooleanEditor({ value, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
75
+ function BooleanEditor({ value, colDef, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
60
76
  const ref = useRef<HTMLInputElement>(null);
61
77
  useEffect(() => { ref.current?.focus(); }, []);
62
78
 
63
79
  const handleChange = () => {
64
- const next = !value;
65
- onValueChange(next);
66
- // Commit immediately on toggle
67
- setTimeout(onCommit, 0);
80
+ // The grid records the new value synchronously, so the toggle can be committed straight away.
81
+ onValueChange(!value);
82
+ onCommit();
68
83
  };
69
84
 
70
85
  return (
@@ -73,47 +88,107 @@ function BooleanEditor({ value, onValueChange, onCommit, onCancel }: GridEditInp
73
88
  ref={ref}
74
89
  type="checkbox"
75
90
  className="ogx__edit-checkbox"
91
+ aria-label={editorLabel(colDef)}
76
92
  checked={!!value}
77
93
  onChange={handleChange}
78
- onKeyDown={e => {
79
- if (e.key === 'Escape') { e.stopPropagation(); onCancel(); }
80
- }}
94
+ onBlur={onCommit}
95
+ onKeyDown={makeKeyDownHandler(onCommit, onCancel, false)}
81
96
  />
82
97
  </div>
83
98
  );
84
99
  }
85
100
 
86
101
  // ─── SingleSelect / Enum ──────────────────────────────────────────────────────
102
+ const optionValue = (opt: ValueOption): unknown => (typeof opt === 'object' ? opt.value : opt);
103
+ const optionLabel = (opt: ValueOption): string => (typeof opt === 'object' ? opt.label : String(opt));
104
+
105
+ function sameOptionValue(a: unknown, b: unknown): boolean {
106
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
107
+ return Object.is(a, b);
108
+ }
109
+
87
110
  function SelectEditor({ value, colDef, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
88
111
  const ref = useRef<HTMLSelectElement>(null);
89
112
  useEffect(() => { ref.current?.focus(); }, []);
90
113
 
91
114
  const options = colDef.valueOptions ?? [];
115
+ const keys = options.map(opt => String(optionValue(opt)));
116
+ let selectedIndex = options.findIndex(opt => sameOptionValue(optionValue(opt), value));
117
+ if (selectedIndex === -1 && value !== null && value !== undefined) {
118
+ // Loose match so a string '2' in the row still selects the numeric option 2.
119
+ selectedIndex = keys.indexOf(String(value));
120
+ }
121
+ // With no matching option the browser would show the first option as chosen while the value
122
+ // is still null, and re-picking that option fires no change event. Show an empty choice instead.
123
+ const showEmptyChoice = selectedIndex === -1;
92
124
 
93
125
  return (
94
126
  <select
95
127
  ref={ref}
96
128
  className="ogx__edit-select"
97
- value={String(value ?? '')}
98
- onChange={e => { onValueChange(e.target.value); }}
99
- onBlur={onCommit}
100
- onKeyDown={e => {
101
- if (e.key === 'Enter') { e.stopPropagation(); onCommit(); }
102
- if (e.key === 'Escape') { e.stopPropagation(); onCancel(); }
129
+ aria-label={editorLabel(colDef)}
130
+ value={showEmptyChoice ? '' : keys[selectedIndex]}
131
+ onChange={e => {
132
+ const index = keys.indexOf(e.target.value);
133
+ // Commit the option's own value (a number stays a number), not the DOM string.
134
+ onValueChange(index === -1 ? null : optionValue(options[index]));
103
135
  }}
136
+ onBlur={onCommit}
137
+ onKeyDown={makeKeyDownHandler(onCommit, onCancel)}
104
138
  >
105
- {options.map(opt => {
106
- const v = typeof opt === 'object' ? opt.value : opt;
107
- const l = typeof opt === 'object' ? opt.label : String(opt);
108
- return <option key={String(v)} value={String(v)}>{l}</option>;
109
- })}
139
+ {showEmptyChoice && <option value="" />}
140
+ {options.map((opt, i) => (
141
+ <option key={i} value={keys[i]}>{optionLabel(opt)}</option>
142
+ ))}
110
143
  </select>
111
144
  );
112
145
  }
113
146
 
114
147
  // ─── Date ─────────────────────────────────────────────────────────────────────
115
- function DateEditor({ value, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
116
- const [local, setLocal] = useState<string>(typeof value === 'string' ? value : String(value ?? ''));
148
+ const pad2 = (n: number) => String(n).padStart(2, '0');
149
+ const ISO_DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
150
+
151
+ /** The `YYYY-MM-DD` an `<input type="date">` needs; Dates use their local calendar day. */
152
+ function toDateInputValue(value: unknown): string {
153
+ if (value instanceof Date) {
154
+ return Number.isNaN(value.getTime())
155
+ ? ''
156
+ : `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}`;
157
+ }
158
+ if (typeof value === 'string') {
159
+ const iso = ISO_DATE_PREFIX.exec(value);
160
+ if (iso) return iso[1];
161
+ return value === '' ? '' : toDateInputValue(new Date(value));
162
+ }
163
+ if (typeof value === 'number') return toDateInputValue(new Date(value));
164
+ return '';
165
+ }
166
+
167
+ /**
168
+ * Turns the picked `YYYY-MM-DD` back into the kind of value the cell held: a Date stays a Date
169
+ * (keeping its time of day), a timestamp stays a timestamp, an ISO date-time string keeps its time
170
+ * part. A cleared input commits `null`.
171
+ */
172
+ function fromDateInputValue(picked: string, original: unknown): unknown {
173
+ if (picked === '') return null;
174
+ const [year, month, day] = picked.split('-').map(Number);
175
+ const withDay = (base: Date) => {
176
+ const next = new Date(base.getTime());
177
+ next.setFullYear(year, month - 1, day);
178
+ return next;
179
+ };
180
+ if (original instanceof Date && !Number.isNaN(original.getTime())) return withDay(original);
181
+ if (typeof original === 'number' && Number.isFinite(original)) return withDay(new Date(original)).getTime();
182
+ if (typeof original === 'string' && ISO_DATE_PREFIX.test(original) && original.length > 10) {
183
+ return picked + original.slice(10);
184
+ }
185
+ return picked;
186
+ }
187
+
188
+ function DateEditor({ value, colDef, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
189
+ // The value the edit started from decides the type that is committed back.
190
+ const [initialValue] = useState(value);
191
+ const [local, setLocal] = useState(() => toDateInputValue(value));
117
192
  const ref = useRef<HTMLInputElement>(null);
118
193
  useEffect(() => { ref.current?.focus(); }, []);
119
194
 
@@ -122,13 +197,11 @@ function DateEditor({ value, onValueChange, onCommit, onCancel }: GridEditInputC
122
197
  ref={ref}
123
198
  type="date"
124
199
  className="ogx__edit-input"
200
+ aria-label={editorLabel(colDef)}
125
201
  value={local}
126
- onChange={e => { setLocal(e.target.value); onValueChange(e.target.value); }}
202
+ onChange={e => { setLocal(e.target.value); onValueChange(fromDateInputValue(e.target.value, initialValue)); }}
127
203
  onBlur={onCommit}
128
- onKeyDown={e => {
129
- if (e.key === 'Enter') { e.stopPropagation(); onCommit(); }
130
- if (e.key === 'Escape') { e.stopPropagation(); onCancel(); }
131
- }}
204
+ onKeyDown={makeKeyDownHandler(onCommit, onCancel)}
132
205
  />
133
206
  );
134
207
  }
@@ -9,7 +9,7 @@
9
9
  align-items: stretch;
10
10
  background: var(--ogx-grid-header-background);
11
11
  border-bottom: 1px solid var(--ogx-grid-border-color);
12
- overflow: hidden;
12
+ /* No overflow clipping: it would make the row the scroll container of its sticky (pinned) cells. */
13
13
  flex-shrink: 0;
14
14
  min-height: var(--ogx-header-height, 48px);
15
15
  }
@@ -46,6 +46,12 @@
46
46
  background: transparent;
47
47
  }
48
48
 
49
+ /* ─── Pinned group cell (sticky over pinned columns) ────────────────────────*/
50
+ .ogx-col-group-cell--pinned {
51
+ background: var(--ogx-grid-header-background);
52
+ z-index: 12;
53
+ }
54
+
49
55
  /* ─── Label text ─────────────────────────────────────────────────────────────*/
50
56
  .ogx-col-group-cell__label {
51
57
  font-size: 0.75rem;
@@ -10,6 +10,17 @@
10
10
  align-items: center;
11
11
  justify-content: center;
12
12
  user-select: none;
13
+ /* Touch and pen drags resize the column instead of scrolling the grid. */
14
+ touch-action: none;
15
+ }
16
+ /* Right-pinned columns grow leftwards: their handle sits on the left edge. */
17
+ .ogx-column-resize-handle--start {
18
+ right: auto;
19
+ left: -4px;
20
+ }
21
+ .ogx-column-resize-handle:focus-visible .ogx-column-resize-handle__line {
22
+ background: var(--ogx-color-primary);
23
+ opacity: 1;
13
24
  }
14
25
  .ogx-column-resize-handle:hover .ogx-column-resize-handle__line,
15
26
  .ogx-column-resize-handle--dragging .ogx-column-resize-handle__line {
@@ -1,58 +1,113 @@
1
-
2
- import React, { useCallback, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { clampResizeWidth } from './clampResizeWidth';
3
3
 
4
4
 
5
5
  export interface ColumnResizeHandleProps {
6
6
  field: string;
7
7
  currentWidth: number;
8
8
  onResize: (field: string, newWidth: number) => void;
9
+ /** Defaults to 50 (the layout's default minimum), or the current width if that is smaller. */
9
10
  minWidth?: number;
11
+ /** Defaults to no limit. */
10
12
  maxWidth?: number;
13
+ /**
14
+ * Which edge of the column the handle sits on. `'start'` (the left edge) is for right-pinned
15
+ * columns, which are anchored on the right and grow leftwards. Default `'end'`.
16
+ */
17
+ edge?: 'start' | 'end';
11
18
  }
12
19
 
13
- export function ColumnResizeHandle(props: ColumnResizeHandleProps) {
14
- const { field, currentWidth, onResize, minWidth = 50, maxWidth = 1000 } = props;
15
- const [isDragging, setIsDragging] = useState(false);
16
- const startXRef = useRef<number>(0);
17
- const startWidthRef = useRef<number>(0);
20
+ interface ActiveResize {
21
+ pointerId: number;
22
+ startX: number;
23
+ startWidth: number;
24
+ lastUpdate: number;
25
+ moved: boolean;
26
+ }
18
27
 
19
- const handleMouseDown = useCallback((event: React.MouseEvent) => {
20
- event.preventDefault();
28
+ const THROTTLE_MS = 16;
29
+
30
+ function preventMouseDefault(event: React.MouseEvent): void {
31
+ event.preventDefault();
32
+ event.stopPropagation();
33
+ }
34
+
35
+ /** Swallows the click the browser synthesizes after a drag, so ending a resize never sorts. */
36
+ function suppressNextClick(): void {
37
+ const swallow = (event: MouseEvent) => {
21
38
  event.stopPropagation();
39
+ event.preventDefault();
40
+ };
41
+ window.addEventListener('click', swallow, { capture: true, once: true });
42
+ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0);
43
+ }
22
44
 
23
- setIsDragging(true);
24
- startXRef.current = event.clientX;
25
- // Use the logical stored width — reliable for pinned, flex, and normal columns
26
- startWidthRef.current = currentWidth;
27
-
28
- let lastUpdateTime = 0;
29
- const throttleMs = 16;
30
-
31
- const handleMouseMove = (e: MouseEvent) => {
32
- const deltaX = e.clientX - startXRef.current;
33
- const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidthRef.current + deltaX));
34
-
35
- const now = Date.now();
36
- if (now - lastUpdateTime >= throttleMs) {
37
- onResize(field, newWidth);
38
- lastUpdateTime = now;
39
- }
40
- };
45
+ export function ColumnResizeHandle(props: ColumnResizeHandleProps) {
46
+ const { field, currentWidth, onResize, minWidth, maxWidth, edge = 'end' } = props;
47
+ const [isDragging, setIsDragging] = useState(false);
48
+ const activeRef = useRef<ActiveResize | null>(null);
41
49
 
42
- const handleMouseUp = (e: MouseEvent) => {
43
- setIsDragging(false);
50
+ // A resize in progress ends with the handle: nothing is reported after unmount.
51
+ useEffect(() => () => { activeRef.current = null; }, []);
44
52
 
45
- const deltaX = e.clientX - startXRef.current;
46
- const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidthRef.current + deltaX));
53
+ const widthAt = useCallback((active: ActiveResize, clientX: number) => {
54
+ const deltaX = clientX - active.startX;
55
+ const raw = active.startWidth + (edge === 'start' ? -deltaX : deltaX);
56
+ return clampResizeWidth(raw, active.startWidth, minWidth, maxWidth);
57
+ }, [edge, minWidth, maxWidth]);
47
58
 
48
- onResize(field, newWidth);
59
+ const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
60
+ if (event.button !== 0) return;
61
+ event.stopPropagation();
49
62
 
50
- document.removeEventListener('mousemove', handleMouseMove);
51
- document.removeEventListener('mouseup', handleMouseUp);
63
+ activeRef.current = {
64
+ pointerId: event.pointerId,
65
+ startX: event.clientX,
66
+ // The logical stored width: reliable for pinned, flex, and normal columns.
67
+ startWidth: currentWidth,
68
+ lastUpdate: 0,
69
+ moved: false,
52
70
  };
53
-
54
- document.addEventListener('mousemove', handleMouseMove);
55
- document.addEventListener('mouseup', handleMouseUp);
71
+ setIsDragging(true);
72
+ // Keeps pointermove / pointerup coming to the handle wherever the pointer goes (also touch and pen).
73
+ try {
74
+ event.currentTarget.setPointerCapture(event.pointerId);
75
+ } catch {
76
+ // The pointer is no longer active (synthetic events); moves over the handle still work.
77
+ }
78
+ }, [currentWidth]);
79
+
80
+ const handlePointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
81
+ const active = activeRef.current;
82
+ if (!active || event.pointerId !== active.pointerId) return;
83
+ if (event.clientX !== active.startX) active.moved = true;
84
+ if (!active.moved) return;
85
+
86
+ const now = Date.now();
87
+ if (now - active.lastUpdate >= THROTTLE_MS) {
88
+ active.lastUpdate = now;
89
+ onResize(field, widthAt(active, event.clientX));
90
+ }
91
+ }, [field, onResize, widthAt]);
92
+
93
+ const finish = useCallback((event: React.PointerEvent<HTMLDivElement>, commit: boolean) => {
94
+ const active = activeRef.current;
95
+ if (!active || event.pointerId !== active.pointerId) return;
96
+ activeRef.current = null;
97
+ setIsDragging(false);
98
+ if (active.moved || event.clientX !== active.startX) {
99
+ // The drag may end over the header cell, which would take the synthesized click as a sort.
100
+ suppressNextClick();
101
+ if (commit) onResize(field, widthAt(active, event.clientX));
102
+ }
103
+ }, [field, onResize, widthAt]);
104
+
105
+ const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
106
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
107
+ event.preventDefault();
108
+ event.stopPropagation();
109
+ const step = (event.shiftKey ? 50 : 10) * (event.key === 'ArrowRight' ? 1 : -1);
110
+ onResize(field, clampResizeWidth(currentWidth + step, currentWidth, minWidth, maxWidth));
56
111
  }, [field, currentWidth, onResize, minWidth, maxWidth]);
57
112
 
58
113
  const handleDoubleClick = useCallback((event: React.MouseEvent) => {
@@ -62,22 +117,33 @@ export function ColumnResizeHandle(props: ColumnResizeHandleProps) {
62
117
 
63
118
  const classNames = [
64
119
  'ogx-column-resize-handle',
120
+ edge === 'start' && 'ogx-column-resize-handle--start',
65
121
  isDragging && 'ogx-column-resize-handle--dragging'
66
122
  ].filter(Boolean).join(' ');
67
123
 
68
124
  return (
69
125
  <div
70
126
  className={classNames}
71
- onMouseDown={handleMouseDown}
127
+ onPointerDown={handlePointerDown}
128
+ // The mouse's default action would start dragging the (draggable) header or select text.
129
+ onMouseDown={preventMouseDefault}
130
+ onPointerMove={handlePointerMove}
131
+ onPointerUp={(e) => finish(e, true)}
132
+ onPointerCancel={(e) => finish(e, false)}
72
133
  onClick={(e) => e.stopPropagation()}
73
134
  onDragStart={(e) => {
74
135
  e.preventDefault();
75
136
  e.stopPropagation();
76
137
  }}
77
138
  onDoubleClick={handleDoubleClick}
139
+ onKeyDown={handleKeyDown}
78
140
  role="separator"
79
141
  aria-orientation="vertical"
80
142
  aria-label={`Resize ${field} column`}
143
+ aria-valuenow={Math.round(currentWidth)}
144
+ aria-valuemin={Math.round(Math.min(minWidth ?? 50, currentWidth))}
145
+ aria-valuemax={maxWidth}
146
+ tabIndex={-1}
81
147
  >
82
148
  <div className="ogx-column-resize-handle__line" />
83
149
  </div>
@@ -0,0 +1,13 @@
1
+ /** The layout's minimum for a column without `minWidth` (see useLayout's flex solver). */
2
+ export const DEFAULT_MIN_COLUMN_WIDTH = 50;
3
+
4
+ /**
5
+ * Clamps a width a resize asks for to the column's `minWidth` / `maxWidth`. Without `minWidth` the
6
+ * floor is 50px, and without `maxWidth` there is no ceiling. A column that already sits outside
7
+ * those bounds (a 30px icon column) is never snapped to them: its own width stays reachable.
8
+ */
9
+ export function clampResizeWidth(width: number, startWidth: number, minWidth?: number, maxWidth?: number): number {
10
+ const min = Math.min(minWidth ?? DEFAULT_MIN_COLUMN_WIDTH, startWidth);
11
+ const max = Math.max(maxWidth ?? Infinity, startWidth);
12
+ return Math.max(min, Math.min(max, width));
13
+ }
@@ -109,6 +109,12 @@
109
109
  font-size: var(--ogx-font-size-xs);
110
110
  color: var(--ogx-overlay-text, var(--ogx-color-gray-900));
111
111
  user-select: none;
112
+ cursor: inherit;
113
+ }
114
+
115
+ /* The column name fills the row, so a click anywhere after the checkbox toggles it. */
116
+ .ogx-column-visibility-panel__item-label .ogx-column-visibility-panel__label {
117
+ flex: 1;
112
118
  }
113
119
 
114
120
  .ogx-column-visibility-panel__footer {
@@ -1,9 +1,9 @@
1
- import { useState, useMemo, useRef } from 'react';
1
+ import { useState, useMemo, useRef, useId } from 'react';
2
2
  import { Checkbox } from '../ui/Checkbox';
3
- import type { GridColDef, GridRowModel } from '../../types';
3
+ import type { GridColDef, GridRowModel, GridValidRowModel } from '../../types';
4
4
 
5
5
 
6
- export interface ColumnVisibilityPanelProps<R extends GridRowModel = GridRowModel> {
6
+ export interface ColumnVisibilityPanelProps<R extends GridValidRowModel = GridRowModel> {
7
7
  columns: GridColDef<R>[];
8
8
  visibleColumns: Set<string>;
9
9
  onVisibilityChange: (field: string, isVisible: boolean) => void;
@@ -43,11 +43,15 @@ function DragHandleIcon() {
43
43
  );
44
44
  }
45
45
 
46
- export function ColumnVisibilityPanel<R extends GridRowModel = GridRowModel>(
46
+ export function ColumnVisibilityPanel<R extends GridValidRowModel = GridRowModel>(
47
47
  props: ColumnVisibilityPanelProps<R>
48
48
  ) {
49
49
  const { columns, visibleColumns, onVisibilityChange, onShowAll, onHideAll, onColumnReorder, onColumnOrderReset, showNonHideableColumns } = props;
50
50
  const [searchQuery, setSearchQuery] = useState('');
51
+ // Each checkbox is named by a <label htmlFor> holding the column name. (Wrapping Checkbox,
52
+ // which renders its own <label>, in another <label> nested labels and left the name to a
53
+ // generic fallback.)
54
+ const idBase = useId();
51
55
  const [dragOverField, setDragOverField] = useState<string | null>(null);
52
56
  const dragFieldRef = useRef<string | null>(null);
53
57
 
@@ -122,7 +126,8 @@ export function ColumnVisibilityPanel<R extends GridRowModel = GridRowModel>(
122
126
  </div>
123
127
 
124
128
  <div className="ogx-column-visibility-panel__list">
125
- {filteredColumns.map(col => {
129
+ {filteredColumns.map((col, index) => {
130
+ const checkboxId = `${idBase}-col-${index}`;
126
131
  const isVisible = visibleColumns.has(col.field);
127
132
  const isHideable = col.hideable !== false;
128
133
  const isDragOver = dragOverField === col.field;
@@ -152,35 +157,38 @@ export function ColumnVisibilityPanel<R extends GridRowModel = GridRowModel>(
152
157
  <DragHandleIcon />
153
158
  </span>
154
159
  )}
155
- <label
160
+ <div
156
161
  className="ogx-column-visibility-panel__item-label"
157
162
  style={{ display: 'flex', alignItems: 'center', gap: 'var(--ogx-spacing-md)', flex: 1, cursor: isHideable ? 'pointer' : 'not-allowed' }}
158
163
  >
159
164
  <Checkbox
165
+ id={checkboxId}
160
166
  checked={isVisible}
161
167
  onChange={(e) => onVisibilityChange(col.field, e.target.checked)}
162
168
  disabled={!isHideable}
163
169
  />
164
- <span className="ogx-column-visibility-panel__label">
170
+ <label htmlFor={checkboxId} className="ogx-column-visibility-panel__label">
165
171
  {col.headerName || col.field}
166
- </span>
167
- </label>
172
+ </label>
173
+ </div>
168
174
  </div>
169
175
  );
170
176
  })}
171
177
  </div>
172
178
 
173
179
  <div className="ogx-column-visibility-panel__footer">
174
- <label className="ogx-column-visibility-panel__item">
180
+ <div className="ogx-column-visibility-panel__item">
175
181
  <Checkbox
182
+ id={`${idBase}-all`}
176
183
  checked={allVisible}
177
184
  onChange={handleToggleAll}
178
185
  />
179
- <span className="ogx-column-visibility-panel__label">
186
+ <label htmlFor={`${idBase}-all`} className="ogx-column-visibility-panel__label">
180
187
  Show/Hide All
181
- </span>
182
- </label>
188
+ </label>
189
+ </div>
183
190
  <button
191
+ type="button"
184
192
  className="ogx-column-visibility-panel__reset-btn"
185
193
  onClick={() => { onShowAll(); onColumnOrderReset?.(); }}
186
194
  >