@alaarab/ogrid-core 1.3.2 → 1.4.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 (44) hide show
  1. package/dist/esm/components/OGridLayout.js +73 -7
  2. package/dist/esm/components/SideBar.js +98 -0
  3. package/dist/esm/hooks/index.js +2 -0
  4. package/dist/esm/hooks/useCellSelection.js +84 -0
  5. package/dist/esm/hooks/useColumnHeaderFilterState.js +25 -3
  6. package/dist/esm/hooks/useColumnResize.js +9 -2
  7. package/dist/esm/hooks/useDataGridState.js +43 -6
  8. package/dist/esm/hooks/useKeyboardNavigation.js +56 -4
  9. package/dist/esm/hooks/useOGrid.js +132 -4
  10. package/dist/esm/hooks/useRichSelectState.js +53 -0
  11. package/dist/esm/hooks/useSideBarState.js +34 -0
  12. package/dist/esm/index.js +3 -2
  13. package/dist/esm/types/dataGridTypes.js +9 -2
  14. package/dist/esm/utils/aggregationUtils.js +39 -0
  15. package/dist/esm/utils/columnUtils.js +97 -0
  16. package/dist/esm/utils/dataGridViewModel.js +105 -6
  17. package/dist/esm/utils/index.js +3 -2
  18. package/dist/esm/utils/ogridHelpers.js +4 -2
  19. package/dist/esm/utils/paginationHelpers.js +1 -1
  20. package/dist/esm/utils/statusBarHelpers.js +8 -1
  21. package/dist/esm/utils/valueParsers.js +15 -1
  22. package/dist/types/components/OGridLayout.d.ts +22 -5
  23. package/dist/types/components/SideBar.d.ts +34 -0
  24. package/dist/types/components/StatusBar.d.ts +8 -0
  25. package/dist/types/hooks/index.d.ts +5 -1
  26. package/dist/types/hooks/useColumnHeaderFilterState.d.ts +9 -1
  27. package/dist/types/hooks/useColumnResize.d.ts +3 -1
  28. package/dist/types/hooks/useDataGridState.d.ts +3 -0
  29. package/dist/types/hooks/useInlineCellEditorState.d.ts +1 -1
  30. package/dist/types/hooks/useOGrid.d.ts +7 -0
  31. package/dist/types/hooks/useRichSelectState.d.ts +17 -0
  32. package/dist/types/hooks/useSideBarState.d.ts +15 -0
  33. package/dist/types/index.d.ts +7 -5
  34. package/dist/types/types/columnTypes.d.ts +26 -2
  35. package/dist/types/types/dataGridTypes.d.ts +56 -6
  36. package/dist/types/types/index.d.ts +2 -2
  37. package/dist/types/utils/aggregationUtils.d.ts +15 -0
  38. package/dist/types/utils/columnUtils.d.ts +16 -1
  39. package/dist/types/utils/dataGridViewModel.d.ts +67 -2
  40. package/dist/types/utils/index.d.ts +5 -3
  41. package/dist/types/utils/ogridHelpers.d.ts +2 -2
  42. package/dist/types/utils/paginationHelpers.d.ts +1 -1
  43. package/dist/types/utils/statusBarHelpers.d.ts +8 -0
  44. package/package.json +1 -1
@@ -1,16 +1,82 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- const defaultGap = 16;
2
+ import { SideBar } from './SideBar';
3
+ // Stable style objects (avoid re-creating on every render)
4
+ const borderedContainerStyle = {
5
+ border: '1px solid var(--ogrid-border, #e0e0e0)',
6
+ borderRadius: 6,
7
+ overflow: 'hidden',
8
+ display: 'flex',
9
+ flexDirection: 'column',
10
+ flex: 1,
11
+ minHeight: 0,
12
+ background: 'var(--ogrid-bg, #fff)',
13
+ };
14
+ const toolbarStripStyle = {
15
+ display: 'flex',
16
+ justifyContent: 'space-between',
17
+ alignItems: 'center',
18
+ padding: '6px 12px',
19
+ borderBottom: '1px solid var(--ogrid-border, #e0e0e0)',
20
+ background: 'var(--ogrid-header-bg, #f5f5f5)',
21
+ gap: 8,
22
+ flexWrap: 'wrap',
23
+ minHeight: 0,
24
+ };
25
+ const toolbarSectionStyle = {
26
+ display: 'flex',
27
+ alignItems: 'center',
28
+ gap: 8,
29
+ };
30
+ const footerStripStyle = {
31
+ borderTop: '1px solid var(--ogrid-border, #e0e0e0)',
32
+ background: 'var(--ogrid-header-bg, #f5f5f5)',
33
+ padding: '6px 12px',
34
+ };
35
+ const gridAreaFlexStyle = {
36
+ width: '100%',
37
+ minWidth: 0,
38
+ flex: 1,
39
+ display: 'flex',
40
+ };
41
+ const gridAreaSoloStyle = {
42
+ width: '100%',
43
+ minWidth: 0,
44
+ minHeight: 0,
45
+ flex: 1,
46
+ display: 'flex',
47
+ flexDirection: 'column',
48
+ };
49
+ const gridChildStyle = {
50
+ flex: 1,
51
+ minWidth: 0,
52
+ minHeight: 0,
53
+ display: 'flex',
54
+ flexDirection: 'column',
55
+ };
3
56
  /**
4
- * Renders OGrid layout: [toolbar row | title, toolbar, columnChooser] [grid] [pagination].
5
- * Inner structure uses divs; only the root uses Container so UIs can use Box/div and pass gap.
57
+ * Renders OGrid layout as a unified bordered container:
58
+ * [deprecated title above]
59
+ * ┌────────────────────────────────────┐
60
+ * │ [toolbar strip] │
61
+ * ├────────────────────────────────────┤
62
+ * │ [sidebar]? [grid] │
63
+ * ├────────────────────────────────────┤
64
+ * │ [footer strip / pagination] │
65
+ * └────────────────────────────────────┘
6
66
  */
7
67
  export function OGridLayout(props) {
8
- const { containerComponent: Container = 'div', containerProps = {}, gap = defaultGap, className, title, toolbar, columnChooser, children, pagination, } = props;
9
- // Always apply flex layout; merge with any containerProps styles
68
+ const { containerComponent: Container = 'div', containerProps = {}, gap = 8, className, title, toolbar, columnChooser, toolbarEnd: toolbarEndProp, children, pagination, sideBar, } = props;
69
+ const hasSideBar = sideBar != null;
70
+ const sideBarPosition = sideBar?.position ?? 'right';
71
+ // Backward compat: columnChooser prop → toolbarEnd
72
+ const toolbarEnd = toolbarEndProp ?? columnChooser;
73
+ const hasToolbar = toolbar != null || toolbarEnd != null;
74
+ // Root styles: flex column, fill parent height, gap for deprecated title spacing
10
75
  const rootStyle = {
11
76
  display: 'flex',
12
77
  flexDirection: 'column',
13
- gap: typeof gap === 'number' ? `${gap}px` : gap,
78
+ height: '100%',
79
+ gap: title != null ? (typeof gap === 'number' ? `${gap}px` : gap) : undefined,
14
80
  };
15
- return (_jsxs(Container, { className: className, style: rootStyle, ...containerProps, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8, minHeight: 40 }, children: [title != null ? _jsx("div", { style: { margin: 0 }, children: title }) : null, toolbar, columnChooser] }), _jsx("div", { style: { width: '100%', minWidth: 0, flex: 1 }, children: children }), pagination] }));
81
+ return (_jsxs(Container, { className: className, style: rootStyle, ...containerProps, children: [title != null && _jsx("div", { style: { margin: 0 }, children: title }), _jsxs("div", { style: borderedContainerStyle, children: [hasToolbar && (_jsxs("div", { style: toolbarStripStyle, children: [_jsx("div", { style: toolbarSectionStyle, children: toolbar }), _jsx("div", { style: toolbarSectionStyle, children: toolbarEnd })] })), hasSideBar ? (_jsxs("div", { style: gridAreaFlexStyle, children: [sideBarPosition === 'left' && _jsx(SideBar, { ...sideBar }), _jsx("div", { style: gridChildStyle, children: children }), sideBarPosition !== 'left' && _jsx(SideBar, { ...sideBar })] })) : (_jsx("div", { style: gridAreaSoloStyle, children: children })), pagination && (_jsx("div", { style: footerStripStyle, children: pagination }))] })] }));
16
82
  }
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ const PANEL_WIDTH = 240;
3
+ const TAB_WIDTH = 36;
4
+ const PANEL_LABELS = {
5
+ columns: 'Columns',
6
+ filters: 'Filters',
7
+ };
8
+ export function SideBar(props) {
9
+ const { activePanel, onPanelChange, panels, position, columns, visibleColumns, onVisibilityChange, onSetVisibleColumns, filterableColumns, multiSelectFilters, textFilters, onMultiSelectFilterChange, onTextFilterChange, dateFilters, onDateFilterChange, filterOptions, } = props;
10
+ const isOpen = activePanel !== null;
11
+ const handleTabClick = (panel) => {
12
+ onPanelChange(activePanel === panel ? null : panel);
13
+ };
14
+ const tabStrip = (_jsx("div", { style: {
15
+ display: 'flex',
16
+ flexDirection: 'column',
17
+ width: TAB_WIDTH,
18
+ borderLeft: position === 'right' ? '1px solid var(--ogrid-border, #e0e0e0)' : undefined,
19
+ borderRight: position === 'left' ? '1px solid var(--ogrid-border, #e0e0e0)' : undefined,
20
+ background: 'var(--ogrid-header-bg, #f5f5f5)',
21
+ }, role: "tablist", "aria-label": "Side bar tabs", children: panels.map((panel) => (_jsx("button", { role: "tab", "aria-selected": activePanel === panel, "aria-label": PANEL_LABELS[panel], onClick: () => handleTabClick(panel), title: PANEL_LABELS[panel], style: {
22
+ width: TAB_WIDTH,
23
+ height: TAB_WIDTH,
24
+ border: 'none',
25
+ cursor: 'pointer',
26
+ background: activePanel === panel ? 'var(--ogrid-bg, #fff)' : 'transparent',
27
+ color: 'var(--ogrid-fg, #242424)',
28
+ fontWeight: activePanel === panel ? 'bold' : 'normal',
29
+ fontSize: 14,
30
+ display: 'flex',
31
+ alignItems: 'center',
32
+ justifyContent: 'center',
33
+ }, children: panel === 'columns' ? '\u2261' : '\u2A65' }, panel))) }));
34
+ const panelContent = isOpen ? (_jsxs("div", { role: "tabpanel", "aria-label": PANEL_LABELS[activePanel], style: {
35
+ width: PANEL_WIDTH,
36
+ display: 'flex',
37
+ flexDirection: 'column',
38
+ borderLeft: position === 'right' ? '1px solid var(--ogrid-border, #e0e0e0)' : undefined,
39
+ borderRight: position === 'left' ? '1px solid var(--ogrid-border, #e0e0e0)' : undefined,
40
+ overflow: 'hidden',
41
+ background: 'var(--ogrid-bg, #fff)',
42
+ color: 'var(--ogrid-fg, #242424)',
43
+ }, children: [_jsxs("div", { style: {
44
+ display: 'flex',
45
+ justifyContent: 'space-between',
46
+ alignItems: 'center',
47
+ padding: '8px 12px',
48
+ borderBottom: '1px solid var(--ogrid-border, #e0e0e0)',
49
+ fontWeight: 600,
50
+ }, children: [_jsx("span", { children: PANEL_LABELS[activePanel] }), _jsx("button", { onClick: () => onPanelChange(null), style: { border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 16, color: 'var(--ogrid-fg, #242424)' }, "aria-label": "Close panel", children: "\u00D7" })] }), _jsxs("div", { style: { flex: 1, overflowY: 'auto', padding: '8px 12px' }, children: [activePanel === 'columns' && (_jsx(ColumnsPanel, { columns: columns, visibleColumns: visibleColumns, onVisibilityChange: onVisibilityChange, onSetVisibleColumns: onSetVisibleColumns })), activePanel === 'filters' && (_jsx(FiltersPanel, { filterableColumns: filterableColumns, multiSelectFilters: multiSelectFilters, textFilters: textFilters, onMultiSelectFilterChange: onMultiSelectFilterChange, onTextFilterChange: onTextFilterChange, dateFilters: dateFilters, onDateFilterChange: onDateFilterChange, filterOptions: filterOptions }))] })] })) : null;
51
+ return (_jsxs("div", { style: { display: 'flex', flexDirection: 'row', flexShrink: 0 }, role: "complementary", "aria-label": "Side bar", children: [position === 'left' && tabStrip, position === 'left' && panelContent, position === 'right' && panelContent, position === 'right' && tabStrip] }));
52
+ }
53
+ // --- Internal sub-components ---
54
+ function ColumnsPanel(props) {
55
+ const { columns, visibleColumns, onVisibilityChange, onSetVisibleColumns } = props;
56
+ const allVisible = columns.every((c) => visibleColumns.has(c.columnId));
57
+ const handleSelectAll = () => {
58
+ const next = new Set(visibleColumns);
59
+ columns.forEach((c) => next.add(c.columnId));
60
+ onSetVisibleColumns(next);
61
+ };
62
+ const handleClearAll = () => {
63
+ const next = new Set();
64
+ columns.forEach((c) => {
65
+ if (c.required && visibleColumns.has(c.columnId))
66
+ next.add(c.columnId);
67
+ });
68
+ onSetVisibleColumns(next);
69
+ };
70
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { style: { display: 'flex', gap: 8, marginBottom: 8 }, children: [_jsx("button", { onClick: handleSelectAll, disabled: allVisible, style: { flex: 1, cursor: 'pointer', background: 'var(--ogrid-bg-subtle, #f3f2f1)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4, padding: '4px 8px' }, children: "Select All" }), _jsx("button", { onClick: handleClearAll, style: { flex: 1, cursor: 'pointer', background: 'var(--ogrid-bg-subtle, #f3f2f1)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4, padding: '4px 8px' }, children: "Clear All" })] }), columns.map((col) => (_jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0', cursor: 'pointer' }, children: [_jsx("input", { type: "checkbox", checked: visibleColumns.has(col.columnId), onChange: (e) => onVisibilityChange(col.columnId, e.target.checked), disabled: col.required }), _jsx("span", { children: col.name })] }, col.columnId)))] }));
71
+ }
72
+ function FiltersPanel(props) {
73
+ const { filterableColumns, multiSelectFilters, textFilters, onMultiSelectFilterChange, onTextFilterChange, dateFilters, onDateFilterChange, filterOptions } = props;
74
+ if (filterableColumns.length === 0) {
75
+ return _jsx("div", { style: { color: 'var(--ogrid-muted, #999)', fontStyle: 'italic' }, children: "No filterable columns" });
76
+ }
77
+ return (_jsx(_Fragment, { children: filterableColumns.map((col) => {
78
+ const filterKey = col.filterField;
79
+ return (_jsxs("div", { style: { marginBottom: 12 }, children: [_jsx("div", { style: { fontWeight: 500, marginBottom: 4, fontSize: 13 }, children: col.name }), col.filterType === 'text' && (_jsx("input", { type: "text", value: textFilters[filterKey] ?? '', onChange: (e) => onTextFilterChange(filterKey, e.target.value), placeholder: `Filter ${col.name}...`, "aria-label": `Filter ${col.name}`, style: { width: '100%', boxSizing: 'border-box', padding: '4px 6px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 } })), col.filterType === 'date' && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [_jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }, children: ["From:", _jsx("input", { type: "date", value: dateFilters[filterKey]?.from ?? '', onChange: (e) => {
80
+ const from = e.target.value || undefined;
81
+ const to = dateFilters[filterKey]?.to;
82
+ onDateFilterChange(filterKey, from || to ? { from, to } : undefined);
83
+ }, "aria-label": `${col.name} from date`, style: { flex: 1, padding: '2px 4px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 } })] }), _jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }, children: ["To:", _jsx("input", { type: "date", value: dateFilters[filterKey]?.to ?? '', onChange: (e) => {
84
+ const to = e.target.value || undefined;
85
+ const from = dateFilters[filterKey]?.from;
86
+ onDateFilterChange(filterKey, from || to ? { from, to } : undefined);
87
+ }, "aria-label": `${col.name} to date`, style: { flex: 1, padding: '2px 4px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 } })] })] })), col.filterType === 'multiSelect' && (_jsx("div", { style: { maxHeight: 120, overflowY: 'auto' }, role: "group", "aria-label": `${col.name} options`, children: (filterOptions[filterKey] ?? []).map((opt) => {
88
+ const selected = (multiSelectFilters[filterKey] ?? []).includes(opt);
89
+ return (_jsxs("label", { style: { display: 'flex', alignItems: 'center', gap: 4, padding: '1px 0', cursor: 'pointer', fontSize: 13 }, children: [_jsx("input", { type: "checkbox", checked: selected, onChange: (e) => {
90
+ const current = multiSelectFilters[filterKey] ?? [];
91
+ const next = e.target.checked
92
+ ? [...current, opt]
93
+ : current.filter((v) => v !== opt);
94
+ onMultiSelectFilterChange(filterKey, next);
95
+ } }), _jsx("span", { children: opt })] }, opt));
96
+ }) }))] }, col.columnId));
97
+ }) }));
98
+ }
@@ -15,3 +15,5 @@ export { useColumnHeaderFilterState } from './useColumnHeaderFilterState';
15
15
  export { useColumnChooserState } from './useColumnChooserState';
16
16
  export { useInlineCellEditorState } from './useInlineCellEditorState';
17
17
  export { useColumnResize } from './useColumnResize';
18
+ export { useRichSelectState } from './useRichSelectState';
19
+ export { useSideBarState } from './useSideBarState';
@@ -2,6 +2,16 @@ import { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { normalizeSelectionRange } from '../types';
3
3
  /** DOM attribute name used for drag-range highlighting (bypasses React). */
4
4
  const DRAG_ATTR = 'data-drag-range';
5
+ /** Auto-scroll config */
6
+ const AUTO_SCROLL_EDGE = 40; // px from wrapper edge to trigger
7
+ const AUTO_SCROLL_MIN_SPEED = 2;
8
+ const AUTO_SCROLL_MAX_SPEED = 20;
9
+ const AUTO_SCROLL_INTERVAL = 16; // ~60fps
10
+ /** Compute scroll speed proportional to distance past the edge, capped. */
11
+ function autoScrollSpeed(distance) {
12
+ const t = Math.min(distance / AUTO_SCROLL_EDGE, 1);
13
+ return AUTO_SCROLL_MIN_SPEED + t * (AUTO_SCROLL_MAX_SPEED - AUTO_SCROLL_MIN_SPEED);
14
+ }
5
15
  export function useCellSelection(params) {
6
16
  const { colOffset, rowCount, visibleColCount, setActiveCell, wrapperRef } = params;
7
17
  const [selectionRange, setSelectionRange] = useState(null);
@@ -11,6 +21,8 @@ export function useCellSelection(params) {
11
21
  const rafRef = useRef(0);
12
22
  /** Live drag range kept in a ref — only committed to React state on mouseup. */
13
23
  const liveDragRangeRef = useRef(null);
24
+ /** Auto-scroll interval during drag. */
25
+ const autoScrollRef = useRef(null);
14
26
  const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
15
27
  // Only handle primary (left) button — let middle-click scroll and right-click context menu work natively
16
28
  if (e.button !== 0)
@@ -116,11 +128,81 @@ export function useCellSelection(params) {
116
128
  endCol: dataCol,
117
129
  });
118
130
  };
131
+ /** Start or update auto-scroll interval based on mouse position relative to wrapper edges. */
132
+ const updateAutoScroll = () => {
133
+ const wrapper = wrapperRef.current;
134
+ const pos = lastMousePosRef.current;
135
+ if (!wrapper || !pos || !isDraggingRef.current) {
136
+ stopAutoScroll();
137
+ return;
138
+ }
139
+ const rect = wrapper.getBoundingClientRect();
140
+ let dx = 0;
141
+ let dy = 0;
142
+ if (pos.cy < rect.top + AUTO_SCROLL_EDGE) {
143
+ dy = -autoScrollSpeed(rect.top + AUTO_SCROLL_EDGE - pos.cy);
144
+ }
145
+ else if (pos.cy > rect.bottom - AUTO_SCROLL_EDGE) {
146
+ dy = autoScrollSpeed(pos.cy - (rect.bottom - AUTO_SCROLL_EDGE));
147
+ }
148
+ if (pos.cx < rect.left + AUTO_SCROLL_EDGE) {
149
+ dx = -autoScrollSpeed(rect.left + AUTO_SCROLL_EDGE - pos.cx);
150
+ }
151
+ else if (pos.cx > rect.right - AUTO_SCROLL_EDGE) {
152
+ dx = autoScrollSpeed(pos.cx - (rect.right - AUTO_SCROLL_EDGE));
153
+ }
154
+ if (dx === 0 && dy === 0) {
155
+ stopAutoScroll();
156
+ return;
157
+ }
158
+ // Start interval if not already running
159
+ if (!autoScrollRef.current) {
160
+ autoScrollRef.current = setInterval(() => {
161
+ const w = wrapperRef.current;
162
+ const p = lastMousePosRef.current;
163
+ if (!w || !p || !isDraggingRef.current) {
164
+ stopAutoScroll();
165
+ return;
166
+ }
167
+ const r = w.getBoundingClientRect();
168
+ let sdx = 0;
169
+ let sdy = 0;
170
+ if (p.cy < r.top + AUTO_SCROLL_EDGE)
171
+ sdy = -autoScrollSpeed(r.top + AUTO_SCROLL_EDGE - p.cy);
172
+ else if (p.cy > r.bottom - AUTO_SCROLL_EDGE)
173
+ sdy = autoScrollSpeed(p.cy - (r.bottom - AUTO_SCROLL_EDGE));
174
+ if (p.cx < r.left + AUTO_SCROLL_EDGE)
175
+ sdx = -autoScrollSpeed(r.left + AUTO_SCROLL_EDGE - p.cx);
176
+ else if (p.cx > r.right - AUTO_SCROLL_EDGE)
177
+ sdx = autoScrollSpeed(p.cx - (r.right - AUTO_SCROLL_EDGE));
178
+ if (sdx === 0 && sdy === 0) {
179
+ stopAutoScroll();
180
+ return;
181
+ }
182
+ w.scrollTop += sdy;
183
+ w.scrollLeft += sdx;
184
+ // After scrolling, re-resolve the cell under the mouse and update drag range
185
+ const newRange = resolveRange(p.cx, p.cy);
186
+ if (newRange) {
187
+ liveDragRangeRef.current = newRange;
188
+ applyDragAttrs(newRange);
189
+ }
190
+ }, AUTO_SCROLL_INTERVAL);
191
+ }
192
+ };
193
+ const stopAutoScroll = () => {
194
+ if (autoScrollRef.current) {
195
+ clearInterval(autoScrollRef.current);
196
+ autoScrollRef.current = null;
197
+ }
198
+ };
119
199
  const onMove = (e) => {
120
200
  if (!isDraggingRef.current || !dragStartRef.current)
121
201
  return;
122
202
  // Always store latest position so mouseUp can flush if RAF hasn't executed
123
203
  lastMousePosRef.current = { cx: e.clientX, cy: e.clientY };
204
+ // Update auto-scroll based on mouse proximity to edges
205
+ updateAutoScroll();
124
206
  // Cancel previous pending frame
125
207
  if (rafRef.current)
126
208
  cancelAnimationFrame(rafRef.current);
@@ -149,6 +231,7 @@ export function useCellSelection(params) {
149
231
  const onUp = () => {
150
232
  if (!isDraggingRef.current)
151
233
  return;
234
+ stopAutoScroll();
152
235
  if (rafRef.current) {
153
236
  cancelAnimationFrame(rafRef.current);
154
237
  rafRef.current = 0;
@@ -185,6 +268,7 @@ export function useCellSelection(params) {
185
268
  window.removeEventListener('mouseup', onUp, true);
186
269
  if (rafRef.current)
187
270
  cancelAnimationFrame(rafRef.current);
271
+ stopAutoScroll();
188
272
  };
189
273
  }, [colOffset, setActiveCell, wrapperRef]);
190
274
  return {
@@ -7,7 +7,7 @@ import { useDebounce } from './useDebounce';
7
7
  const SEARCH_DEBOUNCE_MS = 150;
8
8
  const EMPTY_OPTIONS = [];
9
9
  export function useColumnHeaderFilterState(params) {
10
- const { filterType, onSort, selectedValues, onFilterChange, options, textValue = '', onTextChange, selectedUser, onUserChange, peopleSearch, } = params;
10
+ const { filterType, onSort, selectedValues, onFilterChange, options, textValue = '', onTextChange, selectedUser, onUserChange, peopleSearch, dateValue, onDateChange, } = params;
11
11
  const safeSelectedValues = selectedValues ?? EMPTY_OPTIONS;
12
12
  const safeOptions = options ?? EMPTY_OPTIONS;
13
13
  const headerRef = useRef(null);
@@ -22,12 +22,16 @@ export function useColumnHeaderFilterState(params) {
22
22
  const [peopleSuggestions, setPeopleSuggestions] = useState([]);
23
23
  const [isPeopleLoading, setIsPeopleLoading] = useState(false);
24
24
  const [peopleSearchText, setPeopleSearchText] = useState('');
25
+ const [tempDateFrom, setTempDateFrom] = useState(dateValue?.from ?? '');
26
+ const [tempDateTo, setTempDateTo] = useState(dateValue?.to ?? '');
25
27
  const [popoverPosition, setPopoverPosition] = useState(null);
26
28
  // Sync temp state when popover opens
27
29
  useEffect(() => {
28
30
  if (isFilterOpen) {
29
31
  setTempSelected(new Set(safeSelectedValues));
30
32
  setTempTextValue(textValue);
33
+ setTempDateFrom(dateValue?.from ?? '');
34
+ setTempDateTo(dateValue?.to ?? '');
31
35
  setSearchText('');
32
36
  setPeopleSearchText('');
33
37
  setPeopleSuggestions([]);
@@ -38,7 +42,7 @@ export function useColumnHeaderFilterState(params) {
38
42
  else {
39
43
  setPopoverPosition(null);
40
44
  }
41
- }, [isFilterOpen, filterType, safeSelectedValues, textValue]);
45
+ }, [isFilterOpen, filterType, safeSelectedValues, textValue, dateValue]);
42
46
  // Click outside and Escape to close
43
47
  useEffect(() => {
44
48
  if (!isFilterOpen)
@@ -144,6 +148,16 @@ export function useColumnHeaderFilterState(params) {
144
148
  onUserChange?.(user);
145
149
  setFilterOpen(false);
146
150
  }, [onUserChange]);
151
+ const handleDateApply = useCallback(() => {
152
+ const from = tempDateFrom || undefined;
153
+ const to = tempDateTo || undefined;
154
+ onDateChange?.(from || to ? { from, to } : undefined);
155
+ setFilterOpen(false);
156
+ }, [onDateChange, tempDateFrom, tempDateTo]);
157
+ const handleDateClear = useCallback(() => {
158
+ setTempDateFrom('');
159
+ setTempDateTo('');
160
+ }, []);
147
161
  const handleClearUser = useCallback(() => {
148
162
  onUserChange?.(undefined);
149
163
  setFilterOpen(false);
@@ -163,8 +177,10 @@ export function useColumnHeaderFilterState(params) {
163
177
  return !!textValue.trim();
164
178
  if (filterType === 'people')
165
179
  return !!selectedUser;
180
+ if (filterType === 'date')
181
+ return !!(dateValue?.from || dateValue?.to);
166
182
  return false;
167
- }, [filterType, safeSelectedValues, textValue, selectedUser]);
183
+ }, [filterType, safeSelectedValues, textValue, selectedUser, dateValue]);
168
184
  return {
169
185
  headerRef,
170
186
  popoverRef,
@@ -183,6 +199,10 @@ export function useColumnHeaderFilterState(params) {
183
199
  isPeopleLoading,
184
200
  peopleSearchText,
185
201
  setPeopleSearchText,
202
+ tempDateFrom,
203
+ setTempDateFrom,
204
+ tempDateTo,
205
+ setTempDateTo,
186
206
  hasActiveFilter,
187
207
  popoverPosition,
188
208
  handlers: {
@@ -192,6 +212,8 @@ export function useColumnHeaderFilterState(params) {
192
212
  handleTextClear,
193
213
  handleUserSelect,
194
214
  handleClearUser,
215
+ handleDateApply,
216
+ handleDateClear,
195
217
  handleCheckboxChange,
196
218
  handleSelectAll,
197
219
  handleClearSelection,
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef, useEffect } from 'react';
2
- export function useColumnResize({ columnSizingOverrides, setColumnSizingOverrides, minWidth = 80, defaultWidth = 120, }) {
2
+ export function useColumnResize({ columnSizingOverrides, setColumnSizingOverrides, minWidth = 80, defaultWidth = 120, onColumnResized, }) {
3
3
  const resizingRef = useRef(null);
4
4
  const handleResizeStart = useCallback((e, col) => {
5
5
  e.preventDefault();
@@ -26,8 +26,15 @@ export function useColumnResize({ columnSizingOverrides, setColumnSizingOverride
26
26
  }));
27
27
  }, [setColumnSizingOverrides, minWidth]);
28
28
  const handleResizeEnd = useCallback(() => {
29
+ if (resizingRef.current && onColumnResized) {
30
+ const { columnId } = resizingRef.current;
31
+ const width = columnSizingOverrides[columnId]?.widthPx;
32
+ if (width != null) {
33
+ onColumnResized(columnId, width);
34
+ }
35
+ }
29
36
  resizingRef.current = null;
30
- }, []);
37
+ }, [onColumnResized, columnSizingOverrides]);
31
38
  useEffect(() => {
32
39
  const handleMouseMove = (e) => handleResizeMove(e);
33
40
  const handleMouseUp = () => handleResizeEnd();
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useCallback, useEffect, useState } from 'react';
2
2
  import { flattenColumns, getDataGridStatusBarConfig } from '../utils';
3
3
  import { parseValue } from '../utils/valueParsers';
4
+ import { computeAggregations } from '../utils/aggregationUtils';
4
5
  import { useRowSelection } from './useRowSelection';
5
6
  import { useCellEditing } from './useCellEditing';
6
7
  import { useActiveCell } from './useActiveCell';
@@ -22,7 +23,7 @@ const NOOP_CTX = (_e) => { };
22
23
  */
23
24
  export function useDataGridState(params) {
24
25
  const { props, wrapperRef } = params;
25
- const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, statusBar, emptyState, editable, cellSelection: cellSelectionProp, onCellValueChanged: onCellValueChangedProp, } = props;
26
+ const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, statusBar, emptyState, editable, cellSelection: cellSelectionProp, onCellValueChanged: onCellValueChangedProp, initialColumnWidths, onColumnResized, } = props;
26
27
  const cellSelection = cellSelectionProp !== false;
27
28
  // Wrap onCellValueChanged with undo/redo tracking — all edits are recorded automatically
28
29
  const undoRedo = useUndoRedo({ onCellValueChanged: onCellValueChangedProp });
@@ -146,12 +147,25 @@ export function useDataGridState(params) {
146
147
  measure();
147
148
  return () => ro.disconnect();
148
149
  }, [wrapperRef]);
150
+ const [columnSizingOverrides, setColumnSizingOverrides] = useState(() => {
151
+ if (!initialColumnWidths)
152
+ return {};
153
+ const result = {};
154
+ for (const [id, width] of Object.entries(initialColumnWidths)) {
155
+ result[id] = { widthPx: width };
156
+ }
157
+ return result;
158
+ });
149
159
  const minTableWidth = useMemo(() => {
150
160
  const PADDING = 16;
151
161
  const checkboxW = hasCheckboxCol ? 48 : 0;
152
- return visibleCols.reduce((sum, c) => sum + (c.minWidth ?? 80) + PADDING, checkboxW);
153
- }, [visibleCols, hasCheckboxCol]);
154
- const [columnSizingOverrides, setColumnSizingOverrides] = useState({});
162
+ return visibleCols.reduce((sum, c) => {
163
+ // Use the widest explicit width: resize override > idealWidth > defaultWidth > minWidth > 80
164
+ const override = columnSizingOverrides[c.columnId];
165
+ const w = override?.widthPx ?? c.idealWidth ?? c.defaultWidth ?? c.minWidth ?? 80;
166
+ return sum + w + PADDING;
167
+ }, checkboxW);
168
+ }, [visibleCols, hasCheckboxCol, columnSizingOverrides]);
155
169
  useEffect(() => {
156
170
  const colIds = new Set(flatColumns.map((c) => c.columnId));
157
171
  setColumnSizingOverrides((prev) => {
@@ -166,11 +180,28 @@ export function useDataGridState(params) {
166
180
  return changed ? next : prev;
167
181
  });
168
182
  }, [flatColumns]);
169
- const statusBarConfig = useMemo(() => getDataGridStatusBarConfig(statusBar, items.length, selectedRowIds.size), [statusBar, items.length, selectedRowIds.size]);
183
+ const desiredTableWidth = useMemo(() => {
184
+ const PADDING = 16;
185
+ const checkboxW = hasCheckboxCol ? 48 : 0;
186
+ return visibleCols.reduce((sum, c) => {
187
+ const override = columnSizingOverrides[c.columnId];
188
+ const w = override
189
+ ? override.widthPx
190
+ : (c.idealWidth ?? c.defaultWidth ?? c.minWidth ?? 80);
191
+ return sum + Math.max(c.minWidth ?? 80, w) + PADDING;
192
+ }, checkboxW);
193
+ }, [visibleCols, columnSizingOverrides, hasCheckboxCol]);
194
+ const aggregation = useMemo(() => computeAggregations(items, visibleCols, cellSelection ? selectionRange : null), [items, visibleCols, selectionRange, cellSelection]);
195
+ const statusBarConfig = useMemo(() => {
196
+ const base = getDataGridStatusBarConfig(statusBar, items.length, selectedRowIds.size);
197
+ if (!base)
198
+ return null;
199
+ return { ...base, aggregation: aggregation ?? undefined };
200
+ }, [statusBar, items.length, selectedRowIds.size, aggregation]);
170
201
  const showEmptyInGrid = items.length === 0 && !!emptyState;
171
202
  const hasCellSelection = selectionRange != null || activeCell != null;
172
203
  // --- View-model inputs (shared across all 3 DataGridTables) ---
173
- const { sortBy, sortDirection, onColumnSort, textFilters = {}, onTextFilterChange, peopleFilters = {}, onPeopleFilterChange, peopleSearch, filterOptions, loadingFilterOptions, multiSelectFilters, onMultiSelectFilterChange, } = props;
204
+ const { sortBy, sortDirection, onColumnSort, textFilters = {}, onTextFilterChange, peopleFilters = {}, onPeopleFilterChange, peopleSearch, filterOptions, loadingFilterOptions, multiSelectFilters, onMultiSelectFilterChange, dateFilters = {}, onDateFilterChange, } = props;
174
205
  const headerFilterInput = useMemo(() => ({
175
206
  sortBy,
176
207
  sortDirection,
@@ -184,6 +215,8 @@ export function useDataGridState(params) {
184
215
  loadingFilterOptions,
185
216
  multiSelectFilters,
186
217
  onMultiSelectFilterChange,
218
+ dateFilters,
219
+ onDateFilterChange,
187
220
  }), [
188
221
  sortBy,
189
222
  sortDirection,
@@ -197,6 +230,8 @@ export function useDataGridState(params) {
197
230
  loadingFilterOptions,
198
231
  multiSelectFilters,
199
232
  onMultiSelectFilterChange,
233
+ dateFilters,
234
+ onDateFilterChange,
200
235
  ]);
201
236
  const cellDescriptorInput = useMemo(() => ({
202
237
  editingCell,
@@ -302,8 +337,10 @@ export function useDataGridState(params) {
302
337
  handleFillHandleMouseDown: cellSelection ? handleFillHandleMouseDown : NOOP,
303
338
  containerWidth,
304
339
  minTableWidth,
340
+ desiredTableWidth,
305
341
  columnSizingOverrides,
306
342
  setColumnSizingOverrides,
343
+ onColumnResized,
307
344
  headerFilterInput,
308
345
  cellDescriptorInput,
309
346
  commitCellEdit,
@@ -2,6 +2,32 @@ import { useCallback } from 'react';
2
2
  import { normalizeSelectionRange } from '../types';
3
3
  import { getCellValue } from '../utils';
4
4
  import { parseValue } from '../utils/valueParsers';
5
+ /**
6
+ * Excel-style Ctrl+Arrow: find the target position along a 1D axis.
7
+ * - Non-empty current + non-empty next → scan through non-empties, stop at last before empty/edge.
8
+ * - Otherwise → skip empties, land on next non-empty or edge.
9
+ */
10
+ function findCtrlTarget(pos, edge, step, isEmpty) {
11
+ if (pos === edge)
12
+ return pos;
13
+ const next = pos + step;
14
+ if (!isEmpty(pos) && !isEmpty(next)) {
15
+ let p = next;
16
+ while (p !== edge) {
17
+ if (isEmpty(p + step))
18
+ return p;
19
+ p += step;
20
+ }
21
+ return edge;
22
+ }
23
+ let p = next;
24
+ while (p !== edge) {
25
+ if (!isEmpty(p))
26
+ return p;
27
+ p += step;
28
+ }
29
+ return edge;
30
+ }
5
31
  export function useKeyboardNavigation(params) {
6
32
  const { items, visibleCols, colOffset, hasCheckboxCol, visibleColumnCount, activeCell, setActiveCell, selectionRange, setSelectionRange, editable, onCellValueChanged, getRowId, editingCell, setEditingCell, rowSelection, selectedRowIds, handleRowCheckboxChange, handleCopy, handleCut, handlePaste, setContextMenu, wrapperRef, onUndo, onRedo, clearClipboardRanges, } = params;
7
33
  const maxRowIndex = items.length - 1;
@@ -28,6 +54,12 @@ export function useKeyboardNavigation(params) {
28
54
  const { rowIndex, columnIndex } = activeCell;
29
55
  const dataColIndex = columnIndex - colOffset;
30
56
  const shift = e.shiftKey;
57
+ const isEmptyAt = (r, c) => {
58
+ if (r < 0 || r >= items.length || c < 0 || c >= visibleCols.length)
59
+ return true;
60
+ const v = getCellValue(items[r], visibleCols[c]);
61
+ return v == null || v === '';
62
+ };
31
63
  switch (e.key) {
32
64
  case 'c':
33
65
  if (e.ctrlKey || e.metaKey) {
@@ -55,7 +87,10 @@ export function useKeyboardNavigation(params) {
55
87
  break;
56
88
  case 'ArrowDown': {
57
89
  e.preventDefault();
58
- const newRow = Math.min(rowIndex + 1, maxRowIndex);
90
+ const ctrl = e.ctrlKey || e.metaKey;
91
+ const newRow = ctrl
92
+ ? findCtrlTarget(rowIndex, maxRowIndex, 1, (r) => isEmptyAt(r, Math.max(0, dataColIndex)))
93
+ : Math.min(rowIndex + 1, maxRowIndex);
59
94
  if (shift) {
60
95
  setSelectionRange(normalizeSelectionRange({
61
96
  startRow: selectionRange?.startRow ?? rowIndex,
@@ -77,7 +112,10 @@ export function useKeyboardNavigation(params) {
77
112
  }
78
113
  case 'ArrowUp': {
79
114
  e.preventDefault();
80
- const newRowUp = Math.max(rowIndex - 1, 0);
115
+ const ctrl = e.ctrlKey || e.metaKey;
116
+ const newRowUp = ctrl
117
+ ? findCtrlTarget(rowIndex, 0, -1, (r) => isEmptyAt(r, Math.max(0, dataColIndex)))
118
+ : Math.max(rowIndex - 1, 0);
81
119
  if (shift) {
82
120
  setSelectionRange(normalizeSelectionRange({
83
121
  startRow: selectionRange?.startRow ?? rowIndex,
@@ -99,7 +137,14 @@ export function useKeyboardNavigation(params) {
99
137
  }
100
138
  case 'ArrowRight': {
101
139
  e.preventDefault();
102
- const newCol = Math.min(columnIndex + 1, maxColIndex);
140
+ const ctrl = e.ctrlKey || e.metaKey;
141
+ let newCol;
142
+ if (ctrl && dataColIndex >= 0) {
143
+ newCol = findCtrlTarget(dataColIndex, visibleCols.length - 1, 1, (c) => isEmptyAt(rowIndex, c)) + colOffset;
144
+ }
145
+ else {
146
+ newCol = Math.min(columnIndex + 1, maxColIndex);
147
+ }
103
148
  const newDataCol = newCol - colOffset;
104
149
  if (shift) {
105
150
  setSelectionRange(normalizeSelectionRange({
@@ -122,7 +167,14 @@ export function useKeyboardNavigation(params) {
122
167
  }
123
168
  case 'ArrowLeft': {
124
169
  e.preventDefault();
125
- const newColLeft = Math.max(columnIndex - 1, colOffset);
170
+ const ctrl = e.ctrlKey || e.metaKey;
171
+ let newColLeft;
172
+ if (ctrl && dataColIndex >= 0) {
173
+ newColLeft = findCtrlTarget(dataColIndex, 0, -1, (c) => isEmptyAt(rowIndex, c)) + colOffset;
174
+ }
175
+ else {
176
+ newColLeft = Math.max(columnIndex - 1, colOffset);
177
+ }
126
178
  const newDataColLeft = newColLeft - colOffset;
127
179
  if (shift) {
128
180
  setSelectionRange(normalizeSelectionRange({