@alaarab/ogrid-react 2.0.23 → 2.1.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 (66) hide show
  1. package/dist/esm/components/ColumnHeaderFilterContent.js +0 -2
  2. package/dist/esm/components/MarchingAntsOverlay.js +2 -3
  3. package/dist/esm/components/SideBar.js +8 -7
  4. package/dist/esm/components/createOGrid.js +1 -4
  5. package/dist/esm/hooks/index.js +10 -0
  6. package/dist/esm/hooks/useActiveCell.js +2 -4
  7. package/dist/esm/hooks/useCellSelection.js +85 -52
  8. package/dist/esm/hooks/useClipboard.js +15 -54
  9. package/dist/esm/hooks/useColumnChooserState.js +25 -13
  10. package/dist/esm/hooks/useColumnHeaderFilterState.js +22 -11
  11. package/dist/esm/hooks/useColumnHeaderMenuState.js +1 -1
  12. package/dist/esm/hooks/useColumnMeta.js +61 -0
  13. package/dist/esm/hooks/useColumnPinning.js +11 -12
  14. package/dist/esm/hooks/useColumnReorder.js +8 -1
  15. package/dist/esm/hooks/useColumnResize.js +6 -2
  16. package/dist/esm/hooks/useDataGridContextMenu.js +24 -0
  17. package/dist/esm/hooks/useDataGridEditing.js +56 -0
  18. package/dist/esm/hooks/useDataGridInteraction.js +109 -0
  19. package/dist/esm/hooks/useDataGridLayout.js +172 -0
  20. package/dist/esm/hooks/useDataGridState.js +83 -318
  21. package/dist/esm/hooks/useDataGridTableOrchestration.js +2 -4
  22. package/dist/esm/hooks/useFillHandle.js +60 -55
  23. package/dist/esm/hooks/useFilterOptions.js +2 -4
  24. package/dist/esm/hooks/useInlineCellEditorState.js +7 -13
  25. package/dist/esm/hooks/useKeyboardNavigation.js +19 -132
  26. package/dist/esm/hooks/useMultiSelectFilterState.js +1 -1
  27. package/dist/esm/hooks/useOGrid.js +159 -301
  28. package/dist/esm/hooks/useOGridDataFetching.js +74 -0
  29. package/dist/esm/hooks/useOGridFilters.js +59 -0
  30. package/dist/esm/hooks/useOGridPagination.js +24 -0
  31. package/dist/esm/hooks/useOGridSorting.js +24 -0
  32. package/dist/esm/hooks/usePaginationControls.js +2 -5
  33. package/dist/esm/hooks/usePeopleFilterState.js +6 -1
  34. package/dist/esm/hooks/useRichSelectState.js +7 -5
  35. package/dist/esm/hooks/useRowSelection.js +6 -26
  36. package/dist/esm/hooks/useSelectState.js +2 -5
  37. package/dist/esm/hooks/useShallowEqualMemo.js +14 -0
  38. package/dist/esm/hooks/useTableLayout.js +3 -11
  39. package/dist/esm/hooks/useUndoRedo.js +16 -10
  40. package/dist/esm/index.js +1 -1
  41. package/dist/esm/utils/index.js +1 -1
  42. package/dist/types/components/ColumnChooserProps.d.ts +2 -0
  43. package/dist/types/components/ColumnHeaderFilterContent.d.ts +0 -2
  44. package/dist/types/hooks/index.d.ts +19 -0
  45. package/dist/types/hooks/useClipboard.d.ts +0 -1
  46. package/dist/types/hooks/useColumnChooserState.d.ts +2 -0
  47. package/dist/types/hooks/useColumnHeaderFilterState.d.ts +0 -2
  48. package/dist/types/hooks/useColumnHeaderMenuState.d.ts +0 -2
  49. package/dist/types/hooks/useColumnMeta.d.ts +34 -0
  50. package/dist/types/hooks/useDataGridContextMenu.d.ts +20 -0
  51. package/dist/types/hooks/useDataGridEditing.d.ts +39 -0
  52. package/dist/types/hooks/useDataGridInteraction.d.ts +95 -0
  53. package/dist/types/hooks/useDataGridLayout.d.ts +45 -0
  54. package/dist/types/hooks/useDataGridState.d.ts +7 -1
  55. package/dist/types/hooks/useDataGridTableOrchestration.d.ts +1 -2
  56. package/dist/types/hooks/useOGrid.d.ts +4 -2
  57. package/dist/types/hooks/useOGridDataFetching.d.ts +29 -0
  58. package/dist/types/hooks/useOGridFilters.d.ts +24 -0
  59. package/dist/types/hooks/useOGridPagination.d.ts +18 -0
  60. package/dist/types/hooks/useOGridSorting.d.ts +23 -0
  61. package/dist/types/hooks/usePaginationControls.d.ts +1 -1
  62. package/dist/types/hooks/useRichSelectState.d.ts +2 -0
  63. package/dist/types/hooks/useShallowEqualMemo.d.ts +7 -0
  64. package/dist/types/index.d.ts +2 -2
  65. package/dist/types/utils/index.d.ts +2 -2
  66. package/package.json +12 -4
@@ -5,8 +5,6 @@ DateFilterContent.displayName = 'DateFilterContent';
5
5
  export function getColumnHeaderFilterStateParams(props) {
6
6
  return {
7
7
  filterType: props.filterType,
8
- isSorted: props.isSorted ?? false,
9
- isSortedDescending: props.isSortedDescending ?? false,
10
8
  onSort: props.onSort,
11
9
  selectedValues: props.selectedValues,
12
10
  onFilterChange: props.onFilterChange,
@@ -24,8 +24,7 @@ export function MarchingAntsOverlay({ containerRef, selectionRange, copyRange, c
24
24
  }
25
25
  setSelRect(selectionRange ? measureRange(container, selectionRange, colOffset) : null);
26
26
  setClipRect(clipRange ? measureRange(container, clipRange, colOffset) : null);
27
- // eslint-disable-next-line react-hooks/exhaustive-deps
28
- }, [selectionRange, clipRange, containerRef, colOffset, items, visibleColumns, columnSizingOverrides, columnOrder]);
27
+ }, [selectionRange, clipRange, containerRef, colOffset]);
29
28
  // Inject keyframes on mount
30
29
  useEffect(() => {
31
30
  injectGlobalStyles('ogrid-marching-ants-keyframes', '@keyframes ogrid-marching-ants{to{stroke-dashoffset:-8}}');
@@ -49,7 +48,7 @@ export function MarchingAntsOverlay({ containerRef, selectionRange, copyRange, c
49
48
  cancelAnimationFrame(rafRef.current);
50
49
  ro?.disconnect();
51
50
  };
52
- }, [selectionRange, clipRange, measureAll, containerRef]);
51
+ }, [selectionRange, clipRange, measureAll, containerRef, items, visibleColumns, columnSizingOverrides, columnOrder]);
53
52
  if (!selRect && !clipRect)
54
53
  return null;
55
54
  // When clipboard range matches the selection range, hide the solid selection border
@@ -69,7 +69,7 @@ export function SideBar(props) {
69
69
  const tabStripStyle = position === 'right' ? tabStripBorderLeft : tabStripBorderRight;
70
70
  const panelContainerStyle = position === 'right' ? panelContainerBorderLeft : panelContainerBorderRight;
71
71
  const tabStrip = (_jsx("div", { style: tabStripStyle, 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: activePanel === panel ? tabButtonActive : tabButtonInactive, children: panel === 'columns' ? '\u2261' : '\u2A65' }, panel))) }));
72
- const panelContent = isOpen ? (_jsxs("div", { role: "tabpanel", "aria-label": PANEL_LABELS[activePanel], style: panelContainerStyle, children: [_jsxs("div", { style: panelHeaderStyle, children: [_jsx("span", { children: PANEL_LABELS[activePanel] }), _jsx("button", { onClick: () => onPanelChange(null), style: closeButtonStyle, "aria-label": "Close panel", children: "\u00D7" })] }), _jsxs("div", { style: panelBodyStyle, children: [activePanel === 'columns' && (_jsx(ColumnsPanel, { columns: columns, visibleColumns: visibleColumns, onVisibilityChange: onVisibilityChange, onSetVisibleColumns: onSetVisibleColumns })), activePanel === 'filters' && (_jsx(FiltersPanel, { filterableColumns: filterableColumns, filters: filters, onFilterChange: onFilterChange, filterOptions: filterOptions }))] })] })) : null;
72
+ const panelContent = isOpen && activePanel ? (_jsxs("div", { role: "tabpanel", "aria-label": PANEL_LABELS[activePanel], style: panelContainerStyle, children: [_jsxs("div", { style: panelHeaderStyle, children: [_jsx("span", { children: PANEL_LABELS[activePanel] }), _jsx("button", { onClick: () => onPanelChange(null), style: closeButtonStyle, "aria-label": "Close panel", children: "\u00D7" })] }), _jsxs("div", { style: panelBodyStyle, children: [activePanel === 'columns' && (_jsx(ColumnsPanel, { columns: columns, visibleColumns: visibleColumns, onVisibilityChange: onVisibilityChange, onSetVisibleColumns: onSetVisibleColumns })), activePanel === 'filters' && (_jsx(FiltersPanel, { filterableColumns: filterableColumns, filters: filters, onFilterChange: onFilterChange, filterOptions: filterOptions }))] })] })) : null;
73
73
  return (_jsxs("div", { style: sideBarRootStyle, role: "complementary", "aria-label": "Side bar", children: [position === 'left' && tabStrip, position === 'left' && panelContent, position === 'right' && panelContent, position === 'right' && tabStrip] }));
74
74
  }
75
75
  // --- Internal sub-components ---
@@ -98,20 +98,21 @@ function FiltersPanel(props) {
98
98
  }
99
99
  return (_jsx(_Fragment, { children: filterableColumns.map((col) => {
100
100
  const filterKey = col.filterField;
101
- return (_jsxs("div", { style: filterGroupStyle, children: [_jsx("div", { style: filterLabelStyle, children: col.name }), col.filterType === 'text' && (_jsx("input", { type: "text", value: filters[filterKey]?.type === 'text' ? filters[filterKey].value : '', onChange: (e) => onFilterChange(filterKey, e.target.value ? { type: 'text', value: e.target.value } : undefined), placeholder: `Filter ${col.name}...`, "aria-label": `Filter ${col.name}`, style: textInputStyle })), col.filterType === 'date' && (_jsxs("div", { style: dateContainerStyle, children: [_jsxs("label", { style: dateLabelStyle, children: ["From:", _jsx("input", { type: "date", value: filters[filterKey]?.type === 'date' ? (filters[filterKey].value.from ?? '') : '', onChange: (e) => {
101
+ const fv = filters[filterKey];
102
+ return (_jsxs("div", { style: filterGroupStyle, children: [_jsx("div", { style: filterLabelStyle, children: col.name }), col.filterType === 'text' && (_jsx("input", { type: "text", value: fv?.type === 'text' ? fv.value : '', onChange: (e) => onFilterChange(filterKey, e.target.value ? { type: 'text', value: e.target.value } : undefined), placeholder: `Filter ${col.name}...`, "aria-label": `Filter ${col.name}`, style: textInputStyle })), col.filterType === 'date' && (_jsxs("div", { style: dateContainerStyle, children: [_jsxs("label", { style: dateLabelStyle, children: ["From:", _jsx("input", { type: "date", value: fv?.type === 'date' ? (fv.value.from ?? '') : '', onChange: (e) => {
102
103
  const from = e.target.value || undefined;
103
- const existingValue = filters[filterKey]?.type === 'date' ? filters[filterKey].value : {};
104
+ const existingValue = fv?.type === 'date' ? fv.value : {};
104
105
  const to = existingValue.to;
105
106
  onFilterChange(filterKey, from || to ? { type: 'date', value: { from, to } } : undefined);
106
- }, "aria-label": `${col.name} from date`, style: dateInputStyle })] }), _jsxs("label", { style: dateLabelStyle, children: ["To:", _jsx("input", { type: "date", value: filters[filterKey]?.type === 'date' ? (filters[filterKey].value.to ?? '') : '', onChange: (e) => {
107
+ }, "aria-label": `${col.name} from date`, style: dateInputStyle })] }), _jsxs("label", { style: dateLabelStyle, children: ["To:", _jsx("input", { type: "date", value: fv?.type === 'date' ? (fv.value.to ?? '') : '', onChange: (e) => {
107
108
  const to = e.target.value || undefined;
108
- const existingValue = filters[filterKey]?.type === 'date' ? filters[filterKey].value : {};
109
+ const existingValue = fv?.type === 'date' ? fv.value : {};
109
110
  const from = existingValue.from;
110
111
  onFilterChange(filterKey, from || to ? { type: 'date', value: { from, to } } : undefined);
111
112
  }, "aria-label": `${col.name} to date`, style: dateInputStyle })] })] })), col.filterType === 'multiSelect' && (_jsx("div", { style: multiSelectContainerStyle, role: "group", "aria-label": `${col.name} options`, children: (filterOptions[filterKey] ?? []).map((opt) => {
112
- const selected = filters[filterKey]?.type === 'multiSelect' ? filters[filterKey].value.includes(opt) : false;
113
+ const selected = fv?.type === 'multiSelect' ? fv.value.includes(opt) : false;
113
114
  return (_jsxs("label", { style: multiSelectLabelStyle, children: [_jsx("input", { type: "checkbox", checked: selected, onChange: (e) => {
114
- const current = filters[filterKey]?.type === 'multiSelect' ? filters[filterKey].value : [];
115
+ const current = fv?.type === 'multiSelect' ? fv.value : [];
115
116
  const next = e.target.checked
116
117
  ? [...current, opt]
117
118
  : current.filter((v) => v !== opt);
@@ -12,10 +12,7 @@ export function createOGrid(components) {
12
12
  const { DataGridTable, ColumnChooser, PaginationControls, containerComponent, containerProps, } = components;
13
13
  const OGridInner = forwardRef(function OGridInner(props, ref) {
14
14
  const { dataGridProps, pagination, columnChooser, layout } = useOGrid(props, ref);
15
- return (_jsx(OGridLayout, { containerComponent: containerComponent, containerProps: containerProps, className: layout.className, sideBar: layout.sideBarProps, toolbar: layout.toolbar, toolbarBelow: layout.toolbarBelow, toolbarEnd: columnChooser.placement === 'toolbar' ? (_jsx(ColumnChooser, { columns: columnChooser.columns, visibleColumns: columnChooser.visibleColumns, onVisibilityChange: columnChooser.onVisibilityChange })) : undefined, pagination: _jsx(PaginationControls, { currentPage: pagination.page, pageSize: pagination.pageSize, totalCount: pagination.displayTotalCount, onPageChange: pagination.setPage, onPageSizeChange: (size) => {
16
- pagination.setPageSize(size);
17
- pagination.setPage(1);
18
- }, pageSizeOptions: pagination.pageSizeOptions, entityLabelPlural: pagination.entityLabelPlural }), children: _jsx(DataGridTable, { ...dataGridProps }) }));
15
+ return (_jsx(OGridLayout, { containerComponent: containerComponent, containerProps: containerProps, className: layout.className, sideBar: layout.sideBarProps, toolbar: layout.toolbar, toolbarBelow: layout.toolbarBelow, toolbarEnd: columnChooser.placement === 'toolbar' ? (_jsx(ColumnChooser, { columns: columnChooser.columns, visibleColumns: columnChooser.visibleColumns, onVisibilityChange: columnChooser.onVisibilityChange, onSetVisibleColumns: columnChooser.onSetVisibleColumns })) : undefined, pagination: _jsx(PaginationControls, { currentPage: pagination.page, pageSize: pagination.pageSize, totalCount: pagination.displayTotalCount, onPageChange: pagination.setPage, onPageSizeChange: pagination.setPageSize, pageSizeOptions: pagination.pageSizeOptions, entityLabelPlural: pagination.entityLabelPlural }), children: _jsx(DataGridTable, { ...dataGridProps }) }));
19
16
  });
20
17
  OGridInner.displayName = 'OGrid';
21
18
  return React.memo(OGridInner);
@@ -1,5 +1,9 @@
1
1
  export { useFilterOptions } from './useFilterOptions';
2
2
  export { useOGrid } from './useOGrid';
3
+ export { useOGridPagination } from './useOGridPagination';
4
+ export { useOGridSorting } from './useOGridSorting';
5
+ export { useOGridFilters as useOGridFiltersState } from './useOGridFilters';
6
+ export { useOGridDataFetching } from './useOGridDataFetching';
3
7
  export { useActiveCell } from './useActiveCell';
4
8
  export { useCellEditing } from './useCellEditing';
5
9
  export { useContextMenu } from './useContextMenu';
@@ -11,6 +15,10 @@ export { useUndoRedo } from './useUndoRedo';
11
15
  export { useDebounce } from './useDebounce';
12
16
  export { useFillHandle } from './useFillHandle';
13
17
  export { useDataGridState } from './useDataGridState';
18
+ export { useDataGridLayout } from './useDataGridLayout';
19
+ export { useDataGridEditing } from './useDataGridEditing';
20
+ export { useDataGridInteraction } from './useDataGridInteraction';
21
+ export { useDataGridContextMenu } from './useDataGridContextMenu';
14
22
  export { useColumnHeaderFilterState } from './useColumnHeaderFilterState';
15
23
  export { useTextFilterState } from './useTextFilterState';
16
24
  export { useMultiSelectFilterState } from './useMultiSelectFilterState';
@@ -29,5 +37,7 @@ export { useColumnReorder } from './useColumnReorder';
29
37
  export { useVirtualScroll } from './useVirtualScroll';
30
38
  export { useListVirtualizer } from './useListVirtualizer';
31
39
  export { useLatestRef } from './useLatestRef';
40
+ export { useShallowEqualMemo } from './useShallowEqualMemo';
32
41
  export { usePaginationControls } from './usePaginationControls';
33
42
  export { useDataGridTableOrchestration } from './useDataGridTableOrchestration';
43
+ export { useColumnMeta } from './useColumnMeta';
@@ -31,8 +31,7 @@ export function useActiveCell(wrapperRef, editingCell) {
31
31
  if (cell && document.activeElement !== cell && typeof cell.focus === 'function') {
32
32
  cell.focus({ preventScroll: true });
33
33
  }
34
- // eslint-disable-next-line react-hooks/exhaustive-deps
35
- }, [activeCell, editingCell]); // wrapperRef excluded — refs are stable across renders
34
+ }, [activeCell, editingCell, wrapperRef]);
36
35
  // Batch scroll-into-view via RAF so rapid keyboard navigation only scrolls once
37
36
  useEffect(() => {
38
37
  if (activeCell == null || wrapperRef?.current == null || editingCell != null)
@@ -67,8 +66,7 @@ export function useActiveCell(wrapperRef, editingCell) {
67
66
  }
68
67
  }
69
68
  });
70
- // eslint-disable-next-line react-hooks/exhaustive-deps
71
- }, [activeCell, editingCell]); // wrapperRef excluded — refs are stable across renders
69
+ }, [activeCell, editingCell, wrapperRef]);
72
70
  // Clean up pending RAF on unmount
73
71
  useEffect(() => {
74
72
  return () => cancelAnimationFrame(scrollRafRef.current);
@@ -78,9 +78,7 @@ export function useCellSelection(params) {
78
78
  // even before the first mousemove. This ensures instant visual feedback.
79
79
  setTimeout(() => applyDragAttrsRef.current?.(initial), 0);
80
80
  }
81
- },
82
- // eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectionRange is stable; colOffsetRef is a ref
83
- [setActiveCell]);
81
+ }, [setActiveCell, colOffsetRef, setSelectionRange]);
84
82
  const handleSelectAllCells = useCallback(() => {
85
83
  if (rowCount === 0 || visibleColCount === 0)
86
84
  return;
@@ -91,8 +89,7 @@ export function useCellSelection(params) {
91
89
  endCol: visibleColCount - 1,
92
90
  });
93
91
  setActiveCell({ rowIndex: 0, columnIndex: colOffsetRef.current });
94
- // eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectionRange is stable; colOffsetRef is a ref
95
- }, [rowCount, visibleColCount, setActiveCell]);
92
+ }, [rowCount, visibleColCount, setActiveCell, colOffsetRef, setSelectionRange]);
96
93
  /** Last known mouse position during drag — used by mouseUp to flush pending RAF work. */
97
94
  const lastMousePosRef = useRef(null);
98
95
  // Ref to expose applyDragAttrs outside useEffect so it can be called from mouseDown
@@ -101,7 +98,57 @@ export function useCellSelection(params) {
101
98
  // Performance: during drag, we update a ref + toggle DOM attributes via rAF.
102
99
  // React state is only committed on mouseup (single re-render instead of 60-120/s).
103
100
  useEffect(() => {
101
+ /** Set of currently drag-marked HTMLElements — avoids O(n) full DOM scan on each frame. */
102
+ const markedCells = new Set();
103
+ /** Cell lookup index built on drag start — O(1) lookups per frame instead of querySelectorAll. */
104
+ let cellIndex = null;
105
+ /** Build cell lookup index from a single querySelectorAll scan. */
106
+ const buildCellIndex = () => {
107
+ const wrapper = wrapperRef.current;
108
+ if (!wrapper)
109
+ return;
110
+ cellIndex = new Map();
111
+ const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
112
+ for (let i = 0; i < cells.length; i++) {
113
+ const el = cells[i];
114
+ const r = el.getAttribute('data-row-index') ?? '';
115
+ const c = el.getAttribute('data-col-index') ?? '';
116
+ cellIndex.set(`${r},${c}`, el);
117
+ }
118
+ };
119
+ /** Apply styling to a single in-range cell (attrs + box-shadow). */
120
+ const styleCellInRange = (el, r, c, minR, maxR, minC, maxC, anchor) => {
121
+ if (!el.hasAttribute(DRAG_ATTR))
122
+ el.setAttribute(DRAG_ATTR, '');
123
+ const isAnchor = anchor && r === anchor.row && c === anchor.col;
124
+ if (isAnchor) {
125
+ if (!el.hasAttribute(DRAG_ANCHOR_ATTR))
126
+ el.setAttribute(DRAG_ANCHOR_ATTR, '');
127
+ }
128
+ else {
129
+ if (el.hasAttribute(DRAG_ANCHOR_ATTR))
130
+ el.removeAttribute(DRAG_ANCHOR_ATTR);
131
+ }
132
+ const shadows = [];
133
+ if (r === minR)
134
+ shadows.push('inset 0 2px 0 0 var(--ogrid-selection, #217346)');
135
+ if (r === maxR)
136
+ shadows.push('inset 0 -2px 0 0 var(--ogrid-selection, #217346)');
137
+ if (c === minC)
138
+ shadows.push('inset 2px 0 0 0 var(--ogrid-selection, #217346)');
139
+ if (c === maxC)
140
+ shadows.push('inset -2px 0 0 0 var(--ogrid-selection, #217346)');
141
+ el.style.boxShadow = shadows.length > 0 ? shadows.join(', ') : '';
142
+ markedCells.add(el);
143
+ };
144
+ /** Remove drag styling from a single cell. */
145
+ const unstyleCell = (el) => {
146
+ el.removeAttribute(DRAG_ATTR);
147
+ el.removeAttribute(DRAG_ANCHOR_ATTR);
148
+ el.style.boxShadow = '';
149
+ };
104
150
  /** Toggle DRAG_ATTR on cells to show the range highlight via CSS.
151
+ * Uses a cell index Map for O(1) lookups per cell in the range instead of scanning all cells.
105
152
  * Also sets edge box-shadows for a green border around the selection range,
106
153
  * and marks the anchor cell with DRAG_ANCHOR_ATTR (white background). */
107
154
  const applyDragAttrs = (range) => {
@@ -113,60 +160,45 @@ export function useCellSelection(params) {
113
160
  const minC = Math.min(range.startCol, range.endCol);
114
161
  const maxC = Math.max(range.startCol, range.endCol);
115
162
  const anchor = dragStartRef.current;
116
- const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
117
- for (let i = 0; i < cells.length; i++) {
118
- const el = cells[i];
119
- const r = parseInt(el.getAttribute('data-row-index'), 10);
120
- const c = parseInt(el.getAttribute('data-col-index'), 10) - colOffsetRef.current;
121
- const inRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
122
- if (inRange) {
123
- if (!el.hasAttribute(DRAG_ATTR))
124
- el.setAttribute(DRAG_ATTR, '');
125
- // Anchor cell gets white background instead of green
126
- const isAnchor = anchor && r === anchor.row && c === anchor.col;
127
- if (isAnchor) {
128
- if (!el.hasAttribute(DRAG_ANCHOR_ATTR))
129
- el.setAttribute(DRAG_ANCHOR_ATTR, '');
163
+ const colOff = colOffsetRef.current;
164
+ // 1. Un-mark cells that are no longer in the new range (iterate the small set, not all DOM)
165
+ for (const el of markedCells) {
166
+ const r = parseInt(el.getAttribute('data-row-index') ?? '', 10);
167
+ const c = parseInt(el.getAttribute('data-col-index') ?? '', 10) - colOff;
168
+ const stillInRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
169
+ if (!stillInRange) {
170
+ unstyleCell(el);
171
+ markedCells.delete(el);
172
+ }
173
+ }
174
+ // Build index on first call if not yet initialized
175
+ if (!cellIndex)
176
+ buildCellIndex();
177
+ // 2. Look up only the cells in the new range — O(range size) via Map lookup.
178
+ for (let r = minR; r <= maxR; r++) {
179
+ for (let c = minC; c <= maxC; c++) {
180
+ const key = `${r},${c + colOff}`;
181
+ let el = cellIndex?.get(key);
182
+ // Handle virtual scroll recycling — if element is stale, rebuild index once
183
+ if (el && !el.isConnected) {
184
+ buildCellIndex();
185
+ el = cellIndex?.get(key);
130
186
  }
131
- else {
132
- if (el.hasAttribute(DRAG_ANCHOR_ATTR))
133
- el.removeAttribute(DRAG_ANCHOR_ATTR);
187
+ if (el) {
188
+ styleCellInRange(el, r, c, minR, maxR, minC, maxC, anchor);
134
189
  }
135
- // Edge borders via inset box-shadow (no layout shift)
136
- const shadows = [];
137
- if (r === minR)
138
- shadows.push('inset 0 2px 0 0 var(--ogrid-selection, #217346)');
139
- if (r === maxR)
140
- shadows.push('inset 0 -2px 0 0 var(--ogrid-selection, #217346)');
141
- if (c === minC)
142
- shadows.push('inset 2px 0 0 0 var(--ogrid-selection, #217346)');
143
- if (c === maxC)
144
- shadows.push('inset -2px 0 0 0 var(--ogrid-selection, #217346)');
145
- el.style.boxShadow = shadows.length > 0 ? shadows.join(', ') : '';
146
- }
147
- else {
148
- if (el.hasAttribute(DRAG_ATTR))
149
- el.removeAttribute(DRAG_ATTR);
150
- if (el.hasAttribute(DRAG_ANCHOR_ATTR))
151
- el.removeAttribute(DRAG_ANCHOR_ATTR);
152
- if (el.style.boxShadow)
153
- el.style.boxShadow = '';
154
190
  }
155
191
  }
156
192
  };
157
193
  // Expose applyDragAttrs via ref so mouseDown can access it
158
194
  applyDragAttrsRef.current = applyDragAttrs;
195
+ /** Clear all drag styling using the tracked set — O(marked) not O(all cells). */
159
196
  const clearDragAttrs = () => {
160
- const wrapper = wrapperRef.current;
161
- if (!wrapper)
162
- return;
163
- const marked = wrapper.querySelectorAll(`[${DRAG_ATTR}]`);
164
- for (let i = 0; i < marked.length; i++) {
165
- const el = marked[i];
166
- el.removeAttribute(DRAG_ATTR);
167
- el.removeAttribute(DRAG_ANCHOR_ATTR);
168
- el.style.boxShadow = '';
197
+ for (const el of markedCells) {
198
+ unstyleCell(el);
169
199
  }
200
+ markedCells.clear();
201
+ cellIndex = null;
170
202
  };
171
203
  /** Resolve mouse coordinates to a cell range (shared by RAF callback and mouseUp flush). */
172
204
  const resolveRange = (cx, cy) => {
@@ -266,6 +298,8 @@ export function useCellSelection(params) {
266
298
  if (!dragMovedRef.current) {
267
299
  dragMovedRef.current = true;
268
300
  setIsDragging(true);
301
+ // Build cell index once at drag start for O(1) lookups during drag
302
+ buildCellIndex();
269
303
  }
270
304
  // Always store latest position so mouseUp can flush if RAF hasn't executed
271
305
  lastMousePosRef.current = { cx: e.clientX, cy: e.clientY };
@@ -344,8 +378,7 @@ export function useCellSelection(params) {
344
378
  cancelAnimationFrame(rafRef.current);
345
379
  stopAutoScroll();
346
380
  };
347
- // eslint-disable-next-line react-hooks/exhaustive-deps
348
- }, [setActiveCell]); // wrapperRef, colOffsetRef excluded — refs are stable across renders
381
+ }, [setActiveCell, colOffsetRef, setSelectionRange, wrapperRef]);
349
382
  return {
350
383
  selectionRange,
351
384
  setSelectionRange,
@@ -1,5 +1,5 @@
1
- import { useCallback, useRef, useState } from 'react';
2
- import { getCellValue, parseValue, formatSelectionAsTsv, parseTsvClipboard } from '../utils';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { formatSelectionAsTsv, parseTsvClipboard, applyPastedValues, applyCutClear } from '../utils';
3
3
  import { normalizeSelectionRange } from '../types';
4
4
  import { useLatestRef } from './useLatestRef';
5
5
  /**
@@ -21,6 +21,9 @@ export function useClipboard(params) {
21
21
  const [copyRange, setCopyRange] = useState(null);
22
22
  /** In-page clipboard fallback when system clipboard is unavailable. */
23
23
  const internalClipboardRef = useRef(null);
24
+ /** Guard against async clipboard reads completing after unmount. */
25
+ const isMountedRef = useRef(true);
26
+ useEffect(() => () => { isMountedRef.current = false; }, []);
24
27
  /** Resolve current effective range from selection or active cell. */
25
28
  const getEffectiveRange = useCallback(() => {
26
29
  const sel = selectionRangeRef.current;
@@ -66,6 +69,9 @@ export function useClipboard(params) {
66
69
  catch {
67
70
  text = '';
68
71
  }
72
+ // Bail out if component unmounted during async clipboard read
73
+ if (!isMountedRef.current)
74
+ return;
69
75
  if (!text.trim() && internalClipboardRef.current != null) {
70
76
  text = internalClipboardRef.current;
71
77
  }
@@ -78,58 +84,13 @@ export function useClipboard(params) {
78
84
  const visibleCols = visibleColsRef.current;
79
85
  const parsedRows = parseTsvClipboard(text);
80
86
  beginBatch?.();
81
- for (let r = 0; r < parsedRows.length; r++) {
82
- const cells = parsedRows[r];
83
- for (let c = 0; c < cells.length; c++) {
84
- const targetRow = anchorRow + r;
85
- const targetCol = anchorCol + c;
86
- if (targetRow >= items.length || targetCol >= visibleCols.length)
87
- continue;
88
- const item = items[targetRow];
89
- const col = visibleCols[targetCol];
90
- const colEditable = col.editable === true ||
91
- (typeof col.editable === 'function' && col.editable(item));
92
- if (!colEditable)
93
- continue;
94
- const rawValue = cells[c] ?? '';
95
- const oldValue = getCellValue(item, col);
96
- const result = parseValue(rawValue, oldValue, item, col);
97
- if (!result.valid)
98
- continue;
99
- onCellValueChanged({
100
- item,
101
- columnId: col.columnId,
102
- oldValue,
103
- newValue: result.value,
104
- rowIndex: targetRow,
105
- });
106
- }
107
- }
87
+ const pasteEvents = applyPastedValues(parsedRows, anchorRow, anchorCol, items, visibleCols);
88
+ for (const evt of pasteEvents)
89
+ onCellValueChanged(evt);
108
90
  if (cutRangeRef.current) {
109
- const cut = cutRangeRef.current;
110
- for (let r = cut.startRow; r <= cut.endRow; r++) {
111
- for (let c = cut.startCol; c <= cut.endCol; c++) {
112
- if (r >= items.length || c >= visibleCols.length)
113
- continue;
114
- const item = items[r];
115
- const col = visibleCols[c];
116
- const colEditable = col.editable === true ||
117
- (typeof col.editable === 'function' && col.editable(item));
118
- if (!colEditable)
119
- continue;
120
- const oldValue = getCellValue(item, col);
121
- const result = parseValue('', oldValue, item, col);
122
- if (!result.valid)
123
- continue;
124
- onCellValueChanged({
125
- item,
126
- columnId: col.columnId,
127
- oldValue,
128
- newValue: result.value,
129
- rowIndex: r,
130
- });
131
- }
132
- }
91
+ const cutEvents = applyCutClear(cutRangeRef.current, items, visibleCols);
92
+ for (const evt of cutEvents)
93
+ onCellValueChanged(evt);
133
94
  cutRangeRef.current = null;
134
95
  setCutRange(null);
135
96
  }
@@ -141,5 +102,5 @@ export function useClipboard(params) {
141
102
  setCutRange(null);
142
103
  cutRangeRef.current = null;
143
104
  }, []);
144
- return { handleCopy, handleCut, handlePaste, cutRangeRef, cutRange, copyRange, clearClipboardRanges };
105
+ return { handleCopy, handleCut, handlePaste, cutRange, copyRange, clearClipboardRanges };
145
106
  }
@@ -9,7 +9,7 @@ import { useState, useCallback, useEffect } from 'react';
9
9
  * visibleCount, totalCount. UI renders trigger + popover and wires handlers.
10
10
  */
11
11
  export function useColumnChooserState(params) {
12
- const { columns, visibleColumns, onVisibilityChange } = params;
12
+ const { columns, visibleColumns, onVisibilityChange, onSetVisibleColumns } = params;
13
13
  const [open, setOpen] = useState(false);
14
14
  useEffect(() => {
15
15
  if (!open)
@@ -33,19 +33,31 @@ export function useColumnChooserState(params) {
33
33
  onVisibilityChange(columnKey, visible);
34
34
  }, [onVisibilityChange]);
35
35
  const handleSelectAll = useCallback(() => {
36
- columns.forEach((col) => {
37
- if (!visibleColumns.has(col.columnId)) {
38
- onVisibilityChange(col.columnId, true);
39
- }
40
- });
41
- }, [columns, visibleColumns, onVisibilityChange]);
36
+ if (onSetVisibleColumns) {
37
+ onSetVisibleColumns(new Set(columns.map((col) => col.columnId)));
38
+ }
39
+ else {
40
+ columns.forEach((col) => {
41
+ if (!visibleColumns.has(col.columnId)) {
42
+ onVisibilityChange(col.columnId, true);
43
+ }
44
+ });
45
+ }
46
+ }, [columns, visibleColumns, onVisibilityChange, onSetVisibleColumns]);
42
47
  const handleClearAll = useCallback(() => {
43
- columns.forEach((col) => {
44
- if (!col.required && visibleColumns.has(col.columnId)) {
45
- onVisibilityChange(col.columnId, false);
46
- }
47
- });
48
- }, [columns, visibleColumns, onVisibilityChange]);
48
+ if (onSetVisibleColumns) {
49
+ // Keep required columns visible
50
+ const required = new Set(columns.filter((col) => col.required).map((col) => col.columnId));
51
+ onSetVisibleColumns(required);
52
+ }
53
+ else {
54
+ columns.forEach((col) => {
55
+ if (!col.required && visibleColumns.has(col.columnId)) {
56
+ onVisibilityChange(col.columnId, false);
57
+ }
58
+ });
59
+ }
60
+ }, [columns, visibleColumns, onVisibilityChange, onSetVisibleColumns]);
49
61
  const visibleCount = visibleColumns.size;
50
62
  const totalCount = columns.length;
51
63
  return {
@@ -91,27 +91,38 @@ export function useColumnHeaderFilterState(params) {
91
91
  e.stopPropagation();
92
92
  onSort?.();
93
93
  }, [onSort]);
94
+ // Destructure stable callbacks from sub-hooks before using as deps
95
+ const { handleApplyMultiSelect: _applyMultiSelect } = multiSelectFilterState;
96
+ const { handleTextApply: _textApply, handleTextClear: _textClear } = textFilterState;
97
+ const { handleUserSelect: _userSelect, handleClearUser: _clearUser } = peopleFilterState;
98
+ const { handleDateApply: _dateApply } = dateFilterState;
94
99
  // Wrap sub-hook handlers to close popover
95
100
  const handleApplyMultiSelect = useCallback(() => {
96
- multiSelectFilterState.handleApplyMultiSelect();
101
+ _applyMultiSelect();
97
102
  setFilterOpen(false);
98
- }, [multiSelectFilterState]);
103
+ }, [_applyMultiSelect]);
99
104
  const handleTextApply = useCallback(() => {
100
- textFilterState.handleTextApply();
105
+ _textApply();
101
106
  setFilterOpen(false);
102
- }, [textFilterState]);
107
+ }, [_textApply]);
108
+ // Clear immediately commits an empty value and closes the popover (no 2-step clear required)
109
+ const handleTextClear = useCallback(() => {
110
+ _textClear();
111
+ onTextChange?.('');
112
+ setFilterOpen(false);
113
+ }, [_textClear, onTextChange]);
103
114
  const handleUserSelect = useCallback((user) => {
104
- peopleFilterState.handleUserSelect(user);
115
+ _userSelect(user);
105
116
  setFilterOpen(false);
106
- }, [peopleFilterState]);
117
+ }, [_userSelect]);
107
118
  const handleClearUser = useCallback(() => {
108
- peopleFilterState.handleClearUser();
119
+ _clearUser();
109
120
  setFilterOpen(false);
110
- }, [peopleFilterState]);
121
+ }, [_clearUser]);
111
122
  const handleDateApply = useCallback(() => {
112
- dateFilterState.handleDateApply();
123
+ _dateApply();
113
124
  setFilterOpen(false);
114
- }, [dateFilterState]);
125
+ }, [_dateApply]);
115
126
  // Event propagation stoppers
116
127
  const handlePopoverClick = useCallback((e) => e.stopPropagation(), []);
117
128
  const handleInputFocus = useCallback((e) => e.stopPropagation(), []);
@@ -161,7 +172,7 @@ export function useColumnHeaderFilterState(params) {
161
172
  handleFilterIconClick,
162
173
  handleApplyMultiSelect,
163
174
  handleTextApply,
164
- handleTextClear: textFilterState.handleTextClear,
175
+ handleTextClear,
165
176
  handleUserSelect,
166
177
  handleClearUser,
167
178
  handleDateApply,
@@ -5,7 +5,7 @@ import { measureColumnContentWidth } from '../utils';
5
5
  * Tracks which column's menu is open, anchor element, and action handlers.
6
6
  */
7
7
  export function useColumnHeaderMenuState(params) {
8
- const { pinnedColumns, onPinColumn, onUnpinColumn, sortBy, sortDirection, onColumnSort, onColumnResized, onAutosizeColumn, columns, data: _data, getRowId: _getRowId, } = params;
8
+ const { pinnedColumns, onPinColumn, onUnpinColumn, sortBy, sortDirection, onColumnSort, onColumnResized, onAutosizeColumn, columns, } = params;
9
9
  const [isOpen, setIsOpen] = useState(false);
10
10
  const [openForColumn, setOpenForColumn] = useState(null);
11
11
  const [anchorElement, setAnchorElement] = useState(null);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared hook that pre-computes per-column styles and class names for DataGridTable.
3
+ * Extracted from Radix/Fluent/Material DataGridTable to avoid duplication.
4
+ *
5
+ * @param params.addStickyPosition - When true, adds `position: 'sticky'` inline for pinned columns.
6
+ * This is needed by Fluent UI whose `TableCell` injects atomic `position: relative` via CSS-in-JS,
7
+ * overriding the shared `.pinnedColLeft { position: sticky }` class. Radix/Material don't need it.
8
+ */
9
+ import { useMemo } from 'react';
10
+ import { DEFAULT_MIN_COLUMN_WIDTH } from '@alaarab/ogrid-core';
11
+ /**
12
+ * Computes per-column styles and class names once per render, avoiding per-cell object creation.
13
+ */
14
+ export function useColumnMeta(params) {
15
+ const { visibleCols, getColumnWidth, columnSizingOverrides, measuredColumnWidths, pinnedColumns, leftOffsets, rightOffsets, pinnedColLeftClass, pinnedColRightClass, addStickyPosition = false, } = params;
16
+ return useMemo(() => {
17
+ const cellStyles = {};
18
+ const cellClasses = {};
19
+ const hdrStyles = {};
20
+ const hdrClasses = {};
21
+ for (let i = 0; i < visibleCols.length; i++) {
22
+ const col = visibleCols[i];
23
+ const columnWidth = getColumnWidth(col);
24
+ const hasExplicitWidth = !!(columnSizingOverrides[col.columnId] || col.idealWidth != null || col.defaultWidth != null);
25
+ const isPinnedLeft = pinnedColumns[col.columnId] === 'left';
26
+ const isPinnedRight = pinnedColumns[col.columnId] === 'right';
27
+ const isPinned = isPinnedLeft || isPinnedRight;
28
+ const hasResizeOverride = !!columnSizingOverrides[col.columnId];
29
+ const measuredW = measuredColumnWidths[col.columnId];
30
+ const baseMinWidth = col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH;
31
+ const effectiveMinWidth = hasResizeOverride ? columnWidth : Math.max(baseMinWidth, measuredW ?? 0);
32
+ const stickyOverride = addStickyPosition && isPinned ? { position: 'sticky' } : undefined;
33
+ cellStyles[col.columnId] = {
34
+ minWidth: effectiveMinWidth,
35
+ width: hasExplicitWidth ? columnWidth : undefined,
36
+ maxWidth: hasExplicitWidth ? columnWidth : undefined,
37
+ textAlign: col.type === 'numeric' ? 'right' : col.type === 'boolean' ? 'center' : undefined,
38
+ ...stickyOverride,
39
+ ...(isPinnedLeft && leftOffsets[col.columnId] != null ? { left: leftOffsets[col.columnId] } : undefined),
40
+ ...(isPinnedRight && rightOffsets[col.columnId] != null ? { right: rightOffsets[col.columnId] } : undefined),
41
+ };
42
+ hdrStyles[col.columnId] = {
43
+ minWidth: effectiveMinWidth,
44
+ width: hasExplicitWidth ? columnWidth : undefined,
45
+ maxWidth: hasExplicitWidth ? columnWidth : undefined,
46
+ ...stickyOverride,
47
+ ...(isPinnedLeft && leftOffsets[col.columnId] != null ? { left: leftOffsets[col.columnId] } : undefined),
48
+ ...(isPinnedRight && rightOffsets[col.columnId] != null ? { right: rightOffsets[col.columnId] } : undefined),
49
+ };
50
+ const parts = [];
51
+ if (isPinnedLeft)
52
+ parts.push(pinnedColLeftClass);
53
+ if (isPinnedRight)
54
+ parts.push(pinnedColRightClass);
55
+ const cn = parts.join(' ');
56
+ cellClasses[col.columnId] = cn;
57
+ hdrClasses[col.columnId] = cn;
58
+ }
59
+ return { cellStyles, cellClasses, hdrStyles, hdrClasses };
60
+ }, [visibleCols, getColumnWidth, columnSizingOverrides, measuredColumnWidths, pinnedColumns, leftOffsets, rightOffsets, pinnedColLeftClass, pinnedColRightClass, addStickyPosition]);
61
+ }