@alaarab/ogrid-core 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,7 @@ const footerStripStyle = {
35
35
  const gridAreaFlexStyle = {
36
36
  width: '100%',
37
37
  minWidth: 0,
38
+ minHeight: 0,
38
39
  flex: 1,
39
40
  display: 'flex',
40
41
  };
@@ -17,6 +17,11 @@ export function useActiveCell(wrapperRef, editingCell) {
17
17
  const cell = wrapperRef.current.querySelector(selector);
18
18
  if (cell) {
19
19
  if (typeof cell.scrollIntoView === 'function') {
20
+ // Account for sticky <thead> so scrollIntoView doesn't leave
21
+ // the cell hidden behind the header.
22
+ const thead = wrapperRef.current.querySelector('thead');
23
+ const headerHeight = thead ? thead.getBoundingClientRect().height : 0;
24
+ cell.style.scrollMarginTop = `${headerHeight}px`;
20
25
  cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
21
26
  }
22
27
  if (document.activeElement !== cell && typeof cell.focus === 'function') {
@@ -1,50 +1,58 @@
1
- import { useCallback, useRef, useEffect } from 'react';
1
+ import { useCallback, useRef } from 'react';
2
2
  export function useColumnResize({ columnSizingOverrides, setColumnSizingOverrides, minWidth = 80, defaultWidth = 120, onColumnResized, }) {
3
- const resizingRef = useRef(null);
3
+ const rafRef = useRef(0);
4
+ const onColumnResizedRef = useRef(onColumnResized);
5
+ onColumnResizedRef.current = onColumnResized;
4
6
  const handleResizeStart = useCallback((e, col) => {
5
7
  e.preventDefault();
6
8
  e.stopPropagation();
7
- const currentWidth = columnSizingOverrides[col.columnId]?.widthPx
9
+ const startX = e.clientX;
10
+ const columnId = col.columnId;
11
+ const startWidth = columnSizingOverrides[columnId]?.widthPx
8
12
  ?? col.idealWidth
9
13
  ?? col.defaultWidth
10
14
  ?? defaultWidth;
11
- resizingRef.current = {
12
- columnId: col.columnId,
13
- startX: e.clientX,
14
- startWidth: currentWidth,
15
+ let latestWidth = startWidth;
16
+ // Lock cursor and prevent text selection during drag
17
+ const prevCursor = document.body.style.cursor;
18
+ const prevUserSelect = document.body.style.userSelect;
19
+ document.body.style.cursor = 'col-resize';
20
+ document.body.style.userSelect = 'none';
21
+ const flushWidth = () => {
22
+ setColumnSizingOverrides((prev) => ({
23
+ ...prev,
24
+ [columnId]: { widthPx: latestWidth },
25
+ }));
15
26
  };
16
- }, [columnSizingOverrides, defaultWidth]);
17
- const handleResizeMove = useCallback((e) => {
18
- if (!resizingRef.current)
19
- return;
20
- const { columnId, startX, startWidth } = resizingRef.current;
21
- const deltaX = e.clientX - startX;
22
- const newWidth = Math.max(minWidth, startWidth + deltaX);
23
- setColumnSizingOverrides((prev) => ({
24
- ...prev,
25
- [columnId]: { widthPx: newWidth },
26
- }));
27
- }, [setColumnSizingOverrides, minWidth]);
28
- const handleResizeEnd = useCallback(() => {
29
- if (resizingRef.current && onColumnResized) {
30
- const { columnId } = resizingRef.current;
31
- const width = columnSizingOverrides[columnId]?.widthPx;
32
- if (width != null) {
33
- onColumnResized(columnId, width);
27
+ const onMove = (moveEvent) => {
28
+ const deltaX = moveEvent.clientX - startX;
29
+ latestWidth = Math.max(minWidth, startWidth + deltaX);
30
+ if (!rafRef.current) {
31
+ rafRef.current = requestAnimationFrame(() => {
32
+ rafRef.current = 0;
33
+ flushWidth();
34
+ });
35
+ }
36
+ };
37
+ const onUp = () => {
38
+ document.removeEventListener('mousemove', onMove);
39
+ document.removeEventListener('mouseup', onUp);
40
+ // Restore cursor and user-select
41
+ document.body.style.cursor = prevCursor;
42
+ document.body.style.userSelect = prevUserSelect;
43
+ // Cancel pending RAF and flush final width synchronously
44
+ if (rafRef.current) {
45
+ cancelAnimationFrame(rafRef.current);
46
+ rafRef.current = 0;
47
+ }
48
+ flushWidth();
49
+ if (onColumnResizedRef.current) {
50
+ onColumnResizedRef.current(columnId, latestWidth);
34
51
  }
35
- }
36
- resizingRef.current = null;
37
- }, [onColumnResized, columnSizingOverrides]);
38
- useEffect(() => {
39
- const handleMouseMove = (e) => handleResizeMove(e);
40
- const handleMouseUp = () => handleResizeEnd();
41
- document.addEventListener('mousemove', handleMouseMove);
42
- document.addEventListener('mouseup', handleMouseUp);
43
- return () => {
44
- document.removeEventListener('mousemove', handleMouseMove);
45
- document.removeEventListener('mouseup', handleMouseUp);
46
52
  };
47
- }, [handleResizeMove, handleResizeEnd]);
53
+ document.addEventListener('mousemove', onMove);
54
+ document.addEventListener('mouseup', onUp);
55
+ }, [columnSizingOverrides, defaultWidth, minWidth, setColumnSizingOverrides]);
48
56
  const getColumnWidth = useCallback((col) => {
49
57
  return columnSizingOverrides[col.columnId]?.widthPx
50
58
  ?? col.idealWidth
@@ -23,12 +23,25 @@ const NOOP_CTX = (_e) => { };
23
23
  */
24
24
  export function useDataGridState(params) {
25
25
  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, } = props;
26
+ const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, statusBar, emptyState, editable, cellSelection: cellSelectionProp, onCellValueChanged: onCellValueChangedProp, initialColumnWidths, onColumnResized, pinnedColumns, } = props;
27
27
  const cellSelection = cellSelectionProp !== false;
28
28
  // Wrap onCellValueChanged with undo/redo tracking — all edits are recorded automatically
29
29
  const undoRedo = useUndoRedo({ onCellValueChanged: onCellValueChangedProp });
30
30
  const onCellValueChanged = undoRedo.onCellValueChanged;
31
- const flatColumns = useMemo(() => flattenColumns(columns), [columns]);
31
+ const flatColumnsRaw = useMemo(() => flattenColumns(columns), [columns]);
32
+ // Apply runtime pin overrides (from applyColumnState or programmatic changes)
33
+ const flatColumns = useMemo(() => {
34
+ if (!pinnedColumns || Object.keys(pinnedColumns).length === 0)
35
+ return flatColumnsRaw;
36
+ return flatColumnsRaw.map((col) => {
37
+ const override = pinnedColumns[col.columnId];
38
+ if (override && col.pinned !== override) {
39
+ return { ...col, pinned: override };
40
+ }
41
+ // If col was pinned by definition but not in overrides, keep original
42
+ return col;
43
+ });
44
+ }, [flatColumnsRaw, pinnedColumns]);
32
45
  const visibleCols = useMemo(() => {
33
46
  const filtered = visibleColumns
34
47
  ? flatColumns.filter((c) => visibleColumns.has(c.columnId))
@@ -159,13 +172,8 @@ export function useDataGridState(params) {
159
172
  const minTableWidth = useMemo(() => {
160
173
  const PADDING = 16;
161
174
  const checkboxW = hasCheckboxCol ? 48 : 0;
162
- return visibleCols.reduce((sum, c) => {
163
- // Use the widest explicit width: resize override > idealWidth > defaultWidth > minWidth > 80
164
- const override = columnSizingOverrides[c.columnId];
165
- const w = override?.widthPx ?? c.idealWidth ?? c.defaultWidth ?? c.minWidth ?? 80;
166
- return sum + w + PADDING;
167
- }, checkboxW);
168
- }, [visibleCols, hasCheckboxCol, columnSizingOverrides]);
175
+ return visibleCols.reduce((sum, c) => sum + (c.minWidth ?? 80) + PADDING, checkboxW);
176
+ }, [visibleCols, hasCheckboxCol]);
169
177
  useEffect(() => {
170
178
  const colIds = new Set(flatColumns.map((c) => c.columnId));
171
179
  setColumnSizingOverrides((prev) => {
@@ -6,7 +6,7 @@ import { useFilterOptions } from './useFilterOptions';
6
6
  import { useSideBarState } from './useSideBarState';
7
7
  const DEFAULT_PAGE_SIZE = 25;
8
8
  export function useOGrid(props, ref) {
9
- 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, freezeRows, freezeCols, defaultPageSize = DEFAULT_PAGE_SIZE, defaultSortBy, defaultSortDirection = 'asc', toolbar, emptyState, entityLabelPlural = 'items', className, title, layoutMode = 'fill', suppressHorizontalScroll, editable, cellSelection, onCellValueChanged, onUndo, onRedo, canUndo, canRedo, rowSelection = 'none', selectedRows, onSelectionChange, statusBar, pageSizeOptions, sideBar, onError, columnChooser: columnChooserProp, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, } = props;
9
+ 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, emptyState, entityLabelPlural = 'items', className, title, 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;
10
10
  // Resolve column chooser placement
11
11
  const columnChooserPlacement = columnChooserProp === false ? 'none'
12
12
  : columnChooserProp === 'sidebar' ? 'sidebar'
@@ -36,6 +36,7 @@ export function useOGrid(props, ref) {
36
36
  return new Set(visible.length > 0 ? visible : columns.map((c) => c.columnId));
37
37
  });
38
38
  const [columnWidthOverrides, setColumnWidthOverrides] = useState({});
39
+ const [pinnedOverrides, setPinnedOverrides] = useState({});
39
40
  const page = controlledPage ?? internalPage;
40
41
  const pageSize = controlledPageSize ?? internalPageSize;
41
42
  const sort = controlledSort ?? internalSort;
@@ -260,6 +261,14 @@ export function useOGrid(props, ref) {
260
261
  const displayTotalCount = isClientSide && clientItemsAndTotal
261
262
  ? clientItemsAndTotal.totalCount
262
263
  : serverTotalCount;
264
+ // Fire onFirstDataRendered once when the grid first has data
265
+ const firstDataRenderedRef = useRef(false);
266
+ useEffect(() => {
267
+ if (!firstDataRenderedRef.current && displayItems.length > 0) {
268
+ firstDataRenderedRef.current = true;
269
+ onFirstDataRendered?.();
270
+ }
271
+ }, [displayItems.length, onFirstDataRendered]);
263
272
  useImperativeHandle(ref, () => ({
264
273
  setRowData: (d) => {
265
274
  if (!isServerSide)
@@ -272,6 +281,7 @@ export function useOGrid(props, ref) {
272
281
  columnOrder: columnOrder ?? undefined,
273
282
  columnWidths: Object.keys(columnWidthOverrides).length > 0 ? columnWidthOverrides : undefined,
274
283
  filters: Object.keys(filters).length > 0 ? filters : undefined,
284
+ pinnedColumns: Object.keys(pinnedOverrides).length > 0 ? pinnedOverrides : undefined,
275
285
  }),
276
286
  applyColumnState: (state) => {
277
287
  if (state.visibleColumns) {
@@ -289,6 +299,9 @@ export function useOGrid(props, ref) {
289
299
  if (state.filters) {
290
300
  setFilters(state.filters);
291
301
  }
302
+ if (state.pinnedColumns) {
303
+ setPinnedOverrides(state.pinnedColumns);
304
+ }
292
305
  },
293
306
  setFilterModel: setFilters,
294
307
  getSelectedRows: () => Array.from(effectiveSelectedRows),
@@ -318,6 +331,7 @@ export function useOGrid(props, ref) {
318
331
  sort,
319
332
  columnOrder,
320
333
  columnWidthOverrides,
334
+ pinnedOverrides,
321
335
  filters,
322
336
  setFilters,
323
337
  setSort,
@@ -350,6 +364,7 @@ export function useOGrid(props, ref) {
350
364
  totalCount: totalData,
351
365
  filteredCount: hasActiveFilters ? filteredData : undefined,
352
366
  selectedCount: effectiveSelectedRows.size,
367
+ suppressRowCount: true, // OGrid always has pagination which shows the total
353
368
  };
354
369
  }, [
355
370
  statusBar,
@@ -364,6 +379,17 @@ export function useOGrid(props, ref) {
364
379
  setColumnWidthOverrides((prev) => ({ ...prev, [columnId]: width }));
365
380
  onColumnResized?.(columnId, width);
366
381
  }, [onColumnResized]);
382
+ const handleColumnPinned = useCallback((columnId, pinned) => {
383
+ setPinnedOverrides((prev) => {
384
+ if (pinned === null) {
385
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
386
+ const { [columnId]: _, ...rest } = prev;
387
+ return rest;
388
+ }
389
+ return { ...prev, [columnId]: pinned };
390
+ });
391
+ onColumnPinned?.(columnId, pinned);
392
+ }, [onColumnPinned]);
367
393
  // --- Side bar ---
368
394
  const sideBarState = useSideBarState({ config: sideBar });
369
395
  const filterableColumns = useMemo(() => columns
@@ -425,6 +451,8 @@ export function useOGrid(props, ref) {
425
451
  columnOrder,
426
452
  onColumnOrderChange,
427
453
  onColumnResized: handleColumnResized,
454
+ onColumnPinned: handleColumnPinned,
455
+ pinnedColumns: pinnedOverrides,
428
456
  initialColumnWidths: columnWidthOverrides,
429
457
  freezeRows,
430
458
  freezeCols,
@@ -19,7 +19,9 @@ export function computeAggregations(items, visibleCols, selectionRange) {
19
19
  const item = items[r];
20
20
  const col = visibleCols[c];
21
21
  const raw = getCellValue(item, col);
22
- const num = typeof raw === 'number' ? raw : parseFloat(String(raw));
22
+ // Use Number() instead of parseFloat() so date strings like "2020-08-22"
23
+ // return NaN instead of partially parsing to 2020
24
+ const num = typeof raw === 'number' ? raw : Number(raw);
23
25
  if (!isNaN(num) && isFinite(num)) {
24
26
  numericValues.push(num);
25
27
  }
@@ -2,9 +2,11 @@
2
2
  * Returns an array of status bar parts (Rows, Filtered, Selected) for consistent rendering across packages.
3
3
  */
4
4
  export function getStatusBarParts(input) {
5
- const { totalCount, filteredCount, selectedCount, selectedCellCount, aggregation } = input;
5
+ const { totalCount, filteredCount, selectedCount, selectedCellCount, aggregation, suppressRowCount } = input;
6
6
  const parts = [];
7
- parts.push({ key: 'total', label: 'Rows:', value: totalCount });
7
+ if (!suppressRowCount) {
8
+ parts.push({ key: 'total', label: 'Rows:', value: totalCount });
9
+ }
8
10
  if (filteredCount !== undefined && filteredCount !== totalCount) {
9
11
  parts.push({ key: 'filtered', label: 'Filtered:', value: filteredCount });
10
12
  }
@@ -18,6 +18,7 @@ export interface StatusBarProps {
18
18
  max: number;
19
19
  count: number;
20
20
  } | null;
21
+ suppressRowCount?: boolean;
21
22
  classNames?: StatusBarClassNames;
22
23
  }
23
24
  export declare function StatusBar({ classNames, ...rest }: StatusBarProps): React.ReactElement;
@@ -64,6 +64,8 @@ export interface IGridColumnState {
64
64
  columnWidths?: Record<string, number>;
65
65
  /** Active filters. */
66
66
  filters?: IFilters;
67
+ /** Pinned columns (column id -> 'left' | 'right'). */
68
+ pinnedColumns?: Record<string, 'left' | 'right'>;
67
69
  }
68
70
  /** Row selection mode. */
69
71
  export type RowSelectionMode = 'none' | 'single' | 'multiple';
@@ -92,6 +94,8 @@ export interface IStatusBarProps {
92
94
  max: number;
93
95
  count: number;
94
96
  } | null;
97
+ /** When true, hides the "Rows: X" label (e.g. when pagination already shows it). */
98
+ suppressRowCount?: boolean;
95
99
  }
96
100
  /** Identifies a cell for keyboard navigation. */
97
101
  export interface IActiveCell {
@@ -168,6 +172,8 @@ export interface IOGridProps<T> {
168
172
  onColumnOrderChange?: (order: string[]) => void;
169
173
  /** Called when a column is resized by the user. */
170
174
  onColumnResized?: (columnId: string, width: number) => void;
175
+ /** Called when a column is pinned or unpinned. */
176
+ onColumnPinned?: (columnId: string, pinned: 'left' | 'right' | null) => void;
171
177
  freezeRows?: number;
172
178
  freezeCols?: number;
173
179
  editable?: boolean;
@@ -206,6 +212,8 @@ export interface IOGridProps<T> {
206
212
  sideBar?: boolean | ISideBarDef;
207
213
  /** Page size options shown in the pagination dropdown. Default: [10, 20, 50, 100]. */
208
214
  pageSizeOptions?: number[];
215
+ /** Fires once when the grid first renders with data (useful for restoring column state). */
216
+ onFirstDataRendered?: () => void;
209
217
  /** Called when server-side fetchPage fails. */
210
218
  onError?: (error: unknown) => void;
211
219
  'aria-label'?: string;
@@ -225,6 +233,10 @@ export interface IOGridDataGridProps<T> {
225
233
  onColumnOrderChange?: (order: string[]) => void;
226
234
  /** Called when a column is resized by the user. */
227
235
  onColumnResized?: (columnId: string, width: number) => void;
236
+ /** Called when a column is pinned or unpinned. */
237
+ onColumnPinned?: (columnId: string, pinned: 'left' | 'right' | null) => void;
238
+ /** Runtime pin overrides (from restored state or programmatic changes). */
239
+ pinnedColumns?: Record<string, 'left' | 'right'>;
228
240
  /** Initial column width overrides (from restored state). */
229
241
  initialColumnWidths?: Record<string, number>;
230
242
  /** Number of rows to freeze (sticky), e.g. 1 = header row. */
@@ -19,6 +19,8 @@ export interface StatusBarPartsInput {
19
19
  max: number;
20
20
  count: number;
21
21
  } | null;
22
+ /** When true, hides the "Rows: X" label (e.g. when pagination already shows it). */
23
+ suppressRowCount?: boolean;
22
24
  }
23
25
  /**
24
26
  * Returns an array of status bar parts (Rows, Filtered, Selected) for consistent rendering across packages.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/ogrid-core",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "OGrid core – framework-agnostic types, hooks, and utilities for OGrid data tables.",
5
5
  "main": "dist/esm/index.js",
6
6
  "module": "dist/esm/index.js",