@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.
- package/dist/esm/components/OGridLayout.js +1 -0
- package/dist/esm/hooks/useActiveCell.js +5 -0
- package/dist/esm/hooks/useColumnResize.js +45 -37
- package/dist/esm/hooks/useDataGridState.js +17 -9
- package/dist/esm/hooks/useOGrid.js +29 -1
- package/dist/esm/utils/aggregationUtils.js +3 -1
- package/dist/esm/utils/statusBarHelpers.js +4 -2
- package/dist/types/components/StatusBar.d.ts +1 -0
- package/dist/types/types/dataGridTypes.d.ts +12 -0
- package/dist/types/utils/statusBarHelpers.d.ts +2 -0
- package/package.json +1 -1
|
@@ -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
|
|
1
|
+
import { useCallback, useRef } from 'react';
|
|
2
2
|
export function useColumnResize({ columnSizingOverrides, setColumnSizingOverrides, minWidth = 80, defaultWidth = 120, onColumnResized, }) {
|
|
3
|
-
const
|
|
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
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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