@alaarab/ogrid-core 1.3.1 → 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 (46) 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 +207 -16
  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 +44 -6
  8. package/dist/esm/hooks/useFillHandle.js +91 -10
  9. package/dist/esm/hooks/useKeyboardNavigation.js +56 -4
  10. package/dist/esm/hooks/useOGrid.js +132 -4
  11. package/dist/esm/hooks/useRichSelectState.js +53 -0
  12. package/dist/esm/hooks/useSideBarState.js +34 -0
  13. package/dist/esm/index.js +3 -2
  14. package/dist/esm/types/dataGridTypes.js +9 -2
  15. package/dist/esm/utils/aggregationUtils.js +39 -0
  16. package/dist/esm/utils/columnUtils.js +97 -0
  17. package/dist/esm/utils/dataGridViewModel.js +105 -6
  18. package/dist/esm/utils/index.js +3 -2
  19. package/dist/esm/utils/ogridHelpers.js +4 -2
  20. package/dist/esm/utils/paginationHelpers.js +1 -1
  21. package/dist/esm/utils/statusBarHelpers.js +8 -1
  22. package/dist/esm/utils/valueParsers.js +15 -1
  23. package/dist/types/components/OGridLayout.d.ts +22 -5
  24. package/dist/types/components/SideBar.d.ts +34 -0
  25. package/dist/types/components/StatusBar.d.ts +8 -0
  26. package/dist/types/hooks/index.d.ts +5 -1
  27. package/dist/types/hooks/useCellSelection.d.ts +1 -0
  28. package/dist/types/hooks/useColumnHeaderFilterState.d.ts +9 -1
  29. package/dist/types/hooks/useColumnResize.d.ts +3 -1
  30. package/dist/types/hooks/useDataGridState.d.ts +3 -0
  31. package/dist/types/hooks/useInlineCellEditorState.d.ts +1 -1
  32. package/dist/types/hooks/useOGrid.d.ts +7 -0
  33. package/dist/types/hooks/useRichSelectState.d.ts +17 -0
  34. package/dist/types/hooks/useSideBarState.d.ts +15 -0
  35. package/dist/types/index.d.ts +7 -5
  36. package/dist/types/types/columnTypes.d.ts +26 -2
  37. package/dist/types/types/dataGridTypes.d.ts +56 -6
  38. package/dist/types/types/index.d.ts +2 -2
  39. package/dist/types/utils/aggregationUtils.d.ts +15 -0
  40. package/dist/types/utils/columnUtils.d.ts +16 -1
  41. package/dist/types/utils/dataGridViewModel.d.ts +67 -2
  42. package/dist/types/utils/index.d.ts +5 -3
  43. package/dist/types/utils/ogridHelpers.d.ts +2 -2
  44. package/dist/types/utils/paginationHelpers.d.ts +1 -1
  45. package/dist/types/utils/statusBarHelpers.d.ts +8 -0
  46. 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';
@@ -1,12 +1,32 @@
1
1
  import { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { normalizeSelectionRange } from '../types';
3
+ /** DOM attribute name used for drag-range highlighting (bypasses React). */
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
+ }
3
15
  export function useCellSelection(params) {
4
- const { colOffset, rowCount, visibleColCount, setActiveCell } = params;
16
+ const { colOffset, rowCount, visibleColCount, setActiveCell, wrapperRef } = params;
5
17
  const [selectionRange, setSelectionRange] = useState(null);
6
18
  const isDraggingRef = useRef(false);
7
19
  const [isDragging, setIsDragging] = useState(false);
8
20
  const dragStartRef = useRef(null);
21
+ const rafRef = useRef(0);
22
+ /** Live drag range kept in a ref — only committed to React state on mouseup. */
23
+ const liveDragRangeRef = useRef(null);
24
+ /** Auto-scroll interval during drag. */
25
+ const autoScrollRef = useRef(null);
9
26
  const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
27
+ // Only handle primary (left) button — let middle-click scroll and right-click context menu work natively
28
+ if (e.button !== 0)
29
+ return;
10
30
  if (globalColIndex < colOffset)
11
31
  return;
12
32
  // Prevent native text selection during cell drag
@@ -23,12 +43,14 @@ export function useCellSelection(params) {
23
43
  }
24
44
  else {
25
45
  dragStartRef.current = { row: rowIndex, col: dataColIndex };
26
- setSelectionRange({
46
+ const initial = {
27
47
  startRow: rowIndex,
28
48
  startCol: dataColIndex,
29
49
  endRow: rowIndex,
30
50
  endCol: dataColIndex,
31
- });
51
+ };
52
+ setSelectionRange(initial);
53
+ liveDragRangeRef.current = initial;
32
54
  setActiveCell({ rowIndex, columnIndex: globalColIndex });
33
55
  isDraggingRef.current = true;
34
56
  setIsDragging(true);
@@ -45,41 +67,210 @@ export function useCellSelection(params) {
45
67
  });
46
68
  setActiveCell({ rowIndex: 0, columnIndex: colOffset });
47
69
  }, [rowCount, visibleColCount, colOffset, setActiveCell]);
48
- // Window mouse move/up for drag selection
70
+ /** Last known mouse position during drag — used by mouseUp to flush pending RAF work. */
71
+ const lastMousePosRef = useRef(null);
72
+ // Window mouse move/up for drag selection.
73
+ // Performance: during drag, we update a ref + toggle DOM attributes via rAF.
74
+ // React state is only committed on mouseup (single re-render instead of 60-120/s).
49
75
  useEffect(() => {
50
- const onMove = (e) => {
51
- if (!isDraggingRef.current || !dragStartRef.current)
76
+ const colOff = colOffset; // capture for closure
77
+ /** Toggle DRAG_ATTR on cell-content divs to show the range highlight via CSS. */
78
+ const applyDragAttrs = (range) => {
79
+ const wrapper = wrapperRef.current;
80
+ if (!wrapper)
81
+ return;
82
+ const minR = Math.min(range.startRow, range.endRow);
83
+ const maxR = Math.max(range.startRow, range.endRow);
84
+ const minC = Math.min(range.startCol, range.endCol);
85
+ const maxC = Math.max(range.startCol, range.endCol);
86
+ const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
87
+ for (let i = 0; i < cells.length; i++) {
88
+ const el = cells[i];
89
+ const r = parseInt(el.getAttribute('data-row-index'), 10);
90
+ const c = parseInt(el.getAttribute('data-col-index'), 10) - colOff;
91
+ const inRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
92
+ if (inRange) {
93
+ if (!el.hasAttribute(DRAG_ATTR))
94
+ el.setAttribute(DRAG_ATTR, '');
95
+ }
96
+ else {
97
+ if (el.hasAttribute(DRAG_ATTR))
98
+ el.removeAttribute(DRAG_ATTR);
99
+ }
100
+ }
101
+ };
102
+ const clearDragAttrs = () => {
103
+ const wrapper = wrapperRef.current;
104
+ if (!wrapper)
52
105
  return;
53
- const target = document.elementFromPoint(e.clientX, e.clientY);
106
+ const marked = wrapper.querySelectorAll(`[${DRAG_ATTR}]`);
107
+ for (let i = 0; i < marked.length; i++)
108
+ marked[i].removeAttribute(DRAG_ATTR);
109
+ };
110
+ /** Resolve mouse coordinates to a cell range (shared by RAF callback and mouseUp flush). */
111
+ const resolveRange = (cx, cy) => {
112
+ if (!dragStartRef.current)
113
+ return null;
114
+ const target = document.elementFromPoint(cx, cy);
54
115
  const cell = target?.closest?.('[data-row-index][data-col-index]');
55
116
  if (!cell)
56
- return;
117
+ return null;
57
118
  const r = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
58
119
  const c = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
59
- if (Number.isNaN(r) || Number.isNaN(c) || c < colOffset)
60
- return;
61
- const dataCol = c - colOffset;
120
+ if (Number.isNaN(r) || Number.isNaN(c) || c < colOff)
121
+ return null;
122
+ const dataCol = c - colOff;
62
123
  const start = dragStartRef.current;
63
- setSelectionRange(normalizeSelectionRange({
124
+ return normalizeSelectionRange({
64
125
  startRow: start.row,
65
126
  startCol: start.col,
66
127
  endRow: r,
67
128
  endCol: dataCol,
68
- }));
69
- setActiveCell({ rowIndex: r, columnIndex: c });
129
+ });
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
+ };
199
+ const onMove = (e) => {
200
+ if (!isDraggingRef.current || !dragStartRef.current)
201
+ return;
202
+ // Always store latest position so mouseUp can flush if RAF hasn't executed
203
+ lastMousePosRef.current = { cx: e.clientX, cy: e.clientY };
204
+ // Update auto-scroll based on mouse proximity to edges
205
+ updateAutoScroll();
206
+ // Cancel previous pending frame
207
+ if (rafRef.current)
208
+ cancelAnimationFrame(rafRef.current);
209
+ rafRef.current = requestAnimationFrame(() => {
210
+ rafRef.current = 0;
211
+ const pos = lastMousePosRef.current;
212
+ if (!pos)
213
+ return;
214
+ const newRange = resolveRange(pos.cx, pos.cy);
215
+ if (!newRange)
216
+ return;
217
+ // Skip if range unchanged
218
+ const prev = liveDragRangeRef.current;
219
+ if (prev &&
220
+ prev.startRow === newRange.startRow &&
221
+ prev.startCol === newRange.startCol &&
222
+ prev.endRow === newRange.endRow &&
223
+ prev.endCol === newRange.endCol) {
224
+ return;
225
+ }
226
+ liveDragRangeRef.current = newRange;
227
+ // DOM-only highlighting — no React state update until mouseup
228
+ applyDragAttrs(newRange);
229
+ });
70
230
  };
71
231
  const onUp = () => {
232
+ if (!isDraggingRef.current)
233
+ return;
234
+ stopAutoScroll();
235
+ if (rafRef.current) {
236
+ cancelAnimationFrame(rafRef.current);
237
+ rafRef.current = 0;
238
+ }
72
239
  isDraggingRef.current = false;
73
- setIsDragging(false);
240
+ // Flush: if the last RAF hasn't executed yet, resolve the range now from the
241
+ // last known mouse position so the final committed range is always accurate.
242
+ const pos = lastMousePosRef.current;
243
+ if (pos) {
244
+ const flushed = resolveRange(pos.cx, pos.cy);
245
+ if (flushed)
246
+ liveDragRangeRef.current = flushed;
247
+ }
248
+ // Commit final range to React state (triggers a single re-render)
249
+ const finalRange = liveDragRangeRef.current;
250
+ if (finalRange) {
251
+ setSelectionRange(finalRange);
252
+ setActiveCell({
253
+ rowIndex: finalRange.endRow,
254
+ columnIndex: finalRange.endCol + colOff,
255
+ });
256
+ }
257
+ // Clean up DOM attributes — React will apply CSS-module classes on the same paint
258
+ clearDragAttrs();
259
+ liveDragRangeRef.current = null;
260
+ lastMousePosRef.current = null;
74
261
  dragStartRef.current = null;
262
+ setIsDragging(false);
75
263
  };
76
264
  window.addEventListener('mousemove', onMove, true);
77
265
  window.addEventListener('mouseup', onUp, true);
78
266
  return () => {
79
267
  window.removeEventListener('mousemove', onMove, true);
80
268
  window.removeEventListener('mouseup', onUp, true);
269
+ if (rafRef.current)
270
+ cancelAnimationFrame(rafRef.current);
271
+ stopAutoScroll();
81
272
  };
82
- }, [colOffset, setActiveCell]);
273
+ }, [colOffset, setActiveCell, wrapperRef]);
83
274
  return {
84
275
  selectionRange,
85
276
  setSelectionRange,
@@ -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();