@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.
Files changed (71) hide show
  1. package/README.md +42 -31
  2. package/dist/esm/components/BaseInlineCellEditor.js +112 -0
  3. package/dist/esm/components/CellErrorBoundary.js +43 -0
  4. package/dist/esm/components/EmptyState.js +19 -0
  5. package/dist/esm/components/GridContextMenu.js +4 -3
  6. package/dist/esm/components/MarchingAntsOverlay.js +6 -5
  7. package/dist/esm/components/OGridLayout.js +7 -6
  8. package/dist/esm/components/SideBar.js +66 -44
  9. package/dist/esm/constants.js +11 -0
  10. package/dist/esm/hooks/index.js +6 -0
  11. package/dist/esm/hooks/useActiveCell.js +40 -11
  12. package/dist/esm/hooks/useCellEditing.js +4 -0
  13. package/dist/esm/hooks/useCellSelection.js +58 -20
  14. package/dist/esm/hooks/useClipboard.js +36 -36
  15. package/dist/esm/hooks/useColumnHeaderFilterState.js +71 -119
  16. package/dist/esm/hooks/useColumnResize.js +27 -4
  17. package/dist/esm/hooks/useContextMenu.js +9 -5
  18. package/dist/esm/hooks/useDataGridState.js +110 -162
  19. package/dist/esm/hooks/useDateFilterState.js +34 -0
  20. package/dist/esm/hooks/useFillHandle.js +7 -2
  21. package/dist/esm/hooks/useFilterOptions.js +5 -5
  22. package/dist/esm/hooks/useKeyboardNavigation.js +18 -34
  23. package/dist/esm/hooks/useLatestRef.js +11 -0
  24. package/dist/esm/hooks/useMultiSelectFilterState.js +59 -0
  25. package/dist/esm/hooks/useOGrid.js +71 -18
  26. package/dist/esm/hooks/usePeopleFilterState.js +68 -0
  27. package/dist/esm/hooks/useRichSelectState.js +5 -0
  28. package/dist/esm/hooks/useRowSelection.js +14 -4
  29. package/dist/esm/hooks/useSideBarState.js +5 -0
  30. package/dist/esm/hooks/useTableLayout.js +77 -0
  31. package/dist/esm/hooks/useTextFilterState.js +25 -0
  32. package/dist/esm/hooks/useUndoRedo.js +6 -5
  33. package/dist/esm/index.js +7 -2
  34. package/dist/esm/utils/clientSideData.js +25 -12
  35. package/dist/esm/utils/columnUtils.js +6 -0
  36. package/dist/esm/utils/gridRowComparator.js +68 -0
  37. package/dist/esm/utils/index.js +1 -0
  38. package/dist/esm/utils/ogridHelpers.js +2 -1
  39. package/dist/esm/utils/paginationHelpers.js +7 -1
  40. package/dist/types/components/BaseInlineCellEditor.d.ts +33 -0
  41. package/dist/types/components/CellErrorBoundary.d.ts +25 -0
  42. package/dist/types/components/EmptyState.d.ts +26 -0
  43. package/dist/types/constants.d.ts +11 -0
  44. package/dist/types/hooks/index.d.ts +12 -1
  45. package/dist/types/hooks/useCellEditing.d.ts +4 -0
  46. package/dist/types/hooks/useCellSelection.d.ts +5 -0
  47. package/dist/types/hooks/useClipboard.d.ts +5 -0
  48. package/dist/types/hooks/useColumnHeaderFilterState.d.ts +1 -0
  49. package/dist/types/hooks/useColumnResize.d.ts +5 -0
  50. package/dist/types/hooks/useContextMenu.d.ts +6 -2
  51. package/dist/types/hooks/useDataGridState.d.ts +1 -0
  52. package/dist/types/hooks/useDateFilterState.d.ts +19 -0
  53. package/dist/types/hooks/useFillHandle.d.ts +5 -0
  54. package/dist/types/hooks/useKeyboardNavigation.d.ts +38 -25
  55. package/dist/types/hooks/useLatestRef.d.ts +6 -0
  56. package/dist/types/hooks/useMultiSelectFilterState.d.ts +24 -0
  57. package/dist/types/hooks/useOGrid.d.ts +30 -9
  58. package/dist/types/hooks/usePeopleFilterState.d.ts +25 -0
  59. package/dist/types/hooks/useRichSelectState.d.ts +5 -0
  60. package/dist/types/hooks/useRowSelection.d.ts +5 -0
  61. package/dist/types/hooks/useSideBarState.d.ts +5 -0
  62. package/dist/types/hooks/useTableLayout.d.ts +27 -0
  63. package/dist/types/hooks/useTextFilterState.d.ts +16 -0
  64. package/dist/types/hooks/useUndoRedo.d.ts +3 -1
  65. package/dist/types/index.d.ts +11 -4
  66. package/dist/types/types/columnTypes.d.ts +2 -3
  67. package/dist/types/types/dataGridTypes.d.ts +32 -4
  68. package/dist/types/types/index.d.ts +1 -1
  69. package/dist/types/utils/gridRowComparator.d.ts +49 -0
  70. package/dist/types/utils/index.d.ts +2 -0
  71. 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 (dataSource != null && dataSource.fetchFilterOptions)
107
+ if (hasServerFilterOptions)
103
108
  return serverFilterOptions;
104
109
  return deriveFilterOptionsFromData(displayData, columns);
105
- }, [dataSource, displayData, columns, serverFilterOptions]);
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 dataGridProps = {
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: (isServerSide && loading) || displayLoading,
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: () => setFilters({}),
420
+ onClearAll: clearAllFilters,
389
421
  message: emptyState?.message,
390
422
  render: emptyState?.render,
391
423
  },
392
- };
393
- return {
394
- dataGridProps,
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
- columnChooserColumns,
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
- pageSizeOptions,
412
- sideBarProps,
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(selectedRowIds);
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 < items.length) {
33
- const id = getRowId(items[i]);
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, selectedRowIds, items, getRowId, updateSelection]);
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, maxHistory = 50 } = params;
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(-maxHistory);
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, maxHistory]);
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(-maxHistory);
37
+ historyRef.current = [...historyRef.current, batch].slice(-maxUndoDepth);
38
38
  redoStackRef.current = [];
39
39
  setHistoryLength(historyRef.current.length);
40
40
  setRedoLength(0);
41
- }, [maxHistory]);
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
- let rows = data.slice();
16
- // --- Filtering ---
17
- columns.forEach((col) => {
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
- return;
22
+ continue;
22
23
  switch (val.type) {
23
24
  case 'multiSelect':
24
25
  if (val.value.length > 0) {
25
- rows = rows.filter((r) => val.value.includes(String(getCellValue(r, col))));
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
- if (val.value.trim()) {
30
- const lower = val.value.trim().toLowerCase();
31
- rows = rows.filter((r) => String(getCellValue(r, col) ?? '').toLowerCase().includes(lower));
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
- rows = rows.filter((r) => String(getCellValue(r, col) ?? '').toLowerCase() === email);
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
- rows = rows.filter((r) => {
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
+ }
@@ -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';