@alaarab/ogrid-react 2.0.18 → 2.0.21

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.
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { getColumnHeaderMenuItems } from '../utils';
5
+ /**
6
+ * Base column header dropdown menu for pin/sort/autosize actions.
7
+ * Uses positioned div with portal rendering.
8
+ * Shared by Radix and Fluent UI packages (Material uses MUI Menu instead).
9
+ */
10
+ export function BaseColumnHeaderMenu(props) {
11
+ const { isOpen, anchorElement, onClose, onPinLeft, onPinRight, onUnpin, onSortAsc, onSortDesc, onClearSort, onAutosizeThis, onAutosizeAll, canPinLeft, canPinRight, canUnpin, currentSort, isSortable, isResizable, classNames, getPortalTarget, } = props;
12
+ const [position, setPosition] = React.useState(null);
13
+ const menuRef = React.useRef(null);
14
+ React.useEffect(() => {
15
+ if (!isOpen || !anchorElement) {
16
+ setPosition(null);
17
+ return;
18
+ }
19
+ const rect = anchorElement.getBoundingClientRect();
20
+ setPosition({
21
+ top: rect.bottom + 4,
22
+ left: rect.left,
23
+ });
24
+ const handleClickOutside = (e) => {
25
+ const target = e.target;
26
+ // Don't close if clicking inside the menu itself (portal) — let onClick fire first
27
+ if (menuRef.current && menuRef.current.contains(target))
28
+ return;
29
+ if (anchorElement && !anchorElement.contains(target)) {
30
+ onClose();
31
+ }
32
+ };
33
+ const handleEscape = (e) => {
34
+ if (e.key === 'Escape') {
35
+ onClose();
36
+ }
37
+ };
38
+ document.addEventListener('mousedown', handleClickOutside);
39
+ document.addEventListener('keydown', handleEscape);
40
+ return () => {
41
+ document.removeEventListener('mousedown', handleClickOutside);
42
+ document.removeEventListener('keydown', handleEscape);
43
+ };
44
+ }, [isOpen, anchorElement, onClose]);
45
+ const menuInput = React.useMemo(() => ({
46
+ canPinLeft,
47
+ canPinRight,
48
+ canUnpin,
49
+ currentSort,
50
+ isSortable,
51
+ isResizable,
52
+ }), [canPinLeft, canPinRight, canUnpin, currentSort, isSortable, isResizable]);
53
+ const items = React.useMemo(() => getColumnHeaderMenuItems(menuInput), [menuInput]);
54
+ const handlers = React.useMemo(() => ({
55
+ pinLeft: onPinLeft,
56
+ pinRight: onPinRight,
57
+ unpin: onUnpin,
58
+ sortAsc: onSortAsc,
59
+ sortDesc: onSortDesc,
60
+ clearSort: onClearSort,
61
+ autosizeThis: onAutosizeThis,
62
+ autosizeAll: onAutosizeAll,
63
+ }), [onPinLeft, onPinRight, onUnpin, onSortAsc, onSortDesc, onClearSort, onAutosizeThis, onAutosizeAll]);
64
+ if (!isOpen || !position)
65
+ return null;
66
+ const portalTarget = anchorElement && getPortalTarget
67
+ ? getPortalTarget(anchorElement)
68
+ : document.body;
69
+ return createPortal(_jsx("div", { ref: menuRef, className: classNames?.content, style: {
70
+ position: 'fixed',
71
+ top: position.top,
72
+ left: position.left,
73
+ zIndex: 1000,
74
+ }, children: items.map((item, idx) => (_jsxs(React.Fragment, { children: [_jsx("button", { className: classNames?.item, disabled: item.disabled, onClick: () => {
75
+ handlers[item.id]();
76
+ onClose();
77
+ }, children: item.label }), item.divider && idx < items.length - 1 && (_jsx("div", { className: classNames?.separator }))] }, item.id))) }), portalTarget);
78
+ }
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export function BaseDropIndicator({ dropIndicatorX, wrapperLeft, className }) {
3
+ return (_jsx("div", { className: className, style: { left: dropIndicatorX - wrapperLeft } }));
4
+ }
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function BaseEmptyState({ emptyState, classNames, icon }) {
3
+ return (_jsx("div", { className: classNames.emptyStateInGrid, children: _jsx("div", { className: classNames.emptyStateInGridInner, children: emptyState.render ? (emptyState.render()) : (_jsxs(_Fragment, { children: [icon != null && (_jsx("span", { className: classNames.emptyStateInGridIcon, "aria-hidden": true, children: icon })), _jsx("div", { className: classNames.emptyStateInGridTitle, children: "No results found" }), _jsx("div", { className: classNames.emptyStateInGridMessage, children: emptyState.message != null ? (emptyState.message) : emptyState.hasActiveFilters ? (_jsxs(_Fragment, { children: ["No items match your current filters. Try adjusting your search or", ' ', _jsx("button", { type: "button", className: classNames.emptyStateInGridLink, onClick: emptyState.onClearAll, children: "clear all filters" }), ' ', "to see all items."] })) : ('There are no items available at this time.') })] })) }) }));
4
+ }
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function BaseLoadingOverlay({ message, classNames }) {
3
+ return (_jsx("div", { className: classNames.loadingOverlay, "aria-live": "polite", children: _jsxs("div", { className: classNames.loadingOverlayContent, children: [_jsx("div", { className: classNames.spinner }), _jsx("span", { className: classNames.loadingOverlayText, children: message })] }) }));
4
+ }
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import { forwardRef } from 'react';
4
+ import { useOGrid } from '../hooks';
5
+ import { OGridLayout } from './OGridLayout';
6
+ /**
7
+ * Factory that creates a memoized, forwardRef OGrid component.
8
+ * Used by Radix and Fluent to avoid duplicating the same wiring code.
9
+ * Material uses its own OGrid because it adds MUI theme bridging (containerSx).
10
+ */
11
+ export function createOGrid(components) {
12
+ const { DataGridTable, ColumnChooser, PaginationControls, containerComponent, containerProps, } = components;
13
+ const OGridInner = forwardRef(function OGridInner(props, ref) {
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 }) }));
19
+ });
20
+ OGridInner.displayName = 'OGrid';
21
+ return React.memo(OGridInner);
22
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useLayoutEffect, useCallback, useRef } from 'react';
1
+ import { useState, useLayoutEffect, useEffect, useCallback, useRef } from 'react';
2
2
  /**
3
3
  * Tracks the active cell for keyboard navigation.
4
4
  * When wrapperRef and editingCell are provided, scrolls the active cell into view when it changes (and not editing).
@@ -18,45 +18,60 @@ export function useActiveCell(wrapperRef, editingCell) {
18
18
  return;
19
19
  _setActiveCell(cell);
20
20
  }, []);
21
- // useLayoutEffect ensures focus moves synchronously before the browser can
22
- // reset focus to body (fixes left/right arrow navigation losing focus)
21
+ // RAF ref for batching scroll-into-view during rapid keyboard navigation
22
+ const scrollRafRef = useRef(0);
23
+ // Synchronously focus the cell to prevent the browser from resetting
24
+ // focus to body between arrow presses.
23
25
  useLayoutEffect(() => {
24
- if (activeCell == null ||
25
- wrapperRef?.current == null ||
26
- editingCell != null)
26
+ if (activeCell == null || wrapperRef?.current == null || editingCell != null)
27
27
  return;
28
28
  const { rowIndex, columnIndex } = activeCell;
29
29
  const selector = `[data-row-index="${rowIndex}"][data-col-index="${columnIndex}"]`;
30
30
  const cell = wrapperRef.current.querySelector(selector);
31
- if (cell) {
32
- // Scroll the cell into view within the table wrapper only — do NOT
33
- // use native scrollIntoView() which scrolls all ancestor containers
34
- // including the page, causing an unwanted viewport jump.
35
- const wrapper = wrapperRef.current;
36
- const thead = wrapper.querySelector('thead');
37
- const headerHeight = thead ? thead.getBoundingClientRect().height : 0;
38
- const wrapperRect = wrapper.getBoundingClientRect();
39
- const cellRect = cell.getBoundingClientRect();
40
- // Vertical scroll (account for sticky thead)
41
- const visibleTop = wrapperRect.top + headerHeight;
42
- if (cellRect.top < visibleTop) {
43
- wrapper.scrollTop -= visibleTop - cellRect.top;
44
- }
45
- else if (cellRect.bottom > wrapperRect.bottom) {
46
- wrapper.scrollTop += cellRect.bottom - wrapperRect.bottom;
47
- }
48
- // Horizontal scroll
49
- if (cellRect.left < wrapperRect.left) {
50
- wrapper.scrollLeft -= wrapperRect.left - cellRect.left;
51
- }
52
- else if (cellRect.right > wrapperRect.right) {
53
- wrapper.scrollLeft += cellRect.right - wrapperRect.right;
54
- }
55
- if (document.activeElement !== cell && typeof cell.focus === 'function') {
56
- cell.focus({ preventScroll: true });
57
- }
31
+ if (cell && document.activeElement !== cell && typeof cell.focus === 'function') {
32
+ cell.focus({ preventScroll: true });
58
33
  }
59
34
  // eslint-disable-next-line react-hooks/exhaustive-deps
60
35
  }, [activeCell, editingCell]); // wrapperRef excluded — refs are stable across renders
36
+ // Batch scroll-into-view via RAF so rapid keyboard navigation only scrolls once
37
+ useEffect(() => {
38
+ if (activeCell == null || wrapperRef?.current == null || editingCell != null)
39
+ return;
40
+ cancelAnimationFrame(scrollRafRef.current);
41
+ scrollRafRef.current = requestAnimationFrame(() => {
42
+ const wrapper = wrapperRef?.current;
43
+ if (!wrapper)
44
+ return;
45
+ const { rowIndex, columnIndex } = activeCell;
46
+ const selector = `[data-row-index="${rowIndex}"][data-col-index="${columnIndex}"]`;
47
+ const cell = wrapper.querySelector(selector);
48
+ if (cell) {
49
+ const thead = wrapper.querySelector('thead');
50
+ const headerHeight = thead ? thead.getBoundingClientRect().height : 0;
51
+ const wrapperRect = wrapper.getBoundingClientRect();
52
+ const cellRect = cell.getBoundingClientRect();
53
+ // Vertical scroll (account for sticky thead)
54
+ const visibleTop = wrapperRect.top + headerHeight;
55
+ if (cellRect.top < visibleTop) {
56
+ wrapper.scrollTop -= visibleTop - cellRect.top;
57
+ }
58
+ else if (cellRect.bottom > wrapperRect.bottom) {
59
+ wrapper.scrollTop += cellRect.bottom - wrapperRect.bottom;
60
+ }
61
+ // Horizontal scroll
62
+ if (cellRect.left < wrapperRect.left) {
63
+ wrapper.scrollLeft -= wrapperRect.left - cellRect.left;
64
+ }
65
+ else if (cellRect.right > wrapperRect.right) {
66
+ wrapper.scrollLeft += cellRect.right - wrapperRect.right;
67
+ }
68
+ }
69
+ });
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, [activeCell, editingCell]); // wrapperRef excluded — refs are stable across renders
72
+ // Clean up pending RAF on unmount
73
+ useEffect(() => {
74
+ return () => cancelAnimationFrame(scrollRafRef.current);
75
+ }, []);
61
76
  return { activeCell, setActiveCell };
62
77
  }
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { normalizeSelectionRange } from '../types';
3
3
  import { rangesEqual, computeAutoScrollSpeed } from '../utils';
4
+ import { useLatestRef } from './useLatestRef';
4
5
  /** DOM attribute names used for drag-range highlighting (bypasses React). */
5
6
  const DRAG_ATTR = 'data-drag-range';
6
7
  const DRAG_ANCHOR_ATTR = 'data-drag-anchor';
@@ -14,6 +15,8 @@ const AUTO_SCROLL_INTERVAL = 16; // ~60fps
14
15
  */
15
16
  export function useCellSelection(params) {
16
17
  const { colOffset, rowCount, visibleColCount, setActiveCell, wrapperRef } = params;
18
+ // Use ref for colOffset to prevent drag restart mid-drag when colOffset changes
19
+ const colOffsetRef = useLatestRef(colOffset);
17
20
  const [selectionRange, _setSelectionRange] = useState(null);
18
21
  const isDraggingRef = useRef(false);
19
22
  const [isDragging, setIsDragging] = useState(false);
@@ -39,11 +42,12 @@ export function useCellSelection(params) {
39
42
  // Only handle primary (left) button — let middle-click scroll and right-click context menu work natively
40
43
  if (e.button !== 0)
41
44
  return;
42
- if (globalColIndex < colOffset)
45
+ const colOff = colOffsetRef.current;
46
+ if (globalColIndex < colOff)
43
47
  return;
44
48
  // Prevent native text selection during cell drag
45
49
  e.preventDefault();
46
- const dataColIndex = globalColIndex - colOffset;
50
+ const dataColIndex = globalColIndex - colOff;
47
51
  const currentRange = selectionRangeRef.current;
48
52
  if (e.shiftKey && currentRange != null) {
49
53
  setSelectionRange(normalizeSelectionRange({
@@ -75,8 +79,8 @@ export function useCellSelection(params) {
75
79
  setTimeout(() => applyDragAttrsRef.current?.(initial), 0);
76
80
  }
77
81
  },
78
- // eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectionRange is a stable callback
79
- [colOffset, setActiveCell]);
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectionRange is stable; colOffsetRef is a ref
83
+ [setActiveCell]);
80
84
  const handleSelectAllCells = useCallback(() => {
81
85
  if (rowCount === 0 || visibleColCount === 0)
82
86
  return;
@@ -86,9 +90,9 @@ export function useCellSelection(params) {
86
90
  endRow: rowCount - 1,
87
91
  endCol: visibleColCount - 1,
88
92
  });
89
- setActiveCell({ rowIndex: 0, columnIndex: colOffset });
90
- // eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectionRange is a stable callback
91
- }, [rowCount, visibleColCount, colOffset, setActiveCell]);
93
+ 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
96
  /** Last known mouse position during drag — used by mouseUp to flush pending RAF work. */
93
97
  const lastMousePosRef = useRef(null);
94
98
  // Ref to expose applyDragAttrs outside useEffect so it can be called from mouseDown
@@ -97,7 +101,6 @@ export function useCellSelection(params) {
97
101
  // Performance: during drag, we update a ref + toggle DOM attributes via rAF.
98
102
  // React state is only committed on mouseup (single re-render instead of 60-120/s).
99
103
  useEffect(() => {
100
- const colOff = colOffset; // capture for closure
101
104
  /** Toggle DRAG_ATTR on cells to show the range highlight via CSS.
102
105
  * Also sets edge box-shadows for a green border around the selection range,
103
106
  * and marks the anchor cell with DRAG_ANCHOR_ATTR (white background). */
@@ -114,7 +117,7 @@ export function useCellSelection(params) {
114
117
  for (let i = 0; i < cells.length; i++) {
115
118
  const el = cells[i];
116
119
  const r = parseInt(el.getAttribute('data-row-index'), 10);
117
- const c = parseInt(el.getAttribute('data-col-index'), 10) - colOff;
120
+ const c = parseInt(el.getAttribute('data-col-index'), 10) - colOffsetRef.current;
118
121
  const inRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
119
122
  if (inRange) {
120
123
  if (!el.hasAttribute(DRAG_ATTR))
@@ -175,6 +178,7 @@ export function useCellSelection(params) {
175
178
  return null;
176
179
  const r = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
177
180
  const c = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
181
+ const colOff = colOffsetRef.current;
178
182
  if (Number.isNaN(r) || Number.isNaN(c) || c < colOff)
179
183
  return null;
180
184
  const dataCol = c - colOff;
@@ -317,7 +321,7 @@ export function useCellSelection(params) {
317
321
  setSelectionRange(finalRange);
318
322
  setActiveCell({
319
323
  rowIndex: finalRange.endRow,
320
- columnIndex: finalRange.endCol + colOff,
324
+ columnIndex: finalRange.endCol + colOffsetRef.current,
321
325
  });
322
326
  }
323
327
  }
@@ -341,7 +345,7 @@ export function useCellSelection(params) {
341
345
  stopAutoScroll();
342
346
  };
343
347
  // eslint-disable-next-line react-hooks/exhaustive-deps
344
- }, [colOffset, setActiveCell]); // wrapperRef excluded — refs are stable across renders
348
+ }, [setActiveCell]); // wrapperRef, colOffsetRef excluded — refs are stable across renders
345
349
  return {
346
350
  selectionRange,
347
351
  setSelectionRange,
@@ -26,7 +26,7 @@ const NOOP_CTX = (_e) => { };
26
26
  */
27
27
  export function useDataGridState(params) {
28
28
  const { props, wrapperRef } = params;
29
- 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;
29
+ const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, showRowNumbers, statusBar, emptyState, editable, cellSelection: cellSelectionProp, onCellValueChanged: onCellValueChangedProp, initialColumnWidths, onColumnResized, onAutosizeColumn, pinnedColumns, onColumnPinned, onCellError, } = props;
30
30
  const cellSelection = cellSelectionProp !== false;
31
31
  // Wrap onCellValueChanged with undo/redo tracking — all edits are recorded automatically
32
32
  const undoRedo = useUndoRedo({ onCellValueChanged: onCellValueChangedProp });
@@ -216,8 +216,8 @@ export function useDataGridState(params) {
216
216
  // Autosize callback — updates internal column sizing state + notifies external listener
217
217
  const handleAutosizeColumn = useCallback((columnId, width) => {
218
218
  setColumnSizingOverrides((prev) => ({ ...prev, [columnId]: { widthPx: width } }));
219
- onColumnResized?.(columnId, width);
220
- }, [setColumnSizingOverrides, onColumnResized]);
219
+ (onAutosizeColumn ?? onColumnResized)?.(columnId, width);
220
+ }, [setColumnSizingOverrides, onAutosizeColumn, onColumnResized]);
221
221
  const headerMenuResult = useColumnHeaderMenuState({
222
222
  pinnedColumns: pinningResult.pinnedColumns,
223
223
  onPinColumn: pinningResult.pinColumn,
@@ -386,37 +386,11 @@ export function useDataGridState(params) {
386
386
  isPinned: pinningResult.isPinned,
387
387
  leftOffsets,
388
388
  rightOffsets,
389
- headerMenu: {
390
- isOpen: headerMenuResult.isOpen,
391
- openForColumn: headerMenuResult.openForColumn,
392
- anchorElement: headerMenuResult.anchorElement,
393
- open: headerMenuResult.open,
394
- close: headerMenuResult.close,
395
- handlePinLeft: headerMenuResult.handlePinLeft,
396
- handlePinRight: headerMenuResult.handlePinRight,
397
- handleUnpin: headerMenuResult.handleUnpin,
398
- handleSortAsc: headerMenuResult.handleSortAsc,
399
- handleSortDesc: headerMenuResult.handleSortDesc,
400
- handleClearSort: headerMenuResult.handleClearSort,
401
- handleAutosizeThis: headerMenuResult.handleAutosizeThis,
402
- handleAutosizeAll: headerMenuResult.handleAutosizeAll,
403
- canPinLeft: headerMenuResult.canPinLeft,
404
- canPinRight: headerMenuResult.canPinRight,
405
- canUnpin: headerMenuResult.canUnpin,
406
- currentSort: headerMenuResult.currentSort,
407
- isSortable: headerMenuResult.isSortable,
408
- isResizable: headerMenuResult.isResizable,
409
- },
389
+ headerMenu: headerMenuResult,
410
390
  }), [
411
391
  pinningResult.pinnedColumns, pinningResult.pinColumn, pinningResult.unpinColumn,
412
392
  pinningResult.isPinned, leftOffsets, rightOffsets,
413
- headerMenuResult.isOpen, headerMenuResult.openForColumn, headerMenuResult.anchorElement,
414
- headerMenuResult.open, headerMenuResult.close, headerMenuResult.handlePinLeft,
415
- headerMenuResult.handlePinRight, headerMenuResult.handleUnpin,
416
- headerMenuResult.handleSortAsc, headerMenuResult.handleSortDesc, headerMenuResult.handleClearSort,
417
- headerMenuResult.handleAutosizeThis, headerMenuResult.handleAutosizeAll,
418
- headerMenuResult.canPinLeft, headerMenuResult.canPinRight, headerMenuResult.canUnpin,
419
- headerMenuResult.currentSort, headerMenuResult.isSortable, headerMenuResult.isResizable,
393
+ headerMenuResult,
420
394
  ]);
421
395
  return {
422
396
  layout: layoutState,
@@ -35,7 +35,7 @@ export function useDataGridTableOrchestration(params) {
35
35
  const { headerMenu } = pinning;
36
36
  const handlePasteVoid = useCallback(() => { void handlePaste(); }, [handlePaste]);
37
37
  // ── Props destructuring ─────────────────────────────────────────────────
38
- const { items, columns, getRowId, emptyState, layoutMode = 'fill', rowSelection = 'none', suppressHorizontalScroll, isLoading = false, loadingMessage = 'Loading\u2026', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, visibleColumns, columnOrder, onColumnOrderChange, columnReorder, virtualScroll, density = 'normal', pinnedColumns, currentPage = 1, pageSize: propPageSize = 25, } = props;
38
+ const { items, columns, getRowId, emptyState, layoutMode = 'fill', rowSelection = 'none', suppressHorizontalScroll, isLoading = false, loadingMessage = 'Loading\u2026', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, visibleColumns, columnOrder, onColumnOrderChange, columnReorder, virtualScroll, rowHeight, density = 'normal', pinnedColumns, currentPage = 1, pageSize: propPageSize = 25, } = props;
39
39
  // ── Derived values ──────────────────────────────────────────────────────
40
40
  const rowNumberOffset = hasRowNumbersCol ? (currentPage - 1) * propPageSize : 0;
41
41
  const headerRows = useMemo(() => buildHeaderRows(columns, visibleColumns), [columns, visibleColumns]);
@@ -126,6 +126,7 @@ export function useDataGridTableOrchestration(params) {
126
126
  columnOrder,
127
127
  columnReorder,
128
128
  density,
129
+ rowHeight,
129
130
  pinnedColumns,
130
131
  currentPage,
131
132
  propPageSize,
@@ -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, 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;
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, 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, rowHeight, 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'
@@ -105,10 +105,29 @@ export function useOGrid(props, ref) {
105
105
  return deriveFilterOptionsFromData(displayData, columns);
106
106
  }, [hasServerFilterOptions, displayData, columns, serverFilterOptions]);
107
107
  // --- Client-side filtering & sorting ---
108
+ // Stabilize filters ref via shallow comparison so processClientSideData useMemo
109
+ // doesn't re-run when the filter object reference changes but values are identical.
110
+ const stableFiltersRef = useRef(filters);
111
+ const stableFilters = useMemo(() => {
112
+ const prev = stableFiltersRef.current;
113
+ const prevKeys = Object.keys(prev);
114
+ const nextKeys = Object.keys(filters);
115
+ if (prevKeys.length !== nextKeys.length) {
116
+ stableFiltersRef.current = filters;
117
+ return filters;
118
+ }
119
+ for (let i = 0; i < nextKeys.length; i++) {
120
+ if (prev[nextKeys[i]] !== filters[nextKeys[i]]) {
121
+ stableFiltersRef.current = filters;
122
+ return filters;
123
+ }
124
+ }
125
+ return prev;
126
+ }, [filters]);
108
127
  const clientItemsAndTotal = useMemo(() => {
109
128
  if (!isClientSide)
110
129
  return null;
111
- const rows = processClientSideData(displayData, columns, filters, sort.field, sort.direction);
130
+ const rows = processClientSideData(displayData, columns, stableFilters, sort.field, sort.direction);
112
131
  const total = rows.length;
113
132
  const start = (page - 1) * pageSize;
114
133
  const paged = rows.slice(start, start + pageSize);
@@ -117,7 +136,7 @@ export function useOGrid(props, ref) {
117
136
  isClientSide,
118
137
  displayData,
119
138
  columns,
120
- filters,
139
+ stableFilters,
121
140
  sort.field,
122
141
  sort.direction,
123
142
  page,
@@ -420,6 +439,7 @@ export function useOGrid(props, ref) {
420
439
  suppressHorizontalScroll,
421
440
  columnReorder,
422
441
  virtualScroll,
442
+ rowHeight,
423
443
  density,
424
444
  'aria-label': ariaLabel,
425
445
  'aria-labelledby': ariaLabelledBy,
@@ -437,7 +457,7 @@ export function useOGrid(props, ref) {
437
457
  rowSelection, effectiveSelectedRows, handleSelectionChange, showRowNumbers, page, pageSize, statusBarConfig,
438
458
  isLoadingResolved, filters, handleFilterChange, clientFilterOptions, dataSource,
439
459
  loadingFilterOptions, layoutMode, suppressHorizontalScroll, columnReorder, virtualScroll,
440
- density, ariaLabel, ariaLabelledBy,
460
+ rowHeight, density, ariaLabel, ariaLabelledBy,
441
461
  hasActiveFilters, clearAllFilters, emptyState,
442
462
  ]);
443
463
  const pagination = useMemo(() => ({
@@ -67,8 +67,17 @@ export function useRowSelection(params) {
67
67
  updateSelection(new Set());
68
68
  }
69
69
  }, [items, getRowId, updateSelection]);
70
- const allSelected = useMemo(() => items.length > 0 && items.every((item) => selectedRowIds.has(getRowId(item))), [items, selectedRowIds, getRowId]);
71
- const someSelected = useMemo(() => !allSelected && items.some((item) => selectedRowIds.has(getRowId(item))), [allSelected, items, selectedRowIds, getRowId]);
70
+ const allSelected = useMemo(() => {
71
+ if (selectedRowIds.size === 0 || items.length === 0)
72
+ return false;
73
+ return items.every((item) => selectedRowIds.has(getRowId(item)));
74
+ }, [items, selectedRowIds, getRowId]);
75
+ const someSelected = useMemo(() => {
76
+ if (allSelected)
77
+ return false;
78
+ // No iteration needed — any selected row means "some" are selected
79
+ return selectedRowIds.size > 0;
80
+ }, [allSelected, selectedRowIds.size]);
72
81
  return {
73
82
  selectedRowIds,
74
83
  updateSelection,
package/dist/esm/index.js CHANGED
@@ -12,8 +12,13 @@ export { BaseInlineCellEditor, editorWrapperStyle, editorInputStyle, richSelectW
12
12
  export { GridContextMenu } from './components/GridContextMenu';
13
13
  export { MarchingAntsOverlay } from './components/MarchingAntsOverlay';
14
14
  export { SideBar } from './components/SideBar';
15
+ export { BaseColumnHeaderMenu } from './components/BaseColumnHeaderMenu';
16
+ export { createOGrid } from './components/createOGrid';
15
17
  export { CellErrorBoundary } from './components/CellErrorBoundary';
16
18
  export { EmptyState } from './components/EmptyState';
19
+ export { BaseEmptyState } from './components/BaseEmptyState';
20
+ export { BaseLoadingOverlay } from './components/BaseLoadingOverlay';
21
+ export { BaseDropIndicator } from './components/BaseDropIndicator';
17
22
  export { DateFilterContent, getColumnHeaderFilterStateParams, getDateFilterContentProps, } from './components/ColumnHeaderFilterContent';
18
23
  // Utilities
19
24
  export { escapeCsvValue, buildCsvHeader, buildCsvRows, exportToCsv, triggerCsvDownload, getCellValue, flattenColumns, buildHeaderRows, getFilterField, mergeFilter, deriveFilterOptionsFromData, getMultiSelectFilterFields, getStatusBarParts, getDataGridStatusBarConfig, GRID_CONTEXT_MENU_ITEMS, COLUMN_HEADER_MENU_ITEMS, getContextMenuHandlers, getColumnHeaderMenuItems, formatShortcut, getPaginationViewModel, PAGE_SIZE_OPTIONS, MAX_PAGE_BUTTONS, getHeaderFilterConfig, getCellRenderDescriptor, isRowInRange, resolveCellDisplayContent, resolveCellStyle, buildInlineEditorProps, buildPopoverEditorProps, getCellInteractionProps, parseValue, numberParser, currencyParser, dateParser, emailParser, booleanParser, computeAggregations, processClientSideData, areGridRowPropsEqual, findCtrlArrowTarget, computeTabNavigation, rangesEqual, clampSelectionToBounds, computeAutoScrollSpeed, formatCellValueForTsv, formatSelectionAsTsv, parseTsvClipboard, UndoRedoStack, } from './utils';
@@ -0,0 +1,34 @@
1
+ import * as React from 'react';
2
+ export interface ColumnHeaderMenuClassNames {
3
+ content?: string;
4
+ item?: string;
5
+ separator?: string;
6
+ }
7
+ export interface BaseColumnHeaderMenuProps {
8
+ isOpen: boolean;
9
+ anchorElement: HTMLElement | null;
10
+ onClose: () => void;
11
+ onPinLeft: () => void;
12
+ onPinRight: () => void;
13
+ onUnpin: () => void;
14
+ onSortAsc: () => void;
15
+ onSortDesc: () => void;
16
+ onClearSort: () => void;
17
+ onAutosizeThis: () => void;
18
+ onAutosizeAll: () => void;
19
+ canPinLeft: boolean;
20
+ canPinRight: boolean;
21
+ canUnpin: boolean;
22
+ currentSort: 'asc' | 'desc' | null;
23
+ isSortable: boolean;
24
+ isResizable: boolean;
25
+ classNames?: ColumnHeaderMenuClassNames;
26
+ /** Resolve the portal target element. Defaults to document.body. */
27
+ getPortalTarget?: (anchorElement: HTMLElement) => HTMLElement;
28
+ }
29
+ /**
30
+ * Base column header dropdown menu for pin/sort/autosize actions.
31
+ * Uses positioned div with portal rendering.
32
+ * Shared by Radix and Fluent UI packages (Material uses MUI Menu instead).
33
+ */
34
+ export declare function BaseColumnHeaderMenu(props: BaseColumnHeaderMenuProps): React.ReactPortal | null;
@@ -0,0 +1,7 @@
1
+ import * as React from 'react';
2
+ export interface BaseDropIndicatorProps {
3
+ dropIndicatorX: number;
4
+ wrapperLeft: number;
5
+ className?: string;
6
+ }
7
+ export declare function BaseDropIndicator({ dropIndicatorX, wrapperLeft, className }: BaseDropIndicatorProps): React.ReactElement;
@@ -0,0 +1,21 @@
1
+ import * as React from 'react';
2
+ export interface BaseEmptyStateClassNames {
3
+ emptyStateInGrid?: string;
4
+ emptyStateInGridInner?: string;
5
+ emptyStateInGridIcon?: string;
6
+ emptyStateInGridTitle?: string;
7
+ emptyStateInGridMessage?: string;
8
+ emptyStateInGridLink?: string;
9
+ }
10
+ export interface BaseEmptyStateProps {
11
+ emptyState: {
12
+ render?: () => React.ReactNode;
13
+ message?: React.ReactNode;
14
+ hasActiveFilters?: boolean;
15
+ onClearAll?: () => void;
16
+ };
17
+ classNames: BaseEmptyStateClassNames;
18
+ /** Optional icon rendered above the title (e.g. emoji or SVG) */
19
+ icon?: React.ReactNode;
20
+ }
21
+ export declare function BaseEmptyState({ emptyState, classNames, icon }: BaseEmptyStateProps): React.ReactElement;
@@ -0,0 +1,12 @@
1
+ import * as React from 'react';
2
+ export interface BaseLoadingOverlayClassNames {
3
+ loadingOverlay?: string;
4
+ loadingOverlayContent?: string;
5
+ spinner?: string;
6
+ loadingOverlayText?: string;
7
+ }
8
+ export interface BaseLoadingOverlayProps {
9
+ message: string;
10
+ classNames: BaseLoadingOverlayClassNames;
11
+ }
12
+ export declare function BaseLoadingOverlay({ message, classNames }: BaseLoadingOverlayProps): React.ReactElement;
@@ -0,0 +1,70 @@
1
+ import * as React from 'react';
2
+ import type { IOGridProps, IOGridApi, IOGridDataGridProps } from '../types';
3
+ import type { IColumnDef } from '../types';
4
+ import type { IColumnChooserProps } from './ColumnChooserProps';
5
+ import type { IPaginationControlsProps } from './PaginationControlsProps';
6
+ export interface InlineCellEditorProps<T> {
7
+ value: unknown;
8
+ item: T;
9
+ column: IColumnDef<T>;
10
+ rowIndex: number;
11
+ editorType: 'text' | 'select' | 'checkbox' | 'richSelect' | 'date';
12
+ onCommit: (value: unknown) => void;
13
+ onCancel: () => void;
14
+ }
15
+ export interface GridRowProps {
16
+ item: unknown;
17
+ rowIndex: number;
18
+ rowId: string | number;
19
+ isSelected: boolean;
20
+ visibleCols: IColumnDef<unknown>[];
21
+ columnMeta: {
22
+ cellStyles: Record<string, React.CSSProperties>;
23
+ cellClasses: Record<string, string>;
24
+ };
25
+ renderCellContent: (item: unknown, col: IColumnDef<unknown>, rowIndex: number, colIdx: number) => React.ReactNode;
26
+ handleSingleRowClick: (e: React.MouseEvent<HTMLTableRowElement>) => void;
27
+ handleRowCheckboxChange: (rowId: string | number, checked: boolean, rowIndex: number, shiftKey: boolean) => void;
28
+ lastMouseShiftRef: React.MutableRefObject<boolean>;
29
+ hasCheckboxCol: boolean;
30
+ hasRowNumbersCol: boolean;
31
+ rowNumberOffset: number;
32
+ selectionRange: {
33
+ startRow: number;
34
+ endRow: number;
35
+ startCol: number;
36
+ endCol: number;
37
+ } | null;
38
+ activeCell: {
39
+ rowIndex: number;
40
+ columnIndex: number;
41
+ } | null;
42
+ cutRange: {
43
+ startRow: number;
44
+ endRow: number;
45
+ startCol: number;
46
+ endCol: number;
47
+ } | null;
48
+ copyRange: {
49
+ startRow: number;
50
+ endRow: number;
51
+ startCol: number;
52
+ endCol: number;
53
+ } | null;
54
+ isDragging: boolean;
55
+ editingRowId: string | number | null;
56
+ }
57
+ export interface CreateOGridComponents {
58
+ DataGridTable: React.ComponentType<IOGridDataGridProps<unknown>>;
59
+ ColumnChooser: React.ComponentType<IColumnChooserProps>;
60
+ PaginationControls: React.ComponentType<IPaginationControlsProps>;
61
+ /** Optional wrapper component + props (e.g. MUI Box with sx). */
62
+ containerComponent?: React.ElementType;
63
+ containerProps?: Record<string, unknown>;
64
+ }
65
+ /**
66
+ * Factory that creates a memoized, forwardRef OGrid component.
67
+ * Used by Radix and Fluent to avoid duplicating the same wiring code.
68
+ * Material uses its own OGrid because it adds MUI theme bridging (containerSx).
69
+ */
70
+ export declare function createOGrid(components: CreateOGridComponents): React.ForwardRefExoticComponent<IOGridProps<unknown> & React.RefAttributes<IOGridApi<unknown>>>;
@@ -46,6 +46,7 @@ export interface UseDataGridTableOrchestrationResult<T> {
46
46
  columnOrder: IOGridDataGridProps<T>['columnOrder'];
47
47
  columnReorder: IOGridDataGridProps<T>['columnReorder'];
48
48
  density: 'compact' | 'normal' | 'comfortable';
49
+ rowHeight: number | undefined;
49
50
  pinnedColumns: IOGridDataGridProps<T>['pinnedColumns'];
50
51
  currentPage: number;
51
52
  propPageSize: number;
@@ -16,10 +16,20 @@ export { MarchingAntsOverlay } from './components/MarchingAntsOverlay';
16
16
  export type { MarchingAntsOverlayProps } from './components/MarchingAntsOverlay';
17
17
  export { SideBar } from './components/SideBar';
18
18
  export type { SideBarProps, SideBarFilterColumn } from './components/SideBar';
19
+ export { BaseColumnHeaderMenu } from './components/BaseColumnHeaderMenu';
20
+ export type { BaseColumnHeaderMenuProps, ColumnHeaderMenuClassNames } from './components/BaseColumnHeaderMenu';
21
+ export { createOGrid } from './components/createOGrid';
22
+ export type { CreateOGridComponents, GridRowProps, InlineCellEditorProps } from './components/createOGrid';
19
23
  export { CellErrorBoundary } from './components/CellErrorBoundary';
20
24
  export type { CellErrorBoundaryProps } from './components/CellErrorBoundary';
21
25
  export { EmptyState } from './components/EmptyState';
22
26
  export type { EmptyStateProps } from './components/EmptyState';
27
+ export { BaseEmptyState } from './components/BaseEmptyState';
28
+ export type { BaseEmptyStateProps, BaseEmptyStateClassNames } from './components/BaseEmptyState';
29
+ export { BaseLoadingOverlay } from './components/BaseLoadingOverlay';
30
+ export type { BaseLoadingOverlayProps, BaseLoadingOverlayClassNames } from './components/BaseLoadingOverlay';
31
+ export { BaseDropIndicator } from './components/BaseDropIndicator';
32
+ export type { BaseDropIndicatorProps } from './components/BaseDropIndicator';
23
33
  export { DateFilterContent, getColumnHeaderFilterStateParams, getDateFilterContentProps, } from './components/ColumnHeaderFilterContent';
24
34
  export type { IColumnHeaderFilterProps, DateFilterContentProps, DateFilterClassNames, } from './components/ColumnHeaderFilterContent';
25
35
  export { escapeCsvValue, buildCsvHeader, buildCsvRows, exportToCsv, triggerCsvDownload, getCellValue, flattenColumns, buildHeaderRows, getFilterField, mergeFilter, deriveFilterOptionsFromData, getMultiSelectFilterFields, getStatusBarParts, getDataGridStatusBarConfig, GRID_CONTEXT_MENU_ITEMS, COLUMN_HEADER_MENU_ITEMS, getContextMenuHandlers, getColumnHeaderMenuItems, formatShortcut, getPaginationViewModel, PAGE_SIZE_OPTIONS, MAX_PAGE_BUTTONS, getHeaderFilterConfig, getCellRenderDescriptor, isRowInRange, resolveCellDisplayContent, resolveCellStyle, buildInlineEditorProps, buildPopoverEditorProps, getCellInteractionProps, parseValue, numberParser, currencyParser, dateParser, emailParser, booleanParser, computeAggregations, processClientSideData, areGridRowPropsEqual, findCtrlArrowTarget, computeTabNavigation, rangesEqual, clampSelectionToBounds, computeAutoScrollSpeed, formatCellValueForTsv, formatSelectionAsTsv, parseTsvClipboard, UndoRedoStack, } from './utils';
@@ -72,6 +72,8 @@ interface IOGridBaseProps<T> {
72
72
  columnReorder?: boolean;
73
73
  /** Virtual scrolling configuration. When provided, only visible rows are rendered for large datasets. */
74
74
  virtualScroll?: IVirtualScrollConfig;
75
+ /** Fixed row height in pixels. Overrides default row height (36px). */
76
+ rowHeight?: number;
75
77
  /** Cell spacing/density preset. Controls cell padding throughout the grid. Default: 'normal'. */
76
78
  density?: 'compact' | 'normal' | 'comfortable';
77
79
  /** Fires once when the grid first renders with data (useful for restoring column state). */
@@ -110,6 +112,8 @@ export interface IOGridDataGridProps<T> {
110
112
  onColumnOrderChange?: (order: string[]) => void;
111
113
  /** Called when a column is resized by the user. */
112
114
  onColumnResized?: (columnId: string, width: number) => void;
115
+ /** Called when user requests autosize for a single column (with measured width). */
116
+ onAutosizeColumn?: (columnId: string, width: number) => void;
113
117
  /** Called when a column is pinned or unpinned. */
114
118
  onColumnPinned?: (columnId: string, pinned: 'left' | 'right' | null) => void;
115
119
  /** Runtime pin overrides (from restored state or programmatic changes). */
@@ -157,6 +161,8 @@ export interface IOGridDataGridProps<T> {
157
161
  columnReorder?: boolean;
158
162
  /** Virtual scrolling configuration. When provided, only visible rows are rendered for large datasets. */
159
163
  virtualScroll?: IVirtualScrollConfig;
164
+ /** Fixed row height in pixels. Overrides default row height (36px). */
165
+ rowHeight?: number;
160
166
  /** Cell spacing/density preset. Controls cell padding throughout the grid. Default: 'normal'. */
161
167
  density?: 'compact' | 'normal' | 'comfortable';
162
168
  /** Called when a cell renderer or custom editor throws an error. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/ogrid-react",
3
- "version": "2.0.18",
3
+ "version": "2.0.21",
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",
@@ -36,7 +36,7 @@
36
36
  "node": ">=18"
37
37
  },
38
38
  "dependencies": {
39
- "@alaarab/ogrid-core": "2.0.18"
39
+ "@alaarab/ogrid-core": "2.0.21"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@tanstack/react-virtual": "^3.0.0",