@alaarab/ogrid-react 2.0.2 → 2.0.4

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.
@@ -87,7 +87,11 @@ export function BaseInlineCellEditor(props) {
87
87
  });
88
88
  React.useEffect(() => {
89
89
  const input = wrapperRef.current?.querySelector('input');
90
- input?.focus();
90
+ if (input) {
91
+ input.focus();
92
+ // Select all text for easy replacement (like Excel)
93
+ input.select();
94
+ }
91
95
  }, []);
92
96
  // Rich select (shared across all frameworks)
93
97
  if (editorType === 'richSelect') {
@@ -19,7 +19,11 @@ export { useDateFilterState } from './useDateFilterState';
19
19
  export { useColumnChooserState } from './useColumnChooserState';
20
20
  export { useInlineCellEditorState } from './useInlineCellEditorState';
21
21
  export { useColumnResize } from './useColumnResize';
22
+ export { useColumnPinning } from './useColumnPinning';
23
+ export { useColumnHeaderMenuState } from './useColumnHeaderMenuState';
22
24
  export { useRichSelectState } from './useRichSelectState';
23
25
  export { useSideBarState } from './useSideBarState';
24
26
  export { useTableLayout } from './useTableLayout';
27
+ export { useColumnReorder } from './useColumnReorder';
28
+ export { useVirtualScroll } from './useVirtualScroll';
25
29
  export { useLatestRef } from './useLatestRef';
@@ -9,8 +9,9 @@ function rangesEqual(a, b) {
9
9
  return a.startRow === b.startRow && a.endRow === b.endRow &&
10
10
  a.startCol === b.startCol && a.endCol === b.endCol;
11
11
  }
12
- /** DOM attribute name used for drag-range highlighting (bypasses React). */
12
+ /** DOM attribute names used for drag-range highlighting (bypasses React). */
13
13
  const DRAG_ATTR = 'data-drag-range';
14
+ const DRAG_ANCHOR_ATTR = 'data-drag-anchor';
14
15
  /** Auto-scroll config */
15
16
  const AUTO_SCROLL_EDGE = 40; // px from wrapper edge to trigger
16
17
  const AUTO_SCROLL_MIN_SPEED = 2;
@@ -84,6 +85,9 @@ export function useCellSelection(params) {
84
85
  // setIsDragging(true) is deferred to the first mousemove to avoid
85
86
  // a true→false toggle on simple clicks (which causes 2 extra renders).
86
87
  isDraggingRef.current = true;
88
+ // Apply drag attrs immediately for the initial cell so the anchor styling shows
89
+ // even before the first mousemove. This ensures instant visual feedback.
90
+ setTimeout(() => applyDragAttrsRef.current?.(initial), 0);
87
91
  }
88
92
  },
89
93
  // eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectionRange is a stable callback
@@ -102,12 +106,16 @@ export function useCellSelection(params) {
102
106
  }, [rowCount, visibleColCount, colOffset, setActiveCell]);
103
107
  /** Last known mouse position during drag — used by mouseUp to flush pending RAF work. */
104
108
  const lastMousePosRef = useRef(null);
109
+ // Ref to expose applyDragAttrs outside useEffect so it can be called from mouseDown
110
+ const applyDragAttrsRef = useRef(null);
105
111
  // Window mouse move/up for drag selection.
106
112
  // Performance: during drag, we update a ref + toggle DOM attributes via rAF.
107
113
  // React state is only committed on mouseup (single re-render instead of 60-120/s).
108
114
  useEffect(() => {
109
115
  const colOff = colOffset; // capture for closure
110
- /** Toggle DRAG_ATTR on cell-content divs to show the range highlight via CSS. */
116
+ /** Toggle DRAG_ATTR on cells to show the range highlight via CSS.
117
+ * Also sets edge box-shadows for a green border around the selection range,
118
+ * and marks the anchor cell with DRAG_ANCHOR_ATTR (white background). */
111
119
  const applyDragAttrs = (range) => {
112
120
  const wrapper = wrapperRef.current;
113
121
  if (!wrapper)
@@ -116,6 +124,7 @@ export function useCellSelection(params) {
116
124
  const maxR = Math.max(range.startRow, range.endRow);
117
125
  const minC = Math.min(range.startCol, range.endCol);
118
126
  const maxC = Math.max(range.startCol, range.endCol);
127
+ const anchor = dragStartRef.current;
119
128
  const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
120
129
  for (let i = 0; i < cells.length; i++) {
121
130
  const el = cells[i];
@@ -125,20 +134,51 @@ export function useCellSelection(params) {
125
134
  if (inRange) {
126
135
  if (!el.hasAttribute(DRAG_ATTR))
127
136
  el.setAttribute(DRAG_ATTR, '');
137
+ // Anchor cell gets white background instead of green
138
+ const isAnchor = anchor && r === anchor.row && c === anchor.col;
139
+ if (isAnchor) {
140
+ if (!el.hasAttribute(DRAG_ANCHOR_ATTR))
141
+ el.setAttribute(DRAG_ANCHOR_ATTR, '');
142
+ }
143
+ else {
144
+ if (el.hasAttribute(DRAG_ANCHOR_ATTR))
145
+ el.removeAttribute(DRAG_ANCHOR_ATTR);
146
+ }
147
+ // Edge borders via inset box-shadow (no layout shift)
148
+ const shadows = [];
149
+ if (r === minR)
150
+ shadows.push('inset 0 2px 0 0 var(--ogrid-selection, #217346)');
151
+ if (r === maxR)
152
+ shadows.push('inset 0 -2px 0 0 var(--ogrid-selection, #217346)');
153
+ if (c === minC)
154
+ shadows.push('inset 2px 0 0 0 var(--ogrid-selection, #217346)');
155
+ if (c === maxC)
156
+ shadows.push('inset -2px 0 0 0 var(--ogrid-selection, #217346)');
157
+ el.style.boxShadow = shadows.length > 0 ? shadows.join(', ') : '';
128
158
  }
129
159
  else {
130
160
  if (el.hasAttribute(DRAG_ATTR))
131
161
  el.removeAttribute(DRAG_ATTR);
162
+ if (el.hasAttribute(DRAG_ANCHOR_ATTR))
163
+ el.removeAttribute(DRAG_ANCHOR_ATTR);
164
+ if (el.style.boxShadow)
165
+ el.style.boxShadow = '';
132
166
  }
133
167
  }
134
168
  };
169
+ // Expose applyDragAttrs via ref so mouseDown can access it
170
+ applyDragAttrsRef.current = applyDragAttrs;
135
171
  const clearDragAttrs = () => {
136
172
  const wrapper = wrapperRef.current;
137
173
  if (!wrapper)
138
174
  return;
139
175
  const marked = wrapper.querySelectorAll(`[${DRAG_ATTR}]`);
140
- for (let i = 0; i < marked.length; i++)
141
- marked[i].removeAttribute(DRAG_ATTR);
176
+ for (let i = 0; i < marked.length; i++) {
177
+ const el = marked[i];
178
+ el.removeAttribute(DRAG_ATTR);
179
+ el.removeAttribute(DRAG_ANCHOR_ATTR);
180
+ el.style.boxShadow = '';
181
+ }
142
182
  };
143
183
  /** Resolve mouse coordinates to a cell range (shared by RAF callback and mouseUp flush). */
144
184
  const resolveRange = (cx, cy) => {
@@ -0,0 +1,56 @@
1
+ import { useState, useCallback } from 'react';
2
+ /**
3
+ * Manages state for the column header menu (pin left/right/unpin actions).
4
+ * Tracks which column's menu is open, anchor element, and action handlers.
5
+ */
6
+ export function useColumnHeaderMenuState(params) {
7
+ const { pinnedColumns, onPinColumn, onUnpinColumn } = params;
8
+ const [isOpen, setIsOpen] = useState(false);
9
+ const [openForColumn, setOpenForColumn] = useState(null);
10
+ const [anchorElement, setAnchorElement] = useState(null);
11
+ const open = useCallback((columnId, anchorEl) => {
12
+ setOpenForColumn(columnId);
13
+ setAnchorElement(anchorEl);
14
+ setIsOpen(true);
15
+ }, []);
16
+ const close = useCallback(() => {
17
+ setIsOpen(false);
18
+ setOpenForColumn(null);
19
+ setAnchorElement(null);
20
+ }, []);
21
+ const currentPinState = openForColumn ? pinnedColumns[openForColumn] : undefined;
22
+ const canPinLeft = currentPinState !== 'left';
23
+ const canPinRight = currentPinState !== 'right';
24
+ const canUnpin = !!currentPinState;
25
+ const handlePinLeft = useCallback(() => {
26
+ if (openForColumn && canPinLeft) {
27
+ onPinColumn(openForColumn, 'left');
28
+ close();
29
+ }
30
+ }, [openForColumn, canPinLeft, onPinColumn, close]);
31
+ const handlePinRight = useCallback(() => {
32
+ if (openForColumn && canPinRight) {
33
+ onPinColumn(openForColumn, 'right');
34
+ close();
35
+ }
36
+ }, [openForColumn, canPinRight, onPinColumn, close]);
37
+ const handleUnpin = useCallback(() => {
38
+ if (openForColumn && canUnpin) {
39
+ onUnpinColumn(openForColumn);
40
+ close();
41
+ }
42
+ }, [openForColumn, canUnpin, onUnpinColumn, close]);
43
+ return {
44
+ isOpen,
45
+ openForColumn,
46
+ anchorElement,
47
+ open,
48
+ close,
49
+ handlePinLeft,
50
+ handlePinRight,
51
+ handleUnpin,
52
+ canPinLeft,
53
+ canPinRight,
54
+ canUnpin,
55
+ };
56
+ }
@@ -0,0 +1,67 @@
1
+ import { useState, useCallback, useMemo } from 'react';
2
+ /**
3
+ * Manages column pinning state (left/right sticky positioning).
4
+ * Supports controlled and uncontrolled modes.
5
+ * Initializes from column.pinned definitions and pinnedColumns prop.
6
+ */
7
+ export function useColumnPinning(params) {
8
+ const { columns, pinnedColumns: controlledPinnedColumns, onColumnPinned } = params;
9
+ // Initialize internal state from column.pinned definitions
10
+ const initialPinnedColumns = useMemo(() => {
11
+ const initial = {};
12
+ for (const col of columns) {
13
+ if (col.pinned) {
14
+ initial[col.columnId] = col.pinned;
15
+ }
16
+ }
17
+ return initial;
18
+ }, []); // Only on mount
19
+ const [internalPinnedColumns, setInternalPinnedColumns] = useState(initialPinnedColumns);
20
+ // Use controlled state if provided, otherwise internal
21
+ const pinnedColumns = controlledPinnedColumns ?? internalPinnedColumns;
22
+ const pinColumn = useCallback((columnId, side) => {
23
+ const next = { ...pinnedColumns, [columnId]: side };
24
+ setInternalPinnedColumns(next);
25
+ onColumnPinned?.(columnId, side);
26
+ }, [pinnedColumns, onColumnPinned]);
27
+ const unpinColumn = useCallback((columnId) => {
28
+ const next = { ...pinnedColumns };
29
+ delete next[columnId];
30
+ setInternalPinnedColumns(next);
31
+ onColumnPinned?.(columnId, null);
32
+ }, [pinnedColumns, onColumnPinned]);
33
+ const isPinned = useCallback((columnId) => {
34
+ return pinnedColumns[columnId];
35
+ }, [pinnedColumns]);
36
+ const computeLeftOffsets = useCallback((visibleCols, columnWidths, defaultWidth, hasCheckboxColumn, checkboxColumnWidth) => {
37
+ const offsets = {};
38
+ let left = hasCheckboxColumn ? checkboxColumnWidth : 0;
39
+ for (const col of visibleCols) {
40
+ if (pinnedColumns[col.columnId] === 'left') {
41
+ offsets[col.columnId] = left;
42
+ left += columnWidths[col.columnId] ?? defaultWidth;
43
+ }
44
+ }
45
+ return offsets;
46
+ }, [pinnedColumns]);
47
+ const computeRightOffsets = useCallback((visibleCols, columnWidths, defaultWidth) => {
48
+ const offsets = {};
49
+ let right = 0;
50
+ for (let i = visibleCols.length - 1; i >= 0; i--) {
51
+ const col = visibleCols[i];
52
+ if (pinnedColumns[col.columnId] === 'right') {
53
+ offsets[col.columnId] = right;
54
+ right += columnWidths[col.columnId] ?? defaultWidth;
55
+ }
56
+ }
57
+ return offsets;
58
+ }, [pinnedColumns]);
59
+ return {
60
+ pinnedColumns,
61
+ pinColumn,
62
+ unpinColumn,
63
+ isPinned,
64
+ computeLeftOffsets,
65
+ computeRightOffsets,
66
+ };
67
+ }
@@ -0,0 +1,136 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+ import { calculateDropTarget, reorderColumnArray, getPinStateForColumn, } from '@alaarab/ogrid-core';
3
+ /** Width of the resize handle zone on the right edge of each header cell. */
4
+ const RESIZE_HANDLE_ZONE = 8;
5
+ /**
6
+ * Convert Record<string, 'left' | 'right'> to the { left?, right? } shape core expects.
7
+ */
8
+ function toPinnedColumnsShape(pinned) {
9
+ if (!pinned)
10
+ return undefined;
11
+ const left = [];
12
+ const right = [];
13
+ for (const [id, side] of Object.entries(pinned)) {
14
+ if (side === 'left')
15
+ left.push(id);
16
+ else if (side === 'right')
17
+ right.push(id);
18
+ }
19
+ if (left.length === 0 && right.length === 0)
20
+ return undefined;
21
+ return {
22
+ ...(left.length > 0 ? { left } : {}),
23
+ ...(right.length > 0 ? { right } : {}),
24
+ };
25
+ }
26
+ /**
27
+ * Manages column reorder drag interactions with RAF-throttled updates.
28
+ * @param params - Columns, order, change callback, enabled flag, and wrapper ref.
29
+ * @returns Drag state and mousedown handler for header cells.
30
+ */
31
+ export function useColumnReorder(params) {
32
+ const { columns, columnOrder, onColumnOrderChange, enabled = true, pinnedColumns, wrapperRef, } = params;
33
+ const [isDragging, setIsDragging] = useState(false);
34
+ const [dropIndicatorX, setDropIndicatorX] = useState(null);
35
+ const rafRef = useRef(0);
36
+ // Refs for latest values so the window listeners capture current state
37
+ const columnsRef = useRef(columns);
38
+ columnsRef.current = columns;
39
+ const columnOrderRef = useRef(columnOrder);
40
+ columnOrderRef.current = columnOrder;
41
+ const onColumnOrderChangeRef = useRef(onColumnOrderChange);
42
+ onColumnOrderChangeRef.current = onColumnOrderChange;
43
+ const pinnedColumnsRef = useRef(pinnedColumns);
44
+ pinnedColumnsRef.current = pinnedColumns;
45
+ // Track active drag state for cleanup on unmount
46
+ const cleanupRef = useRef(null);
47
+ useEffect(() => {
48
+ return () => {
49
+ if (cleanupRef.current) {
50
+ cleanupRef.current();
51
+ cleanupRef.current = null;
52
+ }
53
+ };
54
+ }, []);
55
+ const handleHeaderMouseDown = useCallback((columnId, event) => {
56
+ if (!enabled)
57
+ return;
58
+ if (!onColumnOrderChangeRef.current)
59
+ return;
60
+ // Gate on left-click only
61
+ if (event.button !== 0)
62
+ return;
63
+ // Skip if in resize handle zone (right 8px of the header cell)
64
+ const target = event.currentTarget;
65
+ const rect = target.getBoundingClientRect();
66
+ if (event.clientX > rect.right - RESIZE_HANDLE_ZONE)
67
+ return;
68
+ // Skip column groups — only reorder leaf columns
69
+ const cols = columnsRef.current;
70
+ const colIndex = cols.findIndex((c) => c.columnId === columnId);
71
+ if (colIndex === -1)
72
+ return;
73
+ event.preventDefault();
74
+ const startX = event.clientX;
75
+ let hasMoved = false;
76
+ let latestDropTargetIndex = null;
77
+ // Determine pin state of the dragged column
78
+ const pinnedShape = toPinnedColumnsShape(pinnedColumnsRef.current);
79
+ const draggedPinState = getPinStateForColumn(columnId, pinnedShape);
80
+ // Lock text selection and set grabbing cursor during drag
81
+ const prevUserSelect = document.body.style.userSelect;
82
+ const prevCursor = document.body.style.cursor;
83
+ document.body.style.userSelect = 'none';
84
+ document.body.style.cursor = 'grabbing';
85
+ const onMove = (moveEvent) => {
86
+ // Require a small minimum drag distance before activating
87
+ if (!hasMoved && Math.abs(moveEvent.clientX - startX) < 5)
88
+ return;
89
+ if (!hasMoved) {
90
+ hasMoved = true;
91
+ setIsDragging(true);
92
+ }
93
+ if (rafRef.current)
94
+ cancelAnimationFrame(rafRef.current);
95
+ rafRef.current = requestAnimationFrame(() => {
96
+ rafRef.current = 0;
97
+ const wrapper = wrapperRef.current;
98
+ if (!wrapper)
99
+ return;
100
+ const currentOrder = columnOrderRef.current ?? columnsRef.current.map((c) => c.columnId);
101
+ const result = calculateDropTarget(moveEvent.clientX, currentOrder, columnId, draggedPinState, wrapper, pinnedShape);
102
+ if (result) {
103
+ latestDropTargetIndex = result.targetIndex;
104
+ setDropIndicatorX(result.indicatorX);
105
+ }
106
+ });
107
+ };
108
+ const cleanup = () => {
109
+ window.removeEventListener('mousemove', onMove, true);
110
+ window.removeEventListener('mouseup', onUp, true);
111
+ cleanupRef.current = null;
112
+ // Restore user-select and cursor
113
+ document.body.style.userSelect = prevUserSelect;
114
+ document.body.style.cursor = prevCursor;
115
+ // Cancel pending RAF
116
+ if (rafRef.current) {
117
+ cancelAnimationFrame(rafRef.current);
118
+ rafRef.current = 0;
119
+ }
120
+ };
121
+ const onUp = () => {
122
+ cleanup();
123
+ if (hasMoved && latestDropTargetIndex != null) {
124
+ const currentOrder = columnOrderRef.current ?? columnsRef.current.map((c) => c.columnId);
125
+ const newOrder = reorderColumnArray(currentOrder, columnId, latestDropTargetIndex);
126
+ onColumnOrderChangeRef.current?.(newOrder);
127
+ }
128
+ setIsDragging(false);
129
+ setDropIndicatorX(null);
130
+ };
131
+ window.addEventListener('mousemove', onMove, true);
132
+ window.addEventListener('mouseup', onUp, true);
133
+ cleanupRef.current = cleanup;
134
+ }, [enabled, wrapperRef]);
135
+ return { isDragging, dropIndicatorX, handleHeaderMouseDown };
136
+ }
@@ -11,6 +11,8 @@ import { useFillHandle } from './useFillHandle';
11
11
  import { useUndoRedo } from './useUndoRedo';
12
12
  import { useLatestRef } from './useLatestRef';
13
13
  import { useTableLayout } from './useTableLayout';
14
+ import { useColumnPinning } from './useColumnPinning';
15
+ import { useColumnHeaderMenuState } from './useColumnHeaderMenuState';
14
16
  // Stable no-op handlers used when cellSelection is disabled (module-scope = no re-renders)
15
17
  const NOOP = () => { };
16
18
  const NOOP_ASYNC = async () => { };
@@ -23,7 +25,7 @@ const NOOP_CTX = (_e) => { };
23
25
  */
24
26
  export function useDataGridState(params) {
25
27
  const { props, wrapperRef } = params;
26
- const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, statusBar, emptyState, editable, cellSelection: cellSelectionProp, onCellValueChanged: onCellValueChangedProp, initialColumnWidths, onColumnResized, pinnedColumns, onCellError, } = props;
28
+ const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, showRowNumbers, statusBar, emptyState, editable, cellSelection: cellSelectionProp, onCellValueChanged: onCellValueChangedProp, initialColumnWidths, onColumnResized, pinnedColumns, onColumnPinned, onCellError, } = props;
27
29
  const cellSelection = cellSelectionProp !== false;
28
30
  // Wrap onCellValueChanged with undo/redo tracking — all edits are recorded automatically
29
31
  const undoRedo = useUndoRedo({ onCellValueChanged: onCellValueChangedProp });
@@ -63,8 +65,10 @@ export function useDataGridState(params) {
63
65
  }, [flatColumns, visibleColumns, columnOrder]);
64
66
  const visibleColumnCount = visibleCols.length;
65
67
  const hasCheckboxCol = rowSelection === 'multiple';
66
- const totalColCount = visibleColumnCount + (hasCheckboxCol ? 1 : 0);
67
- const colOffset = hasCheckboxCol ? 1 : 0;
68
+ const hasRowNumbersCol = !!showRowNumbers;
69
+ const specialColsCount = (hasCheckboxCol ? 1 : 0) + (hasRowNumbersCol ? 1 : 0);
70
+ const totalColCount = visibleColumnCount + specialColsCount;
71
+ const colOffset = specialColsCount;
68
72
  const rowIndexByRowId = useMemo(() => {
69
73
  const m = new Map();
70
74
  items.forEach((item, idx) => m.set(getRowId(item), idx));
@@ -136,6 +140,16 @@ export function useDataGridState(params) {
136
140
  initialColumnWidths,
137
141
  onColumnResized,
138
142
  });
143
+ const pinningResult = useColumnPinning({
144
+ columns: flatColumns,
145
+ pinnedColumns,
146
+ onColumnPinned,
147
+ });
148
+ const headerMenuResult = useColumnHeaderMenuState({
149
+ pinnedColumns: pinningResult.pinnedColumns,
150
+ onPinColumn: pinningResult.pinColumn,
151
+ onUnpinColumn: pinningResult.unpinColumn,
152
+ });
139
153
  const aggregation = useMemo(() => computeAggregations(items, visibleCols, cellSelection ? selectionRange : null), [items, visibleCols, selectionRange, cellSelection]);
140
154
  const statusBarConfig = useMemo(() => {
141
155
  const base = getDataGridStatusBarConfig(statusBar, items.length, selectedRowIds.size);
@@ -250,11 +264,11 @@ export function useDataGridState(params) {
250
264
  // --- Memoize each sub-object so downstream consumers only re-render when their slice changes ---
251
265
  const layoutState = useMemo(() => ({
252
266
  flatColumns, visibleCols, visibleColumnCount, totalColCount, colOffset,
253
- hasCheckboxCol, rowIndexByRowId, containerWidth, minTableWidth,
267
+ hasCheckboxCol, hasRowNumbersCol, rowIndexByRowId, containerWidth, minTableWidth,
254
268
  desiredTableWidth, columnSizingOverrides, setColumnSizingOverrides, onColumnResized,
255
269
  }), [
256
270
  flatColumns, visibleCols, visibleColumnCount, totalColCount, colOffset,
257
- hasCheckboxCol, rowIndexByRowId, containerWidth, minTableWidth,
271
+ hasCheckboxCol, hasRowNumbersCol, rowIndexByRowId, containerWidth, minTableWidth,
258
272
  desiredTableWidth, columnSizingOverrides, setColumnSizingOverrides, onColumnResized,
259
273
  ]);
260
274
  const rowSelectionState = useMemo(() => ({
@@ -302,6 +316,27 @@ export function useDataGridState(params) {
302
316
  const viewModelsState = useMemo(() => ({
303
317
  headerFilterInput, cellDescriptorInput, statusBarConfig, showEmptyInGrid, onCellError,
304
318
  }), [headerFilterInput, cellDescriptorInput, statusBarConfig, showEmptyInGrid, onCellError]);
319
+ const pinningState = useMemo(() => ({
320
+ pinnedColumns: pinningResult.pinnedColumns,
321
+ pinColumn: pinningResult.pinColumn,
322
+ unpinColumn: pinningResult.unpinColumn,
323
+ isPinned: pinningResult.isPinned,
324
+ computeLeftOffsets: pinningResult.computeLeftOffsets,
325
+ computeRightOffsets: pinningResult.computeRightOffsets,
326
+ headerMenu: {
327
+ isOpen: headerMenuResult.isOpen,
328
+ openForColumn: headerMenuResult.openForColumn,
329
+ anchorElement: headerMenuResult.anchorElement,
330
+ open: headerMenuResult.open,
331
+ close: headerMenuResult.close,
332
+ handlePinLeft: headerMenuResult.handlePinLeft,
333
+ handlePinRight: headerMenuResult.handlePinRight,
334
+ handleUnpin: headerMenuResult.handleUnpin,
335
+ canPinLeft: headerMenuResult.canPinLeft,
336
+ canPinRight: headerMenuResult.canPinRight,
337
+ canUnpin: headerMenuResult.canUnpin,
338
+ },
339
+ }), [pinningResult, headerMenuResult]);
305
340
  return {
306
341
  layout: layoutState,
307
342
  rowSelection: rowSelectionState,
@@ -309,5 +344,6 @@ export function useDataGridState(params) {
309
344
  interaction: interactionState,
310
345
  contextMenu: contextMenuState,
311
346
  viewModels: viewModelsState,
347
+ pinning: pinningState,
312
348
  };
313
349
  }
@@ -11,7 +11,7 @@ const EMPTY_LOADING_OPTIONS = {};
11
11
  * @returns Grouped props for DataGridTable, pagination controls, column chooser, layout, and filters.
12
12
  */
13
13
  export function useOGrid(props, ref) {
14
- const { columns: columnsProp, getRowId, data, dataSource, page: controlledPage, pageSize: controlledPageSize, sort: controlledSort, filters: controlledFilters, visibleColumns: controlledVisibleColumns, isLoading: controlledLoading, onPageChange, onPageSizeChange, onSortChange, onFiltersChange, onVisibleColumnsChange, columnOrder, onColumnOrderChange, onColumnResized, onColumnPinned, freezeRows, freezeCols, defaultPageSize = DEFAULT_PAGE_SIZE, defaultSortBy, defaultSortDirection = 'asc', toolbar, toolbarBelow, emptyState, entityLabelPlural = 'items', className, layoutMode = 'fill', suppressHorizontalScroll, editable, cellSelection, onCellValueChanged, onUndo, onRedo, canUndo, canRedo, rowSelection = 'none', selectedRows, onSelectionChange, statusBar, pageSizeOptions, sideBar, onFirstDataRendered, onError, columnChooser: columnChooserProp, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, } = props;
14
+ const { columns: columnsProp, getRowId, data, dataSource, page: controlledPage, pageSize: controlledPageSize, sort: controlledSort, filters: controlledFilters, visibleColumns: controlledVisibleColumns, isLoading: controlledLoading, onPageChange, onPageSizeChange, onSortChange, onFiltersChange, onVisibleColumnsChange, columnOrder, onColumnOrderChange, onColumnResized, onColumnPinned, freezeRows, freezeCols, defaultPageSize = DEFAULT_PAGE_SIZE, defaultSortBy, defaultSortDirection = 'asc', toolbar, toolbarBelow, emptyState, entityLabelPlural = 'items', className, layoutMode = 'fill', suppressHorizontalScroll, editable, cellSelection, onCellValueChanged, onUndo, onRedo, canUndo, canRedo, rowSelection = 'none', selectedRows, onSelectionChange, showRowNumbers, statusBar, pageSizeOptions, sideBar, onFirstDataRendered, onError, columnChooser: columnChooserProp, columnReorder, virtualScroll, density = 'normal', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, } = props;
15
15
  // Resolve column chooser placement
16
16
  const columnChooserPlacement = columnChooserProp === false ? 'none'
17
17
  : columnChooserProp === 'sidebar' ? 'sidebar'
@@ -265,6 +265,14 @@ export function useOGrid(props, ref) {
265
265
  setRefreshCounter(refreshCounterRef.current);
266
266
  }
267
267
  },
268
+ getColumnOrder: () => columnOrder ?? columns.map((c) => c.columnId),
269
+ setColumnOrder: (order) => {
270
+ onColumnOrderChange?.(order);
271
+ },
272
+ scrollToRow: () => {
273
+ // No-op at orchestration level — DataGridTable components implement
274
+ // this via useVirtualScroll.scrollToIndex when virtual scrolling is active.
275
+ },
268
276
  }), [
269
277
  visibleColumns,
270
278
  sort,
@@ -401,6 +409,9 @@ export function useOGrid(props, ref) {
401
409
  rowSelection,
402
410
  selectedRows: effectiveSelectedRows,
403
411
  onSelectionChange: handleSelectionChange,
412
+ showRowNumbers,
413
+ currentPage: page,
414
+ pageSize,
404
415
  statusBar: statusBarConfig,
405
416
  isLoading: isLoadingResolved,
406
417
  filters,
@@ -411,6 +422,9 @@ export function useOGrid(props, ref) {
411
422
  getUserByEmail: dataSource?.getUserByEmail,
412
423
  layoutMode,
413
424
  suppressHorizontalScroll,
425
+ columnReorder,
426
+ virtualScroll,
427
+ density,
414
428
  'aria-label': ariaLabel,
415
429
  'aria-labelledby': ariaLabelledBy,
416
430
  emptyState: {
@@ -424,9 +438,10 @@ export function useOGrid(props, ref) {
424
438
  visibleColumns, columnOrder, onColumnOrderChange, handleColumnResized,
425
439
  handleColumnPinned, pinnedOverrides, columnWidthOverrides, freezeRows, freezeCols,
426
440
  editable, cellSelection, onCellValueChanged, onUndo, onRedo, canUndo, canRedo,
427
- rowSelection, effectiveSelectedRows, handleSelectionChange, statusBarConfig,
441
+ rowSelection, effectiveSelectedRows, handleSelectionChange, showRowNumbers, page, pageSize, statusBarConfig,
428
442
  isLoadingResolved, filters, handleFilterChange, clientFilterOptions, dataSource,
429
- loadingFilterOptions, layoutMode, suppressHorizontalScroll, ariaLabel, ariaLabelledBy,
443
+ loadingFilterOptions, layoutMode, suppressHorizontalScroll, columnReorder, virtualScroll,
444
+ density, ariaLabel, ariaLabelledBy,
430
445
  hasActiveFilters, clearAllFilters, emptyState,
431
446
  ]);
432
447
  const pagination = useMemo(() => ({
@@ -0,0 +1,69 @@
1
+ import { useMemo, useCallback, useRef } from 'react';
2
+ import { useVirtualizer } from '@tanstack/react-virtual';
3
+ /** Threshold below which virtual scrolling is a no-op (all rows rendered). */
4
+ const PASSTHROUGH_THRESHOLD = 100;
5
+ /**
6
+ * Wraps TanStack Virtual for row virtualization.
7
+ * When disabled or when totalRows < threshold, returns a pass-through (all rows visible).
8
+ * @param params - Total rows, row height, enabled flag, overscan, and container ref.
9
+ * @returns Virtualizer instance, total height, visible range, and scrollToIndex helper.
10
+ */
11
+ export function useVirtualScroll(params) {
12
+ const { totalRows, rowHeight, enabled, overscan = 5, containerRef, } = params;
13
+ const isActive = enabled && totalRows >= PASSTHROUGH_THRESHOLD;
14
+ const getScrollElement = useCallback(() => containerRef.current, [containerRef]);
15
+ const virtualizer = useVirtualizer({
16
+ count: isActive ? totalRows : 0,
17
+ getScrollElement,
18
+ estimateSize: () => rowHeight,
19
+ overscan,
20
+ enabled: isActive,
21
+ });
22
+ const passthroughRange = useMemo(() => ({
23
+ startIndex: 0,
24
+ endIndex: Math.max(0, totalRows - 1),
25
+ offsetTop: 0,
26
+ offsetBottom: 0,
27
+ }), [totalRows]);
28
+ const activeRange = useMemo(() => {
29
+ if (!isActive)
30
+ return passthroughRange;
31
+ const virtualItems = virtualizer.getVirtualItems();
32
+ if (virtualItems.length === 0) {
33
+ return { startIndex: 0, endIndex: -1, offsetTop: 0, offsetBottom: 0 };
34
+ }
35
+ const first = virtualItems[0];
36
+ const last = virtualItems[virtualItems.length - 1];
37
+ const totalSize = virtualizer.getTotalSize();
38
+ return {
39
+ startIndex: first.index,
40
+ endIndex: last.index,
41
+ offsetTop: first.start,
42
+ offsetBottom: Math.max(0, totalSize - last.end),
43
+ };
44
+ }, [isActive, virtualizer, passthroughRange]);
45
+ const totalHeight = isActive
46
+ ? virtualizer.getTotalSize()
47
+ : totalRows * rowHeight;
48
+ const scrollToIndexRef = useRef(virtualizer);
49
+ scrollToIndexRef.current = virtualizer;
50
+ const scrollToIndex = useCallback((index) => {
51
+ if (isActive) {
52
+ scrollToIndexRef.current?.scrollToIndex(index, { align: 'auto' });
53
+ }
54
+ else {
55
+ // When not virtualized, scroll the container directly
56
+ const container = containerRef.current;
57
+ if (container) {
58
+ const top = index * rowHeight;
59
+ container.scrollTo({ top, behavior: 'auto' });
60
+ }
61
+ }
62
+ }, [isActive, containerRef, rowHeight]);
63
+ return {
64
+ virtualizer: isActive ? virtualizer : null,
65
+ totalHeight,
66
+ visibleRange: activeRange,
67
+ scrollToIndex,
68
+ };
69
+ }
package/dist/esm/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Constants (re-exported from core)
2
- export { CHECKBOX_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH, CELL_PADDING, GRID_BORDER_RADIUS, } from '@alaarab/ogrid-core';
2
+ export { CHECKBOX_COLUMN_WIDTH, ROW_NUMBER_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH, CELL_PADDING, GRID_BORDER_RADIUS, } from '@alaarab/ogrid-core';
3
3
  export { toUserLike, isInSelectionRange, normalizeSelectionRange } from './types';
4
4
  // Hooks
5
- export { useFilterOptions, useOGrid, useActiveCell, useCellEditing, useContextMenu, useCellSelection, useClipboard, useRowSelection, useKeyboardNavigation, useUndoRedo, useDebounce, useFillHandle, useDataGridState, useColumnHeaderFilterState, useTextFilterState, useMultiSelectFilterState, usePeopleFilterState, useDateFilterState, useColumnChooserState, useInlineCellEditorState, useColumnResize, useRichSelectState, useSideBarState, useTableLayout, useLatestRef, } from './hooks';
5
+ export { useFilterOptions, useOGrid, useActiveCell, useCellEditing, useContextMenu, useCellSelection, useClipboard, useRowSelection, useKeyboardNavigation, useUndoRedo, useDebounce, useFillHandle, useDataGridState, useColumnHeaderFilterState, useTextFilterState, useMultiSelectFilterState, usePeopleFilterState, useDateFilterState, useColumnChooserState, useInlineCellEditorState, useColumnResize, useRichSelectState, useSideBarState, useTableLayout, useColumnReorder, useVirtualScroll, useLatestRef, } from './hooks';
6
6
  // Components
7
7
  export { OGridLayout } from './components/OGridLayout';
8
8
  export { StatusBar } from './components/StatusBar';
@@ -39,10 +39,18 @@ export { useInlineCellEditorState } from './useInlineCellEditorState';
39
39
  export type { UseInlineCellEditorStateParams, UseInlineCellEditorStateResult, InlineCellEditorType, } from './useInlineCellEditorState';
40
40
  export { useColumnResize } from './useColumnResize';
41
41
  export type { UseColumnResizeParams, UseColumnResizeResult, } from './useColumnResize';
42
+ export { useColumnPinning } from './useColumnPinning';
43
+ export type { UseColumnPinningParams, UseColumnPinningResult, } from './useColumnPinning';
44
+ export { useColumnHeaderMenuState } from './useColumnHeaderMenuState';
45
+ export type { UseColumnHeaderMenuStateParams, UseColumnHeaderMenuStateResult, } from './useColumnHeaderMenuState';
42
46
  export { useRichSelectState } from './useRichSelectState';
43
47
  export type { UseRichSelectStateParams, UseRichSelectStateResult } from './useRichSelectState';
44
48
  export { useSideBarState } from './useSideBarState';
45
49
  export type { UseSideBarStateParams, UseSideBarStateResult } from './useSideBarState';
46
50
  export { useTableLayout } from './useTableLayout';
47
51
  export type { UseTableLayoutParams, UseTableLayoutResult } from './useTableLayout';
52
+ export { useColumnReorder } from './useColumnReorder';
53
+ export type { UseColumnReorderParams, UseColumnReorderResult, } from './useColumnReorder';
54
+ export { useVirtualScroll } from './useVirtualScroll';
55
+ export type { IVirtualScrollConfig, UseVirtualScrollParams, UseVirtualScrollResult, } from './useVirtualScroll';
48
56
  export { useLatestRef } from './useLatestRef';
@@ -0,0 +1,23 @@
1
+ export interface UseColumnHeaderMenuStateParams {
2
+ pinnedColumns: Record<string, 'left' | 'right'>;
3
+ onPinColumn: (columnId: string, side: 'left' | 'right') => void;
4
+ onUnpinColumn: (columnId: string) => void;
5
+ }
6
+ export interface UseColumnHeaderMenuStateResult {
7
+ isOpen: boolean;
8
+ openForColumn: string | null;
9
+ anchorElement: HTMLElement | null;
10
+ open: (columnId: string, anchorEl: HTMLElement) => void;
11
+ close: () => void;
12
+ handlePinLeft: () => void;
13
+ handlePinRight: () => void;
14
+ handleUnpin: () => void;
15
+ canPinLeft: boolean;
16
+ canPinRight: boolean;
17
+ canUnpin: boolean;
18
+ }
19
+ /**
20
+ * Manages state for the column header menu (pin left/right/unpin actions).
21
+ * Tracks which column's menu is open, anchor element, and action handlers.
22
+ */
23
+ export declare function useColumnHeaderMenuState(params: UseColumnHeaderMenuStateParams): UseColumnHeaderMenuStateResult;
@@ -0,0 +1,32 @@
1
+ import type { IColumnDef } from '@alaarab/ogrid-core';
2
+ export interface UseColumnPinningParams<T = unknown> {
3
+ columns: IColumnDef<T>[];
4
+ /** Controlled pinned columns state. If provided, component is controlled. */
5
+ pinnedColumns?: Record<string, 'left' | 'right'>;
6
+ /** Called when user pins/unpins a column via UI. */
7
+ onColumnPinned?: (columnId: string, pinned: 'left' | 'right' | null) => void;
8
+ }
9
+ export interface UseColumnPinningResult {
10
+ /** Current pinned columns (controlled or internal). */
11
+ pinnedColumns: Record<string, 'left' | 'right'>;
12
+ /** Pin a column to left or right. */
13
+ pinColumn: (columnId: string, side: 'left' | 'right') => void;
14
+ /** Unpin a column. */
15
+ unpinColumn: (columnId: string) => void;
16
+ /** Check if a column is pinned and which side. */
17
+ isPinned: (columnId: string) => 'left' | 'right' | undefined;
18
+ /** Compute sticky left offsets for pinned columns. */
19
+ computeLeftOffsets: (visibleCols: {
20
+ columnId: string;
21
+ }[], columnWidths: Record<string, number>, defaultWidth: number, hasCheckboxColumn: boolean, checkboxColumnWidth: number) => Record<string, number>;
22
+ /** Compute sticky right offsets for pinned columns. */
23
+ computeRightOffsets: (visibleCols: {
24
+ columnId: string;
25
+ }[], columnWidths: Record<string, number>, defaultWidth: number) => Record<string, number>;
26
+ }
27
+ /**
28
+ * Manages column pinning state (left/right sticky positioning).
29
+ * Supports controlled and uncontrolled modes.
30
+ * Initializes from column.pinned definitions and pinnedColumns prop.
31
+ */
32
+ export declare function useColumnPinning<T = unknown>(params: UseColumnPinningParams<T>): UseColumnPinningResult;
@@ -0,0 +1,22 @@
1
+ import type { RefObject } from 'react';
2
+ import type { IColumnDef } from '../types';
3
+ export interface UseColumnReorderParams<T> {
4
+ columns: IColumnDef<T>[];
5
+ columnOrder?: string[];
6
+ onColumnOrderChange?: (order: string[]) => void;
7
+ enabled?: boolean;
8
+ /** Pinned column configuration for zone constraints. */
9
+ pinnedColumns?: Record<string, 'left' | 'right'>;
10
+ wrapperRef: RefObject<HTMLElement | null>;
11
+ }
12
+ export interface UseColumnReorderResult {
13
+ isDragging: boolean;
14
+ dropIndicatorX: number | null;
15
+ handleHeaderMouseDown: (columnId: string, event: React.MouseEvent) => void;
16
+ }
17
+ /**
18
+ * Manages column reorder drag interactions with RAF-throttled updates.
19
+ * @param params - Columns, order, change callback, enabled flag, and wrapper ref.
20
+ * @returns Drag state and mousedown handler for header cells.
21
+ */
22
+ export declare function useColumnReorder<T>(params: UseColumnReorderParams<T>): UseColumnReorderResult;
@@ -13,6 +13,7 @@ export interface DataGridLayoutState<T> {
13
13
  totalColCount: number;
14
14
  colOffset: number;
15
15
  hasCheckboxCol: boolean;
16
+ hasRowNumbersCol: boolean;
16
17
  rowIndexByRowId: Map<RowId, number>;
17
18
  containerWidth: number;
18
19
  minTableWidth: number;
@@ -121,6 +122,32 @@ export interface DataGridViewModelState<T> {
121
122
  showEmptyInGrid: boolean;
122
123
  onCellError?: (error: Error, errorInfo: React.ErrorInfo) => void;
123
124
  }
125
+ /** Column pinning state and column header menu. */
126
+ export interface DataGridPinningState {
127
+ pinnedColumns: Record<string, 'left' | 'right'>;
128
+ pinColumn: (columnId: string, side: 'left' | 'right') => void;
129
+ unpinColumn: (columnId: string) => void;
130
+ isPinned: (columnId: string) => 'left' | 'right' | undefined;
131
+ computeLeftOffsets: (visibleCols: {
132
+ columnId: string;
133
+ }[], columnWidths: Record<string, number>, defaultWidth: number, hasCheckboxColumn: boolean, checkboxColumnWidth: number) => Record<string, number>;
134
+ computeRightOffsets: (visibleCols: {
135
+ columnId: string;
136
+ }[], columnWidths: Record<string, number>, defaultWidth: number) => Record<string, number>;
137
+ headerMenu: {
138
+ isOpen: boolean;
139
+ openForColumn: string | null;
140
+ anchorElement: HTMLElement | null;
141
+ open: (columnId: string, anchorEl: HTMLElement) => void;
142
+ close: () => void;
143
+ handlePinLeft: () => void;
144
+ handlePinRight: () => void;
145
+ handleUnpin: () => void;
146
+ canPinLeft: boolean;
147
+ canPinRight: boolean;
148
+ canUnpin: boolean;
149
+ };
150
+ }
124
151
  /** Grouped result from useDataGridState. */
125
152
  export interface UseDataGridStateResult<T> {
126
153
  layout: DataGridLayoutState<T>;
@@ -129,6 +156,7 @@ export interface UseDataGridStateResult<T> {
129
156
  interaction: DataGridCellInteractionState;
130
157
  contextMenu: DataGridContextMenuState;
131
158
  viewModels: DataGridViewModelState<T>;
159
+ pinning: DataGridPinningState;
132
160
  }
133
161
  /**
134
162
  * Single orchestration hook for DataGridTable. Takes grid props and wrapper ref,
@@ -0,0 +1,33 @@
1
+ import type { Virtualizer } from '@tanstack/react-virtual';
2
+ import type { RefObject } from 'react';
3
+ import type { IVisibleRange } from '@alaarab/ogrid-core';
4
+ export type { IVirtualScrollConfig } from '@alaarab/ogrid-core';
5
+ export interface UseVirtualScrollParams {
6
+ /** Total number of rows in the data set. */
7
+ totalRows: number;
8
+ /** Row height in pixels. */
9
+ rowHeight: number;
10
+ /** Whether virtual scrolling is enabled. */
11
+ enabled: boolean;
12
+ /** Number of extra rows to render outside the visible area. Default: 5. */
13
+ overscan?: number;
14
+ /** Ref to the scrollable container element. */
15
+ containerRef: RefObject<HTMLElement | null>;
16
+ }
17
+ export interface UseVirtualScrollResult {
18
+ /** The TanStack virtualizer instance (null when disabled). */
19
+ virtualizer: Virtualizer<HTMLElement, Element> | null;
20
+ /** Total height of all rows in pixels. */
21
+ totalHeight: number;
22
+ /** The range of visible rows with spacer offsets. */
23
+ visibleRange: IVisibleRange;
24
+ /** Scroll to a specific row index. */
25
+ scrollToIndex: (index: number) => void;
26
+ }
27
+ /**
28
+ * Wraps TanStack Virtual for row virtualization.
29
+ * When disabled or when totalRows < threshold, returns a pass-through (all rows visible).
30
+ * @param params - Total rows, row height, enabled flag, overscan, and container ref.
31
+ * @returns Virtualizer instance, total height, visible range, and scrollToIndex helper.
32
+ */
33
+ export declare function useVirtualScroll(params: UseVirtualScrollParams): UseVirtualScrollResult;
@@ -1,8 +1,8 @@
1
- export { CHECKBOX_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH, CELL_PADDING, GRID_BORDER_RADIUS, } from '@alaarab/ogrid-core';
2
- export type { ColumnFilterType, IColumnFilterDef, IColumnMeta, IColumnDef, IColumnGroupDef, IColumnDefinition, ICellValueChangedEvent, ICellEditorProps, CellEditorParams, IValueParserParams, UserLike, UserLikeInput, FilterValue, IFilters, IFetchParams, IPageResult, IDataSource, IGridColumnState, IOGridApi, IOGridProps, IOGridDataGridProps, RowSelectionMode, RowId, IRowSelectionChangeEvent, StatusBarPanel, IStatusBarProps, IActiveCell, ISelectionRange, HeaderCell, HeaderRow, SideBarPanelId, ISideBarDef, IDateFilterValue, } from './types';
1
+ export { CHECKBOX_COLUMN_WIDTH, ROW_NUMBER_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH, CELL_PADDING, GRID_BORDER_RADIUS, } from '@alaarab/ogrid-core';
2
+ export type { ColumnFilterType, IColumnFilterDef, IColumnMeta, IColumnDef, IColumnGroupDef, IColumnDefinition, ICellValueChangedEvent, ICellEditorProps, CellEditorParams, IValueParserParams, UserLike, UserLikeInput, FilterValue, IFilters, IFetchParams, IPageResult, IDataSource, IGridColumnState, IOGridApi, IOGridProps, IOGridDataGridProps, RowSelectionMode, RowId, IRowSelectionChangeEvent, StatusBarPanel, IStatusBarProps, IActiveCell, ISelectionRange, HeaderCell, HeaderRow, SideBarPanelId, ISideBarDef, IDateFilterValue, IVirtualScrollConfig, IColumnReorderConfig, } from './types';
3
3
  export { toUserLike, isInSelectionRange, normalizeSelectionRange } from './types';
4
- export { useFilterOptions, useOGrid, useActiveCell, useCellEditing, useContextMenu, useCellSelection, useClipboard, useRowSelection, useKeyboardNavigation, useUndoRedo, useDebounce, useFillHandle, useDataGridState, useColumnHeaderFilterState, useTextFilterState, useMultiSelectFilterState, usePeopleFilterState, useDateFilterState, useColumnChooserState, useInlineCellEditorState, useColumnResize, useRichSelectState, useSideBarState, useTableLayout, useLatestRef, } from './hooks';
5
- export type { UseFilterOptionsResult, UseOGridResult, UseOGridPagination, UseOGridColumnChooser, UseOGridLayout, UseOGridFilters, ColumnChooserPlacement, UseActiveCellResult, UseCellEditingResult, EditingCell, UseContextMenuResult, ContextMenuPosition, UseCellSelectionResult, UseCellSelectionParams, UseClipboardResult, UseClipboardParams, UseRowSelectionResult, UseRowSelectionParams, UseKeyboardNavigationResult, UseKeyboardNavigationParams, UseUndoRedoResult, UseUndoRedoParams, UseFillHandleResult, UseFillHandleParams, UseDataGridStateParams, UseDataGridStateResult, DataGridLayoutState, DataGridRowSelectionState, DataGridEditingState, DataGridCellInteractionState, DataGridContextMenuState, DataGridViewModelState, UseColumnHeaderFilterStateParams, UseColumnHeaderFilterStateResult, UseTextFilterStateParams, UseTextFilterStateResult, UseMultiSelectFilterStateParams, UseMultiSelectFilterStateResult, UsePeopleFilterStateParams, UsePeopleFilterStateResult, UseDateFilterStateParams, UseDateFilterStateResult, UseColumnChooserStateParams, UseColumnChooserStateResult, UseInlineCellEditorStateParams, UseInlineCellEditorStateResult, InlineCellEditorType, UseColumnResizeParams, UseColumnResizeResult, UseRichSelectStateParams, UseRichSelectStateResult, UseSideBarStateParams, UseSideBarStateResult, UseTableLayoutParams, UseTableLayoutResult, } from './hooks';
4
+ export { useFilterOptions, useOGrid, useActiveCell, useCellEditing, useContextMenu, useCellSelection, useClipboard, useRowSelection, useKeyboardNavigation, useUndoRedo, useDebounce, useFillHandle, useDataGridState, useColumnHeaderFilterState, useTextFilterState, useMultiSelectFilterState, usePeopleFilterState, useDateFilterState, useColumnChooserState, useInlineCellEditorState, useColumnResize, useRichSelectState, useSideBarState, useTableLayout, useColumnReorder, useVirtualScroll, useLatestRef, } from './hooks';
5
+ export type { UseFilterOptionsResult, UseOGridResult, UseOGridPagination, UseOGridColumnChooser, UseOGridLayout, UseOGridFilters, ColumnChooserPlacement, UseActiveCellResult, UseCellEditingResult, EditingCell, UseContextMenuResult, ContextMenuPosition, UseCellSelectionResult, UseCellSelectionParams, UseClipboardResult, UseClipboardParams, UseRowSelectionResult, UseRowSelectionParams, UseKeyboardNavigationResult, UseKeyboardNavigationParams, UseUndoRedoResult, UseUndoRedoParams, UseFillHandleResult, UseFillHandleParams, UseDataGridStateParams, UseDataGridStateResult, DataGridLayoutState, DataGridRowSelectionState, DataGridEditingState, DataGridCellInteractionState, DataGridContextMenuState, DataGridViewModelState, UseColumnHeaderFilterStateParams, UseColumnHeaderFilterStateResult, UseTextFilterStateParams, UseTextFilterStateResult, UseMultiSelectFilterStateParams, UseMultiSelectFilterStateResult, UsePeopleFilterStateParams, UsePeopleFilterStateResult, UseDateFilterStateParams, UseDateFilterStateResult, UseColumnChooserStateParams, UseColumnChooserStateResult, UseInlineCellEditorStateParams, UseInlineCellEditorStateResult, InlineCellEditorType, UseColumnResizeParams, UseColumnResizeResult, UseRichSelectStateParams, UseRichSelectStateResult, UseSideBarStateParams, UseSideBarStateResult, UseTableLayoutParams, UseTableLayoutResult, UseColumnReorderParams, UseColumnReorderResult, UseVirtualScrollParams, UseVirtualScrollResult, } from './hooks';
6
6
  export { OGridLayout } from './components/OGridLayout';
7
7
  export type { OGridLayoutProps } from './components/OGridLayout';
8
8
  export { StatusBar } from './components/StatusBar';
@@ -1,8 +1,8 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { IColumnDef, IColumnGroupDef, ICellValueChangedEvent } from './columnTypes';
3
- export type { RowId, UserLike, UserLikeInput, FilterValue, IFilters, IFetchParams, IPageResult, IDataSource, IGridColumnState, RowSelectionMode, IRowSelectionChangeEvent, StatusBarPanel, IStatusBarProps, IActiveCell, ISelectionRange, SideBarPanelId, ISideBarDef, IOGridApi, } from '@alaarab/ogrid-core';
3
+ export type { RowId, UserLike, UserLikeInput, FilterValue, IFilters, IFetchParams, IPageResult, IDataSource, IGridColumnState, RowSelectionMode, IRowSelectionChangeEvent, StatusBarPanel, IStatusBarProps, IActiveCell, ISelectionRange, SideBarPanelId, ISideBarDef, IVirtualScrollConfig, IColumnReorderConfig, IOGridApi, } from '@alaarab/ogrid-core';
4
4
  export { toUserLike, isInSelectionRange, normalizeSelectionRange } from '@alaarab/ogrid-core';
5
- import type { RowId, UserLike, IFilters, FilterValue, RowSelectionMode, IRowSelectionChangeEvent, IStatusBarProps, IDataSource, ISideBarDef } from '@alaarab/ogrid-core';
5
+ import type { RowId, UserLike, IFilters, FilterValue, RowSelectionMode, IRowSelectionChangeEvent, IStatusBarProps, IDataSource, ISideBarDef, IVirtualScrollConfig } from '@alaarab/ogrid-core';
6
6
  /** Base props shared by both client-side and server-side OGrid modes. */
7
7
  interface IOGridBaseProps<T> {
8
8
  columns: (IColumnDef<T> | IColumnGroupDef<T>)[];
@@ -43,6 +43,8 @@ interface IOGridBaseProps<T> {
43
43
  rowSelection?: RowSelectionMode;
44
44
  selectedRows?: Set<RowId>;
45
45
  onSelectionChange?: (event: IRowSelectionChangeEvent<T>) => void;
46
+ /** Show Excel-style row numbers column at the start of the grid (1, 2, 3...). Default: false. */
47
+ showRowNumbers?: boolean;
46
48
  statusBar?: boolean | IStatusBarProps;
47
49
  defaultPageSize?: number;
48
50
  defaultSortBy?: string;
@@ -68,6 +70,12 @@ interface IOGridBaseProps<T> {
68
70
  sideBar?: boolean | ISideBarDef;
69
71
  /** Page size options shown in the pagination dropdown. Default: [10, 20, 50, 100]. */
70
72
  pageSizeOptions?: number[];
73
+ /** Enable column reordering via drag-and-drop on header cells. Default: false. */
74
+ columnReorder?: boolean;
75
+ /** Virtual scrolling configuration. When provided, only visible rows are rendered for large datasets. */
76
+ virtualScroll?: IVirtualScrollConfig;
77
+ /** Cell spacing/density preset. Controls cell padding throughout the grid. Default: 'normal'. */
78
+ density?: 'compact' | 'normal' | 'comfortable';
71
79
  /** Fires once when the grid first renders with data (useful for restoring column state). */
72
80
  onFirstDataRendered?: () => void;
73
81
  /** Called when server-side fetchPage fails. */
@@ -130,6 +138,12 @@ export interface IOGridDataGridProps<T> {
130
138
  rowSelection?: RowSelectionMode;
131
139
  selectedRows?: Set<RowId>;
132
140
  onSelectionChange?: (event: IRowSelectionChangeEvent<T>) => void;
141
+ /** Show Excel-style row numbers column. */
142
+ showRowNumbers?: boolean;
143
+ /** Current page number (1-based) for row number calculation. */
144
+ currentPage?: number;
145
+ /** Page size for row number calculation. */
146
+ pageSize?: number;
133
147
  statusBar?: IStatusBarProps;
134
148
  /** Unified filter model (discriminated union values). */
135
149
  filters: IFilters;
@@ -145,6 +159,12 @@ export interface IOGridDataGridProps<T> {
145
159
  message?: ReactNode;
146
160
  render?: () => ReactNode;
147
161
  };
162
+ /** Enable column reordering via drag-and-drop on header cells. Default: false. */
163
+ columnReorder?: boolean;
164
+ /** Virtual scrolling configuration. When provided, only visible rows are rendered for large datasets. */
165
+ virtualScroll?: IVirtualScrollConfig;
166
+ /** Cell spacing/density preset. Controls cell padding throughout the grid. Default: 'normal'. */
167
+ density?: 'compact' | 'normal' | 'comfortable';
148
168
  /** Called when a cell renderer or custom editor throws an error. */
149
169
  onCellError?: (error: Error, errorInfo: React.ErrorInfo) => void;
150
170
  'aria-label'?: string;
@@ -1,3 +1,3 @@
1
1
  export type { ColumnFilterType, IColumnFilterDef, IColumnMeta, IColumnDef, IColumnGroupDef, IColumnDefinition, ICellValueChangedEvent, ICellEditorProps, CellEditorParams, IValueParserParams, IDateFilterValue, HeaderCell, HeaderRow, } from './columnTypes';
2
- export type { RowId, UserLike, UserLikeInput, FilterValue, IFilters, IFetchParams, IPageResult, IDataSource, IGridColumnState, IOGridApi, IOGridProps, IOGridClientProps, IOGridServerProps, IOGridDataGridProps, RowSelectionMode, IRowSelectionChangeEvent, StatusBarPanel, IStatusBarProps, IActiveCell, ISelectionRange, SideBarPanelId, ISideBarDef, } from './dataGridTypes';
2
+ export type { RowId, UserLike, UserLikeInput, FilterValue, IFilters, IFetchParams, IPageResult, IDataSource, IGridColumnState, IOGridApi, IOGridProps, IOGridClientProps, IOGridServerProps, IOGridDataGridProps, RowSelectionMode, IRowSelectionChangeEvent, StatusBarPanel, IStatusBarProps, IActiveCell, ISelectionRange, SideBarPanelId, ISideBarDef, IVirtualScrollConfig, IColumnReorderConfig, } from './dataGridTypes';
3
3
  export { toUserLike, isInSelectionRange, normalizeSelectionRange } from './dataGridTypes';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/ogrid-react",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "OGrid React – React hooks, headless components, and utilities for OGrid data grids.",
5
5
  "main": "dist/esm/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -35,7 +35,8 @@
35
35
  "node": ">=18"
36
36
  },
37
37
  "dependencies": {
38
- "@alaarab/ogrid-core": "2.0.2"
38
+ "@alaarab/ogrid-core": "2.0.4",
39
+ "@tanstack/react-virtual": "^3.11.0"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "react": "^17.0.0 || ^18.0.0 || ^19.0.0"