@alaarab/ogrid-core 1.8.1 → 1.9.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/README.md +42 -31
- package/dist/esm/components/BaseInlineCellEditor.js +112 -0
- package/dist/esm/components/CellErrorBoundary.js +43 -0
- package/dist/esm/components/EmptyState.js +19 -0
- package/dist/esm/components/GridContextMenu.js +4 -3
- package/dist/esm/components/MarchingAntsOverlay.js +6 -5
- package/dist/esm/components/OGridLayout.js +7 -6
- package/dist/esm/components/SideBar.js +66 -44
- package/dist/esm/constants.js +11 -0
- package/dist/esm/hooks/index.js +6 -0
- package/dist/esm/hooks/useActiveCell.js +40 -11
- package/dist/esm/hooks/useCellEditing.js +4 -0
- package/dist/esm/hooks/useCellSelection.js +58 -20
- package/dist/esm/hooks/useClipboard.js +36 -36
- package/dist/esm/hooks/useColumnHeaderFilterState.js +71 -119
- package/dist/esm/hooks/useColumnResize.js +27 -4
- package/dist/esm/hooks/useContextMenu.js +9 -5
- package/dist/esm/hooks/useDataGridState.js +110 -162
- package/dist/esm/hooks/useDateFilterState.js +34 -0
- package/dist/esm/hooks/useFillHandle.js +7 -2
- package/dist/esm/hooks/useFilterOptions.js +5 -5
- package/dist/esm/hooks/useKeyboardNavigation.js +18 -34
- package/dist/esm/hooks/useLatestRef.js +11 -0
- package/dist/esm/hooks/useMultiSelectFilterState.js +59 -0
- package/dist/esm/hooks/useOGrid.js +71 -18
- package/dist/esm/hooks/usePeopleFilterState.js +68 -0
- package/dist/esm/hooks/useRichSelectState.js +5 -0
- package/dist/esm/hooks/useRowSelection.js +14 -4
- package/dist/esm/hooks/useSideBarState.js +5 -0
- package/dist/esm/hooks/useTableLayout.js +77 -0
- package/dist/esm/hooks/useTextFilterState.js +25 -0
- package/dist/esm/hooks/useUndoRedo.js +6 -5
- package/dist/esm/index.js +7 -2
- package/dist/esm/utils/clientSideData.js +25 -12
- package/dist/esm/utils/columnUtils.js +6 -0
- package/dist/esm/utils/gridRowComparator.js +68 -0
- package/dist/esm/utils/index.js +1 -0
- package/dist/esm/utils/ogridHelpers.js +2 -1
- package/dist/esm/utils/paginationHelpers.js +7 -1
- package/dist/types/components/BaseInlineCellEditor.d.ts +33 -0
- package/dist/types/components/CellErrorBoundary.d.ts +25 -0
- package/dist/types/components/EmptyState.d.ts +26 -0
- package/dist/types/constants.d.ts +11 -0
- package/dist/types/hooks/index.d.ts +12 -1
- package/dist/types/hooks/useCellEditing.d.ts +4 -0
- package/dist/types/hooks/useCellSelection.d.ts +5 -0
- package/dist/types/hooks/useClipboard.d.ts +5 -0
- package/dist/types/hooks/useColumnHeaderFilterState.d.ts +1 -0
- package/dist/types/hooks/useColumnResize.d.ts +5 -0
- package/dist/types/hooks/useContextMenu.d.ts +6 -2
- package/dist/types/hooks/useDataGridState.d.ts +1 -0
- package/dist/types/hooks/useDateFilterState.d.ts +19 -0
- package/dist/types/hooks/useFillHandle.d.ts +5 -0
- package/dist/types/hooks/useKeyboardNavigation.d.ts +38 -25
- package/dist/types/hooks/useLatestRef.d.ts +6 -0
- package/dist/types/hooks/useMultiSelectFilterState.d.ts +24 -0
- package/dist/types/hooks/useOGrid.d.ts +30 -9
- package/dist/types/hooks/usePeopleFilterState.d.ts +25 -0
- package/dist/types/hooks/useRichSelectState.d.ts +5 -0
- package/dist/types/hooks/useRowSelection.d.ts +5 -0
- package/dist/types/hooks/useSideBarState.d.ts +5 -0
- package/dist/types/hooks/useTableLayout.d.ts +27 -0
- package/dist/types/hooks/useTextFilterState.d.ts +16 -0
- package/dist/types/hooks/useUndoRedo.d.ts +3 -1
- package/dist/types/index.d.ts +11 -4
- package/dist/types/types/columnTypes.d.ts +2 -3
- package/dist/types/types/dataGridTypes.d.ts +32 -4
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/utils/gridRowComparator.d.ts +49 -0
- package/dist/types/utils/index.d.ts +2 -0
- package/package.json +1 -1
|
@@ -4,6 +4,13 @@ import { flattenColumns, processClientSideData } from '../utils';
|
|
|
4
4
|
import { useFilterOptions } from './useFilterOptions';
|
|
5
5
|
import { useSideBarState } from './useSideBarState';
|
|
6
6
|
const DEFAULT_PAGE_SIZE = 25;
|
|
7
|
+
const EMPTY_LOADING_OPTIONS = {};
|
|
8
|
+
/**
|
|
9
|
+
* Top-level orchestration hook for OGrid: manages pagination, sorting, filtering, column visibility, and sidebar.
|
|
10
|
+
* @param props - All OGrid props (columns, data, callbacks, feature flags).
|
|
11
|
+
* @param ref - Forwarded ref for imperative API (refresh, export, applyColumnState).
|
|
12
|
+
* @returns Grouped props for DataGridTable, pagination controls, column chooser, layout, and filters.
|
|
13
|
+
*/
|
|
7
14
|
export function useOGrid(props, ref) {
|
|
8
15
|
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;
|
|
9
16
|
// Resolve column chooser placement
|
|
@@ -15,9 +22,6 @@ export function useOGrid(props, ref) {
|
|
|
15
22
|
const isClientSide = !isServerSide;
|
|
16
23
|
const [internalData, setInternalData] = useState([]);
|
|
17
24
|
const [internalLoading, setInternalLoading] = useState(false);
|
|
18
|
-
if (data != null && dataSource != null) {
|
|
19
|
-
console.error('OGrid: pass either data or dataSource, not both.');
|
|
20
|
-
}
|
|
21
25
|
const displayData = data ?? internalData;
|
|
22
26
|
const displayLoading = controlledLoading ?? internalLoading;
|
|
23
27
|
const defaultSortField = defaultSortBy ?? columns[0]?.columnId ?? '';
|
|
@@ -98,11 +102,12 @@ export function useOGrid(props, ref) {
|
|
|
98
102
|
const multiSelectFilterFields = useMemo(() => getMultiSelectFilterFields(columns), [columns]);
|
|
99
103
|
const filterOptionsSource = useMemo(() => dataSource ?? { fetchFilterOptions: undefined }, [dataSource]);
|
|
100
104
|
const { filterOptions: serverFilterOptions, loadingOptions: loadingFilterOptions } = useFilterOptions(filterOptionsSource, multiSelectFilterFields);
|
|
105
|
+
const hasServerFilterOptions = dataSource?.fetchFilterOptions != null;
|
|
101
106
|
const clientFilterOptions = useMemo(() => {
|
|
102
|
-
if (
|
|
107
|
+
if (hasServerFilterOptions)
|
|
103
108
|
return serverFilterOptions;
|
|
104
109
|
return deriveFilterOptionsFromData(displayData, columns);
|
|
105
|
-
}, [
|
|
110
|
+
}, [hasServerFilterOptions, displayData, columns, serverFilterOptions]);
|
|
106
111
|
// --- Client-side filtering & sorting ---
|
|
107
112
|
const clientItemsAndTotal = useMemo(() => {
|
|
108
113
|
if (!isClientSide)
|
|
@@ -126,6 +131,9 @@ export function useOGrid(props, ref) {
|
|
|
126
131
|
const [serverTotalCount, setServerTotalCount] = useState(0);
|
|
127
132
|
const [loading, setLoading] = useState(true);
|
|
128
133
|
const fetchIdRef = useRef(0);
|
|
134
|
+
// Ref counter to trigger server-side re-fetches
|
|
135
|
+
const refreshCounterRef = useRef(0);
|
|
136
|
+
const [refreshCounter, setRefreshCounter] = useState(0);
|
|
129
137
|
useEffect(() => {
|
|
130
138
|
if (!isServerSide || !dataSource) {
|
|
131
139
|
if (!isServerSide)
|
|
@@ -158,6 +166,7 @@ export function useOGrid(props, ref) {
|
|
|
158
166
|
if (id === fetchIdRef.current)
|
|
159
167
|
setLoading(false);
|
|
160
168
|
});
|
|
169
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
161
170
|
}, [
|
|
162
171
|
isServerSide,
|
|
163
172
|
dataSource,
|
|
@@ -167,6 +176,7 @@ export function useOGrid(props, ref) {
|
|
|
167
176
|
sort.direction,
|
|
168
177
|
filters,
|
|
169
178
|
onError,
|
|
179
|
+
refreshCounter,
|
|
170
180
|
]);
|
|
171
181
|
const displayItems = isClientSide && clientItemsAndTotal
|
|
172
182
|
? clientItemsAndTotal.items
|
|
@@ -239,6 +249,24 @@ export function useOGrid(props, ref) {
|
|
|
239
249
|
selectedItems: [],
|
|
240
250
|
});
|
|
241
251
|
},
|
|
252
|
+
clearFilters: () => setFilters({}),
|
|
253
|
+
clearSort: () => setSort({ field: defaultSortField, direction: defaultSortDirection }),
|
|
254
|
+
resetGridState: (options) => {
|
|
255
|
+
setFilters({});
|
|
256
|
+
setSort({ field: defaultSortField, direction: defaultSortDirection });
|
|
257
|
+
if (!options?.keepSelection) {
|
|
258
|
+
if (selectedRows === undefined)
|
|
259
|
+
setInternalSelectedRows(new Set());
|
|
260
|
+
onSelectionChange?.({ selectedRowIds: [], selectedItems: [] });
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
getDisplayedRows: () => displayItems,
|
|
264
|
+
refreshData: () => {
|
|
265
|
+
if (isServerSide) {
|
|
266
|
+
refreshCounterRef.current += 1;
|
|
267
|
+
setRefreshCounter(refreshCounterRef.current);
|
|
268
|
+
}
|
|
269
|
+
},
|
|
242
270
|
}), [
|
|
243
271
|
visibleColumns,
|
|
244
272
|
sort,
|
|
@@ -256,6 +284,8 @@ export function useOGrid(props, ref) {
|
|
|
256
284
|
displayItems,
|
|
257
285
|
getRowId,
|
|
258
286
|
onSelectionChange,
|
|
287
|
+
defaultSortField,
|
|
288
|
+
defaultSortDirection,
|
|
259
289
|
]);
|
|
260
290
|
// With discriminated union, any defined value is active (mergeFilter already strips empties)
|
|
261
291
|
const hasActiveFilters = useMemo(() => {
|
|
@@ -345,7 +375,9 @@ export function useOGrid(props, ref) {
|
|
|
345
375
|
handleFilterChange,
|
|
346
376
|
clientFilterOptions,
|
|
347
377
|
]);
|
|
348
|
-
const
|
|
378
|
+
const clearAllFilters = useCallback(() => setFilters({}), [setFilters]);
|
|
379
|
+
const isLoadingResolved = (isServerSide && loading) || displayLoading;
|
|
380
|
+
const dataGridProps = useMemo(() => ({
|
|
349
381
|
items: displayItems,
|
|
350
382
|
columns: columnsProp,
|
|
351
383
|
getRowId,
|
|
@@ -372,11 +404,11 @@ export function useOGrid(props, ref) {
|
|
|
372
404
|
selectedRows: effectiveSelectedRows,
|
|
373
405
|
onSelectionChange: handleSelectionChange,
|
|
374
406
|
statusBar: statusBarConfig,
|
|
375
|
-
isLoading:
|
|
407
|
+
isLoading: isLoadingResolved,
|
|
376
408
|
filters,
|
|
377
409
|
onFilterChange: handleFilterChange,
|
|
378
410
|
filterOptions: clientFilterOptions,
|
|
379
|
-
loadingFilterOptions: dataSource?.fetchFilterOptions ? loadingFilterOptions :
|
|
411
|
+
loadingFilterOptions: dataSource?.fetchFilterOptions ? loadingFilterOptions : EMPTY_LOADING_OPTIONS,
|
|
380
412
|
peopleSearch: dataSource?.searchPeople,
|
|
381
413
|
getUserByEmail: dataSource?.getUserByEmail,
|
|
382
414
|
layoutMode,
|
|
@@ -385,30 +417,51 @@ export function useOGrid(props, ref) {
|
|
|
385
417
|
'aria-labelledby': ariaLabelledBy,
|
|
386
418
|
emptyState: {
|
|
387
419
|
hasActiveFilters,
|
|
388
|
-
onClearAll:
|
|
420
|
+
onClearAll: clearAllFilters,
|
|
389
421
|
message: emptyState?.message,
|
|
390
422
|
render: emptyState?.render,
|
|
391
423
|
},
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
424
|
+
}), [
|
|
425
|
+
displayItems, columnsProp, getRowId, sort.field, sort.direction, handleSort,
|
|
426
|
+
visibleColumns, columnOrder, onColumnOrderChange, handleColumnResized,
|
|
427
|
+
handleColumnPinned, pinnedOverrides, columnWidthOverrides, freezeRows, freezeCols,
|
|
428
|
+
editable, cellSelection, onCellValueChanged, onUndo, onRedo, canUndo, canRedo,
|
|
429
|
+
rowSelection, effectiveSelectedRows, handleSelectionChange, statusBarConfig,
|
|
430
|
+
isLoadingResolved, filters, handleFilterChange, clientFilterOptions, dataSource,
|
|
431
|
+
loadingFilterOptions, layoutMode, suppressHorizontalScroll, ariaLabel, ariaLabelledBy,
|
|
432
|
+
hasActiveFilters, clearAllFilters, emptyState,
|
|
433
|
+
]);
|
|
434
|
+
const pagination = useMemo(() => ({
|
|
395
435
|
page,
|
|
396
436
|
pageSize,
|
|
397
437
|
displayTotalCount,
|
|
398
438
|
setPage,
|
|
399
439
|
setPageSize,
|
|
400
|
-
|
|
440
|
+
pageSizeOptions,
|
|
441
|
+
entityLabelPlural,
|
|
442
|
+
}), [page, pageSize, displayTotalCount, setPage, setPageSize, pageSizeOptions, entityLabelPlural]);
|
|
443
|
+
const columnChooser = useMemo(() => ({
|
|
444
|
+
columns: columnChooserColumns,
|
|
401
445
|
visibleColumns,
|
|
402
|
-
handleVisibilityChange,
|
|
403
|
-
columnChooserPlacement,
|
|
446
|
+
onVisibilityChange: handleVisibilityChange,
|
|
447
|
+
placement: columnChooserPlacement,
|
|
448
|
+
}), [columnChooserColumns, visibleColumns, handleVisibilityChange, columnChooserPlacement]);
|
|
449
|
+
const layout = useMemo(() => ({
|
|
404
450
|
toolbar,
|
|
405
451
|
toolbarBelow,
|
|
406
452
|
className,
|
|
407
|
-
entityLabelPlural,
|
|
408
453
|
emptyState,
|
|
454
|
+
sideBarProps,
|
|
455
|
+
}), [toolbar, toolbarBelow, className, emptyState, sideBarProps]);
|
|
456
|
+
const filtersResult = useMemo(() => ({
|
|
409
457
|
hasActiveFilters,
|
|
410
458
|
setFilters,
|
|
411
|
-
|
|
412
|
-
|
|
459
|
+
}), [hasActiveFilters, setFilters]);
|
|
460
|
+
return {
|
|
461
|
+
dataGridProps,
|
|
462
|
+
pagination,
|
|
463
|
+
columnChooser,
|
|
464
|
+
layout,
|
|
465
|
+
filters: filtersResult,
|
|
413
466
|
};
|
|
414
467
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* People filter state sub-hook for column header filters.
|
|
3
|
+
* Manages people search text, suggestions, loading state, input ref, and user select/clear handlers.
|
|
4
|
+
* Includes debounced people search effect.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
7
|
+
const PEOPLE_SEARCH_DEBOUNCE_MS = 300;
|
|
8
|
+
export function usePeopleFilterState(params) {
|
|
9
|
+
const { selectedUser, onUserChange, peopleSearch, isFilterOpen, filterType } = params;
|
|
10
|
+
const peopleInputRef = useRef(null);
|
|
11
|
+
const peopleSearchTimeoutRef = useRef(undefined);
|
|
12
|
+
const [peopleSuggestions, setPeopleSuggestions] = useState([]);
|
|
13
|
+
const [isPeopleLoading, setIsPeopleLoading] = useState(false);
|
|
14
|
+
const [peopleSearchText, setPeopleSearchText] = useState('');
|
|
15
|
+
// Sync temp state when popover opens
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (isFilterOpen) {
|
|
18
|
+
setPeopleSearchText('');
|
|
19
|
+
setPeopleSuggestions([]);
|
|
20
|
+
if (filterType === 'people') {
|
|
21
|
+
setTimeout(() => peopleInputRef.current?.focus(), 50);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}, [isFilterOpen, filterType]);
|
|
25
|
+
// People search with debounce
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!peopleSearch || !isFilterOpen || filterType !== 'people')
|
|
28
|
+
return;
|
|
29
|
+
if (peopleSearchTimeoutRef.current)
|
|
30
|
+
window.clearTimeout(peopleSearchTimeoutRef.current);
|
|
31
|
+
if (!peopleSearchText.trim()) {
|
|
32
|
+
setPeopleSuggestions([]);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setIsPeopleLoading(true);
|
|
36
|
+
peopleSearchTimeoutRef.current = window.setTimeout(async () => {
|
|
37
|
+
try {
|
|
38
|
+
const results = await peopleSearch(peopleSearchText);
|
|
39
|
+
setPeopleSuggestions(results.slice(0, 10));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
setPeopleSuggestions([]);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
setIsPeopleLoading(false);
|
|
46
|
+
}
|
|
47
|
+
}, PEOPLE_SEARCH_DEBOUNCE_MS);
|
|
48
|
+
return () => {
|
|
49
|
+
if (peopleSearchTimeoutRef.current)
|
|
50
|
+
window.clearTimeout(peopleSearchTimeoutRef.current);
|
|
51
|
+
};
|
|
52
|
+
}, [peopleSearchText, peopleSearch, isFilterOpen, filterType]);
|
|
53
|
+
const handleUserSelect = useCallback((user) => {
|
|
54
|
+
onUserChange?.(user);
|
|
55
|
+
}, [onUserChange]);
|
|
56
|
+
const handleClearUser = useCallback(() => {
|
|
57
|
+
onUserChange?.(undefined);
|
|
58
|
+
}, [onUserChange]);
|
|
59
|
+
return {
|
|
60
|
+
peopleSuggestions,
|
|
61
|
+
isPeopleLoading,
|
|
62
|
+
peopleSearchText,
|
|
63
|
+
setPeopleSearchText,
|
|
64
|
+
peopleInputRef,
|
|
65
|
+
handleUserSelect,
|
|
66
|
+
handleClearUser,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { useState, useCallback, useMemo } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Manages searchable rich select editor state with keyboard navigation (arrow keys, enter, escape).
|
|
4
|
+
* @param params - Values, format function, initial value, and commit/cancel callbacks.
|
|
5
|
+
* @returns Search text, filtered values, highlighted index, keyboard handler, and select function.
|
|
6
|
+
*/
|
|
2
7
|
export function useRichSelectState(params) {
|
|
3
8
|
const { values, formatValue, onCommit, onCancel } = params;
|
|
4
9
|
const [searchText, setSearchText] = useState('');
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useMemo } from 'react';
|
|
2
|
+
import { useLatestRef } from './useLatestRef';
|
|
3
|
+
/**
|
|
4
|
+
* Manages row selection state for single or multiple selection modes with shift-click range support.
|
|
5
|
+
* @param params - Items, getRowId, selection mode, controlled state, and selection change callback.
|
|
6
|
+
* @returns Selected row IDs, update function, checkbox handlers, and selection state booleans.
|
|
7
|
+
*/
|
|
2
8
|
export function useRowSelection(params) {
|
|
3
9
|
const { items, getRowId, rowSelection, controlledSelectedRows, onSelectionChange, } = params;
|
|
4
10
|
const [internalSelectedRows, setInternalSelectedRows] = useState(new Set());
|
|
@@ -18,19 +24,23 @@ export function useRowSelection(params) {
|
|
|
18
24
|
selectedItems: items.filter((item) => newSelectedIds.has(getRowId(item))),
|
|
19
25
|
});
|
|
20
26
|
}, [controlledSelectedRows, onSelectionChange, items, getRowId]);
|
|
27
|
+
// Read selectedRowIds via ref to avoid recreating this callback on every selection change
|
|
28
|
+
const selectedRowIdsRef = useLatestRef(selectedRowIds);
|
|
29
|
+
const itemsRef = useLatestRef(items);
|
|
21
30
|
const handleRowCheckboxChange = useCallback((rowId, checked, rowIndex, shiftKey) => {
|
|
22
31
|
if (rowSelection === 'single') {
|
|
23
32
|
updateSelection(checked ? new Set([rowId]) : new Set());
|
|
24
33
|
lastClickedRowRef.current = rowIndex;
|
|
25
34
|
return;
|
|
26
35
|
}
|
|
27
|
-
const next = new Set(
|
|
36
|
+
const next = new Set(selectedRowIdsRef.current);
|
|
37
|
+
const currentItems = itemsRef.current;
|
|
28
38
|
if (shiftKey && lastClickedRowRef.current >= 0 && lastClickedRowRef.current !== rowIndex) {
|
|
29
39
|
const start = Math.min(lastClickedRowRef.current, rowIndex);
|
|
30
40
|
const end = Math.max(lastClickedRowRef.current, rowIndex);
|
|
31
41
|
for (let i = start; i <= end; i++) {
|
|
32
|
-
if (i <
|
|
33
|
-
const id = getRowId(
|
|
42
|
+
if (i < currentItems.length) {
|
|
43
|
+
const id = getRowId(currentItems[i]);
|
|
34
44
|
if (checked)
|
|
35
45
|
next.add(id);
|
|
36
46
|
else
|
|
@@ -46,7 +56,7 @@ export function useRowSelection(params) {
|
|
|
46
56
|
}
|
|
47
57
|
lastClickedRowRef.current = rowIndex;
|
|
48
58
|
updateSelection(next);
|
|
49
|
-
}, [rowSelection,
|
|
59
|
+
}, [rowSelection, getRowId, updateSelection]);
|
|
50
60
|
const handleSelectAll = useCallback((checked) => {
|
|
51
61
|
if (checked) {
|
|
52
62
|
updateSelection(new Set(items.map((item) => getRowId(item))));
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { useState, useCallback, useMemo } from 'react';
|
|
2
2
|
const DEFAULT_PANELS = ['columns', 'filters'];
|
|
3
|
+
/**
|
|
4
|
+
* Manages side bar panel state: enabled panels, active panel, position, and toggle/close handlers.
|
|
5
|
+
* @param params - Side bar config (boolean, ISideBarDef, or undefined).
|
|
6
|
+
* @returns Enabled flag, active panel, setters, panel list, position, open state, toggle, and close.
|
|
7
|
+
*/
|
|
3
8
|
export function useSideBarState(params) {
|
|
4
9
|
const { config } = params;
|
|
5
10
|
const isEnabled = config != null && config !== false;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { CHECKBOX_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH, CELL_PADDING } from '../constants';
|
|
3
|
+
/**
|
|
4
|
+
* Manages table layout: container width measurement, column sizing overrides,
|
|
5
|
+
* min/desired table width calculations.
|
|
6
|
+
*/
|
|
7
|
+
export function useTableLayout(params) {
|
|
8
|
+
const { wrapperRef, visibleCols, flatColumns, hasCheckboxCol, initialColumnWidths, onColumnResized, } = params;
|
|
9
|
+
// --- Container width measurement via ResizeObserver ---
|
|
10
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const el = wrapperRef.current;
|
|
13
|
+
if (!el)
|
|
14
|
+
return;
|
|
15
|
+
const measure = () => {
|
|
16
|
+
const rect = el.getBoundingClientRect();
|
|
17
|
+
const cs = window.getComputedStyle(el);
|
|
18
|
+
const borderX = (parseFloat(cs.borderLeftWidth || '0') || 0) +
|
|
19
|
+
(parseFloat(cs.borderRightWidth || '0') || 0);
|
|
20
|
+
setContainerWidth(Math.max(0, rect.width - borderX));
|
|
21
|
+
};
|
|
22
|
+
const ro = new ResizeObserver(measure);
|
|
23
|
+
ro.observe(el);
|
|
24
|
+
measure();
|
|
25
|
+
return () => ro.disconnect();
|
|
26
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
27
|
+
}, []); // wrapperRef excluded — refs are stable
|
|
28
|
+
// --- Column sizing overrides state ---
|
|
29
|
+
const [columnSizingOverrides, setColumnSizingOverrides] = useState(() => {
|
|
30
|
+
if (!initialColumnWidths)
|
|
31
|
+
return {};
|
|
32
|
+
const result = {};
|
|
33
|
+
for (const [id, width] of Object.entries(initialColumnWidths)) {
|
|
34
|
+
result[id] = { widthPx: width };
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
});
|
|
38
|
+
// --- Minimum table width calculation ---
|
|
39
|
+
const minTableWidth = useMemo(() => {
|
|
40
|
+
const checkboxW = hasCheckboxCol ? CHECKBOX_COLUMN_WIDTH : 0;
|
|
41
|
+
return visibleCols.reduce((sum, c) => sum + (c.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH) + CELL_PADDING, checkboxW);
|
|
42
|
+
}, [visibleCols, hasCheckboxCol]);
|
|
43
|
+
// --- Cleanup effect: remove overrides for columns that no longer exist ---
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const colIds = new Set(flatColumns.map((c) => c.columnId));
|
|
46
|
+
setColumnSizingOverrides((prev) => {
|
|
47
|
+
const next = { ...prev };
|
|
48
|
+
let changed = false;
|
|
49
|
+
for (const id of Object.keys(next)) {
|
|
50
|
+
if (!colIds.has(id)) {
|
|
51
|
+
delete next[id];
|
|
52
|
+
changed = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return changed ? next : prev;
|
|
56
|
+
});
|
|
57
|
+
}, [flatColumns]);
|
|
58
|
+
// --- Desired table width calculation ---
|
|
59
|
+
const desiredTableWidth = useMemo(() => {
|
|
60
|
+
const checkboxW = hasCheckboxCol ? CHECKBOX_COLUMN_WIDTH : 0;
|
|
61
|
+
return visibleCols.reduce((sum, c) => {
|
|
62
|
+
const override = columnSizingOverrides[c.columnId];
|
|
63
|
+
const w = override
|
|
64
|
+
? override.widthPx
|
|
65
|
+
: (c.idealWidth ?? c.defaultWidth ?? c.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH);
|
|
66
|
+
return sum + Math.max(c.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH, w) + CELL_PADDING;
|
|
67
|
+
}, checkboxW);
|
|
68
|
+
}, [visibleCols, columnSizingOverrides, hasCheckboxCol]);
|
|
69
|
+
return {
|
|
70
|
+
containerWidth,
|
|
71
|
+
minTableWidth,
|
|
72
|
+
desiredTableWidth,
|
|
73
|
+
columnSizingOverrides,
|
|
74
|
+
setColumnSizingOverrides,
|
|
75
|
+
onColumnResized,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text filter state sub-hook for column header filters.
|
|
3
|
+
* Manages temporary text value and apply/clear handlers.
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
6
|
+
export function useTextFilterState(params) {
|
|
7
|
+
const { textValue = '', onTextChange, isFilterOpen } = params;
|
|
8
|
+
const [tempTextValue, setTempTextValue] = useState(textValue);
|
|
9
|
+
// Sync temp state when popover opens
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (isFilterOpen) {
|
|
12
|
+
setTempTextValue(textValue);
|
|
13
|
+
}
|
|
14
|
+
}, [isFilterOpen, textValue]);
|
|
15
|
+
const handleTextApply = useCallback(() => {
|
|
16
|
+
onTextChange?.(tempTextValue.trim());
|
|
17
|
+
}, [onTextChange, tempTextValue]);
|
|
18
|
+
const handleTextClear = useCallback(() => setTempTextValue(''), []);
|
|
19
|
+
return {
|
|
20
|
+
tempTextValue,
|
|
21
|
+
setTempTextValue,
|
|
22
|
+
handleTextApply,
|
|
23
|
+
handleTextClear,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from 'react';
|
|
|
4
4
|
* Supports batch operations: changes between beginBatch/endBatch are one undo step.
|
|
5
5
|
*/
|
|
6
6
|
export function useUndoRedo(params) {
|
|
7
|
-
const { onCellValueChanged,
|
|
7
|
+
const { onCellValueChanged, maxUndoDepth = 100 } = params;
|
|
8
8
|
// Each history entry is an array of events (batch). Single edits are [event].
|
|
9
9
|
const historyRef = useRef([]);
|
|
10
10
|
const redoStackRef = useRef([]);
|
|
@@ -19,13 +19,13 @@ export function useUndoRedo(params) {
|
|
|
19
19
|
batchRef.current.push(event);
|
|
20
20
|
}
|
|
21
21
|
else {
|
|
22
|
-
historyRef.current = [...historyRef.current, [event]].slice(-
|
|
22
|
+
historyRef.current = [...historyRef.current, [event]].slice(-maxUndoDepth);
|
|
23
23
|
redoStackRef.current = [];
|
|
24
24
|
setHistoryLength(historyRef.current.length);
|
|
25
25
|
setRedoLength(0);
|
|
26
26
|
}
|
|
27
27
|
onCellValueChanged(event);
|
|
28
|
-
}, [onCellValueChanged,
|
|
28
|
+
}, [onCellValueChanged, maxUndoDepth]);
|
|
29
29
|
const beginBatch = useCallback(() => {
|
|
30
30
|
batchRef.current = [];
|
|
31
31
|
}, []);
|
|
@@ -34,11 +34,11 @@ export function useUndoRedo(params) {
|
|
|
34
34
|
batchRef.current = null;
|
|
35
35
|
if (!batch || batch.length === 0)
|
|
36
36
|
return;
|
|
37
|
-
historyRef.current = [...historyRef.current, batch].slice(-
|
|
37
|
+
historyRef.current = [...historyRef.current, batch].slice(-maxUndoDepth);
|
|
38
38
|
redoStackRef.current = [];
|
|
39
39
|
setHistoryLength(historyRef.current.length);
|
|
40
40
|
setRedoLength(0);
|
|
41
|
-
}, [
|
|
41
|
+
}, [maxUndoDepth]);
|
|
42
42
|
const undo = useCallback(() => {
|
|
43
43
|
if (!onCellValueChanged || historyRef.current.length === 0)
|
|
44
44
|
return;
|
|
@@ -78,5 +78,6 @@ export function useUndoRedo(params) {
|
|
|
78
78
|
canRedo: redoLength > 0,
|
|
79
79
|
beginBatch,
|
|
80
80
|
endBatch,
|
|
81
|
+
maxUndoDepth,
|
|
81
82
|
};
|
|
82
83
|
}
|
package/dist/esm/index.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
// Constants
|
|
2
|
+
export { CHECKBOX_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH, CELL_PADDING, GRID_BORDER_RADIUS, } from './constants';
|
|
1
3
|
export { toUserLike, isInSelectionRange, normalizeSelectionRange } from './types';
|
|
2
4
|
// Hooks
|
|
3
|
-
export { useFilterOptions, useOGrid, useActiveCell, useCellEditing, useContextMenu, useCellSelection, useClipboard, useRowSelection, useKeyboardNavigation, useUndoRedo, useDebounce, useFillHandle, useDataGridState, useColumnHeaderFilterState, useColumnChooserState, useInlineCellEditorState, useColumnResize, useRichSelectState, useSideBarState, } 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, useLatestRef, } from './hooks';
|
|
4
6
|
// Components
|
|
5
7
|
export { OGridLayout } from './components/OGridLayout';
|
|
6
8
|
export { StatusBar } from './components/StatusBar';
|
|
9
|
+
export { BaseInlineCellEditor, editorWrapperStyle, editorInputStyle, richSelectWrapperStyle, richSelectDropdownStyle, richSelectOptionStyle, richSelectOptionHighlightedStyle, richSelectNoMatchesStyle, selectEditorStyle, } from './components/BaseInlineCellEditor';
|
|
7
10
|
export { GridContextMenu } from './components/GridContextMenu';
|
|
8
11
|
export { MarchingAntsOverlay } from './components/MarchingAntsOverlay';
|
|
9
12
|
export { SideBar } from './components/SideBar';
|
|
13
|
+
export { CellErrorBoundary } from './components/CellErrorBoundary';
|
|
14
|
+
export { EmptyState } from './components/EmptyState';
|
|
10
15
|
// Utilities
|
|
11
|
-
export { escapeCsvValue, buildCsvHeader, buildCsvRows, exportToCsv, triggerCsvDownload, getCellValue, flattenColumns, buildHeaderRows, getFilterField, mergeFilter, deriveFilterOptionsFromData, getMultiSelectFilterFields, getStatusBarParts, getDataGridStatusBarConfig, GRID_CONTEXT_MENU_ITEMS, getContextMenuHandlers, formatShortcut, getPaginationViewModel, PAGE_SIZE_OPTIONS, MAX_PAGE_BUTTONS, getHeaderFilterConfig, getCellRenderDescriptor, isRowInRange, resolveCellDisplayContent, resolveCellStyle, buildInlineEditorProps, buildPopoverEditorProps, getCellInteractionProps, parseValue, numberParser, currencyParser, dateParser, emailParser, booleanParser, computeAggregations, processClientSideData, } from './utils';
|
|
16
|
+
export { escapeCsvValue, buildCsvHeader, buildCsvRows, exportToCsv, triggerCsvDownload, getCellValue, flattenColumns, buildHeaderRows, getFilterField, mergeFilter, deriveFilterOptionsFromData, getMultiSelectFilterFields, getStatusBarParts, getDataGridStatusBarConfig, GRID_CONTEXT_MENU_ITEMS, getContextMenuHandlers, 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, } from './utils';
|
|
@@ -12,33 +12,37 @@ import { getFilterField } from './ogridHelpers';
|
|
|
12
12
|
* @returns Filtered and sorted array
|
|
13
13
|
*/
|
|
14
14
|
export function processClientSideData(data, columns, filters, sortBy, sortDirection) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
columns.
|
|
15
|
+
// --- Filtering (single-pass: build predicates, then one .filter()) ---
|
|
16
|
+
const predicates = [];
|
|
17
|
+
for (let i = 0; i < columns.length; i++) {
|
|
18
|
+
const col = columns[i];
|
|
18
19
|
const filterKey = getFilterField(col);
|
|
19
20
|
const val = filters[filterKey];
|
|
20
21
|
if (!val)
|
|
21
|
-
|
|
22
|
+
continue;
|
|
22
23
|
switch (val.type) {
|
|
23
24
|
case 'multiSelect':
|
|
24
25
|
if (val.value.length > 0) {
|
|
25
|
-
|
|
26
|
+
const allowedSet = new Set(val.value);
|
|
27
|
+
predicates.push((r) => allowedSet.has(String(getCellValue(r, col))));
|
|
26
28
|
}
|
|
27
29
|
break;
|
|
28
|
-
case 'text':
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
case 'text': {
|
|
31
|
+
const trimmed = val.value.trim();
|
|
32
|
+
if (trimmed) {
|
|
33
|
+
const lower = trimmed.toLowerCase();
|
|
34
|
+
predicates.push((r) => String(getCellValue(r, col) ?? '').toLowerCase().includes(lower));
|
|
32
35
|
}
|
|
33
36
|
break;
|
|
37
|
+
}
|
|
34
38
|
case 'people': {
|
|
35
39
|
const email = val.value.email.toLowerCase();
|
|
36
|
-
|
|
40
|
+
predicates.push((r) => String(getCellValue(r, col) ?? '').toLowerCase() === email);
|
|
37
41
|
break;
|
|
38
42
|
}
|
|
39
43
|
case 'date': {
|
|
40
44
|
const dv = val.value;
|
|
41
|
-
|
|
45
|
+
predicates.push((r) => {
|
|
42
46
|
const cellVal = getCellValue(r, col);
|
|
43
47
|
if (cellVal == null)
|
|
44
48
|
return false;
|
|
@@ -55,7 +59,16 @@ export function processClientSideData(data, columns, filters, sortBy, sortDirect
|
|
|
55
59
|
break;
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
|
-
}
|
|
62
|
+
}
|
|
63
|
+
let rows = predicates.length > 0
|
|
64
|
+
? data.filter((row) => {
|
|
65
|
+
for (let i = 0; i < predicates.length; i++) {
|
|
66
|
+
if (!predicates[i](row))
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
})
|
|
71
|
+
: data.slice();
|
|
59
72
|
// --- Sorting ---
|
|
60
73
|
if (sortBy) {
|
|
61
74
|
const sortCol = columns.find((c) => c.columnId === sortBy);
|
|
@@ -66,7 +66,12 @@ export function buildHeaderRows(columns, visibleColumns) {
|
|
|
66
66
|
const totalRows = maxDepth + 1;
|
|
67
67
|
const rows = Array.from({ length: totalRows }, () => []);
|
|
68
68
|
// Step 3: Walk the tree and place cells
|
|
69
|
+
// Cache leaf counts by children array ref to avoid O(n²) repeated traversals
|
|
70
|
+
const leafCountCache = new Map();
|
|
69
71
|
function countVisibleLeaves(cols) {
|
|
72
|
+
const cached = leafCountCache.get(cols);
|
|
73
|
+
if (cached !== undefined)
|
|
74
|
+
return cached;
|
|
70
75
|
let count = 0;
|
|
71
76
|
for (const c of cols) {
|
|
72
77
|
if (isColumnGroupDef(c)) {
|
|
@@ -78,6 +83,7 @@ export function buildHeaderRows(columns, visibleColumns) {
|
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
}
|
|
86
|
+
leafCountCache.set(cols, count);
|
|
81
87
|
return count;
|
|
82
88
|
}
|
|
83
89
|
function walk(cols, depth) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isRowInRange } from './dataGridViewModel';
|
|
2
|
+
/**
|
|
3
|
+
* Shared React.memo comparator for GridRow components across all 3 UI packages.
|
|
4
|
+
* Skips re-render for rows unaffected by selection/editing/interaction changes.
|
|
5
|
+
*
|
|
6
|
+
* Used by:
|
|
7
|
+
* - packages/radix/src/DataGridTable/DataGridTable.tsx
|
|
8
|
+
* - packages/fluent/src/DataGridTable/DataGridTable.tsx
|
|
9
|
+
* - packages/material/src/DataGridTable/DataGridTable.tsx
|
|
10
|
+
*/
|
|
11
|
+
export function areGridRowPropsEqual(prev, next) {
|
|
12
|
+
// Data / structure changes — always re-render
|
|
13
|
+
if (prev.item !== next.item)
|
|
14
|
+
return false;
|
|
15
|
+
if (prev.isSelected !== next.isSelected)
|
|
16
|
+
return false;
|
|
17
|
+
if (prev.hasCheckboxCol !== next.hasCheckboxCol)
|
|
18
|
+
return false;
|
|
19
|
+
// Framework-specific structure props (compared by identity)
|
|
20
|
+
if (prev.visibleCols !== next.visibleCols)
|
|
21
|
+
return false;
|
|
22
|
+
if (prev.columnMeta !== next.columnMeta)
|
|
23
|
+
return false;
|
|
24
|
+
if (prev.cellClassMap !== next.cellClassMap)
|
|
25
|
+
return false;
|
|
26
|
+
if (prev.columnLayouts !== next.columnLayouts)
|
|
27
|
+
return false;
|
|
28
|
+
const ri = prev.rowIndex;
|
|
29
|
+
// Editing cell in this row?
|
|
30
|
+
if (prev.editingRowId !== next.editingRowId) {
|
|
31
|
+
if (prev.editingRowId === prev.rowId || next.editingRowId === next.rowId)
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
// Active cell in this row?
|
|
35
|
+
const prevActive = prev.activeCell?.rowIndex === ri;
|
|
36
|
+
const nextActive = next.activeCell?.rowIndex === ri;
|
|
37
|
+
if (prevActive !== nextActive)
|
|
38
|
+
return false;
|
|
39
|
+
if (prevActive && nextActive && prev.activeCell.columnIndex !== next.activeCell.columnIndex)
|
|
40
|
+
return false;
|
|
41
|
+
// Selection range touches this row?
|
|
42
|
+
const prevInSel = isRowInRange(prev.selectionRange, ri);
|
|
43
|
+
const nextInSel = isRowInRange(next.selectionRange, ri);
|
|
44
|
+
if (prevInSel !== nextInSel)
|
|
45
|
+
return false;
|
|
46
|
+
if (prevInSel && nextInSel) {
|
|
47
|
+
if (prev.selectionRange.startCol !== next.selectionRange.startCol ||
|
|
48
|
+
prev.selectionRange.endCol !== next.selectionRange.endCol)
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
// Fill handle (selection end row) + isDragging
|
|
52
|
+
const prevIsEnd = prev.selectionRange?.endRow === ri;
|
|
53
|
+
const nextIsEnd = next.selectionRange?.endRow === ri;
|
|
54
|
+
if (prevIsEnd !== nextIsEnd)
|
|
55
|
+
return false;
|
|
56
|
+
if ((prevIsEnd || nextIsEnd) && prev.isDragging !== next.isDragging)
|
|
57
|
+
return false;
|
|
58
|
+
// Cut/copy ranges touch this row?
|
|
59
|
+
if (prev.cutRange !== next.cutRange) {
|
|
60
|
+
if (isRowInRange(prev.cutRange, ri) || isRowInRange(next.cutRange, ri))
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (prev.copyRange !== next.copyRange) {
|
|
64
|
+
if (isRowInRange(prev.copyRange, ri) || isRowInRange(next.copyRange, ri))
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
package/dist/esm/utils/index.js
CHANGED
|
@@ -10,3 +10,4 @@ export { getHeaderFilterConfig, getCellRenderDescriptor, isRowInRange, resolveCe
|
|
|
10
10
|
export { parseValue, numberParser, currencyParser, dateParser, emailParser, booleanParser, } from './valueParsers';
|
|
11
11
|
export { computeAggregations } from './aggregationUtils';
|
|
12
12
|
export { processClientSideData } from './clientSideData';
|
|
13
|
+
export { areGridRowPropsEqual } from './gridRowComparator';
|