@trackunit/react-table 2.4.2-alpha-3b19e5fe058.0 → 2.5.5-alpha-770d9994af7.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/index.cjs.js CHANGED
@@ -108,6 +108,18 @@ const setupLibraryTranslations = () => {
108
108
  i18nLibraryTranslation.registerTranslations(translations);
109
109
  };
110
110
 
111
+ /**
112
+ * Renders the icon for an action button.
113
+ * Should not be used outside the ActionSheet component.
114
+ *
115
+ * @param {ActionIconProps} props - The properties for the action icon.
116
+ * @param {ActionModel} props.action - The action model defining what action to perform.
117
+ * @returns {ReactElement | null} - The icon for the action button, or null if no icon is defined.
118
+ */
119
+ const ActionIcon = ({ action }) => {
120
+ return action.icon ? (jsxRuntime.jsx(reactComponents.Icon, { color: "white", "data-testid": "action-icon", name: action.icon, ref: action.ref, size: "small", style: action.style })) : null;
121
+ };
122
+
111
123
  const cvaActionSheet = cssClassVarianceUtilities.cvaMerge([
112
124
  "w-[760px]",
113
125
  "max-w-[80%]",
@@ -165,18 +177,6 @@ const cvaMoreContainer = cssClassVarianceUtilities.cvaMerge([
165
177
  },
166
178
  });
167
179
 
168
- /**
169
- * Renders the icon for an action button.
170
- * Should not be used outside the ActionSheet component.
171
- *
172
- * @param {ActionIconProps} props - The properties for the action icon.
173
- * @param {ActionModel} props.action - The action model defining what action to perform.
174
- * @returns {ReactElement | null} - The icon for the action button, or null if no icon is defined.
175
- */
176
- const ActionIcon = ({ action }) => {
177
- return action.icon ? (jsxRuntime.jsx(reactComponents.Icon, { color: "white", "data-testid": "action-icon", name: action.icon, ref: action.ref, size: "small", style: action.style })) : null;
178
- };
179
-
180
180
  /**
181
181
  * The button to show in the ActionSheet.
182
182
  * Should not be used outside the ActionSheet component.
@@ -258,7 +258,7 @@ const ActionContainerAndOverflow = ({ actions, dropdownActions, moreActions, "da
258
258
  ? dropdownActions.map(action => {
259
259
  return (jsxRuntime.jsx(reactComponents.MoreMenu, { customButton: jsxRuntime.jsx(reactComponents.Button, { "data-testid": action["data-testid"], id: action.id, loading: action.loading, onClick: action.method, prefix: jsxRuntime.jsx(ActionIcon, { action: action }), children: action.label }), "data-testid": `${dataTestId}-dropdown-more-menu`, popoverProps: { placement: "top-end" }, children: !action.loading ? action.dropdown : null }, action.id));
260
260
  })
261
- : null, react.Children.count(moreActions) > 0 || overflowItemCount ? (jsxRuntime.jsx(reactComponents.MoreMenu, { "data-testid": `${dataTestId}-more-menu`, iconButtonProps: { variant: "primary" }, iconProps: { color: "white" }, popoverProps: { placement: "top-end" }, children: close => (jsxRuntime.jsxs(reactComponents.MenuList, { className: "relative -right-2", onClick: close, children: [moreActions ? moreActions : null, actions.map(action => {
261
+ : null, react.Children.count(moreActions) > 0 || overflowItemCount ? (jsxRuntime.jsx(reactComponents.MoreMenu, { "data-testid": `${dataTestId}-more-menu`, iconButtonProps: { variant: "primary" }, iconProps: { color: "white" }, popoverProps: { placement: "top-end" }, children: close => (jsxRuntime.jsxs(reactComponents.MenuContent, { className: "relative -right-2", onClick: close, children: [moreActions ? moreActions : null, actions.map(action => {
262
262
  return itemOverflowMap[action.id] ? actionDataToMenuItem(action, dataTestId) : null;
263
263
  })] })) })) : null] })] }));
264
264
  };
@@ -343,7 +343,7 @@ const ActionContainerAndOverflow = ({ actions, dropdownActions, moreActions, "da
343
343
  * @example With dropdown actions
344
344
  * ```tsx
345
345
  * import { ActionSheet } from "@trackunit/react-table";
346
- * import { MenuList, MenuItem } from "@trackunit/react-components";
346
+ * import { MenuContent, MenuItem } from "@trackunit/react-components";
347
347
  *
348
348
  * <ActionSheet
349
349
  * selections={selectedIds}
@@ -356,10 +356,10 @@ const ActionContainerAndOverflow = ({ actions, dropdownActions, moreActions, "da
356
356
  * label: "Change Status",
357
357
  * iconName: "Flag",
358
358
  * dropdownContent: (close) => (
359
- * <MenuList>
359
+ * <MenuContent>
360
360
  * <MenuItem label="Active" onClick={() => { setStatus("active"); close(); }} />
361
361
  * <MenuItem label="Inactive" onClick={() => { setStatus("inactive"); close(); }} />
362
- * </MenuList>
362
+ * </MenuContent>
363
363
  * ),
364
364
  * },
365
365
  * ]}
@@ -568,6 +568,12 @@ const validateTableState = (raw) => {
568
568
  * the URL, such as `expanded`, still survive reloads. Changes are
569
569
  * debounced (300 ms) and synced to both the hash and localStorage.
570
570
  *
571
+ * `onTableStateChange` takes a *partial* state and merges it into whatever
572
+ * was reported earlier in the session, so a consumer can report a single
573
+ * slice (e.g. only `expanded`) without the omitted fields falling back to
574
+ * their mount-time values. A field reported empty is dropped from the stored
575
+ * state, and once every field is empty the entry is removed altogether.
576
+ *
571
577
  * @param persistenceKey - Unique key used to store and retrieve the table state.
572
578
  * Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
573
579
  * @returns {UseTablePersistenceResult} An object containing:
@@ -583,7 +589,7 @@ const useTablePersistence = (persistenceKey) => {
583
589
  const { clientSideUserId } = reactCoreHooks.useCurrentUser();
584
590
  const localStorageEnabled = clientSideUserId !== undefined;
585
591
  const { toUrlValue, fromUrlValue } = useCompactTableUrl();
586
- const { initialState, persistState } = reactComponents.usePersistedState({
592
+ const { initialState, persistState, clearState } = reactComponents.usePersistedState({
587
593
  key: `${persistenceKey}Tp`,
588
594
  validate: validateTableState,
589
595
  toUrlValue,
@@ -602,6 +608,7 @@ const useTablePersistence = (persistenceKey) => {
602
608
  });
603
609
  const [pendingState, setPendingState] = react.useState(null);
604
610
  const debouncedPending = reactComponents.useDebounce(pendingState, { delay: 300 });
611
+ const hasReportedChange = debouncedPending !== null;
605
612
  const currentState = react.useMemo(() => {
606
613
  if (!debouncedPending && !initialState) {
607
614
  return undefined;
@@ -616,10 +623,10 @@ const useTablePersistence = (persistenceKey) => {
616
623
  if (columnOrder && columnOrder.length > 0) {
617
624
  state.columnOrder = columnOrder;
618
625
  }
619
- if (columnSizing && Object.keys(columnSizing).length > 0) {
626
+ if (columnSizing && sharedUtils.objectKeys(columnSizing).length > 0) {
620
627
  state.columnSizing = columnSizing;
621
628
  }
622
- if (columnVisibility && Object.keys(columnVisibility).length > 0) {
629
+ if (columnVisibility && sharedUtils.objectKeys(columnVisibility).length > 0) {
623
630
  state.columnVisibility = columnVisibility;
624
631
  }
625
632
  if (sorting && sorting.length > 0) {
@@ -628,23 +635,27 @@ const useTablePersistence = (persistenceKey) => {
628
635
  if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
629
636
  state.columnPinning = columnPinning;
630
637
  }
631
- if (expanded === true || (typeof expanded === "object" && Object.keys(expanded).length > 0)) {
638
+ if (expanded === true || (typeof expanded === "object" && sharedUtils.objectKeys(expanded).length > 0)) {
632
639
  state.expanded = expanded;
633
640
  }
634
- return Object.keys(state).length > 0 ? state : undefined;
635
- }, [debouncedPending, initialState]);
641
+ return sharedUtils.objectKeys(state).length > 0 || hasReportedChange ? state : undefined;
642
+ }, [debouncedPending, hasReportedChange, initialState]);
636
643
  const onTableStateChange = react.useCallback((newTableState) => {
637
644
  if (!newTableState) {
638
645
  return;
639
646
  }
640
- setPendingState(newTableState);
647
+ setPendingState(previousState => ({ ...previousState, ...newTableState }));
641
648
  }, []);
642
649
  react.useEffect(() => {
643
650
  if (!currentState) {
644
651
  return;
645
652
  }
653
+ if (sharedUtils.objectKeys(currentState).length === 0) {
654
+ clearState();
655
+ return;
656
+ }
646
657
  persistState(currentState);
647
- }, [currentState, persistState]);
658
+ }, [clearState, currentState, persistState]);
648
659
  return react.useMemo(() => ({
649
660
  onTableStateChange,
650
661
  initialState,
@@ -781,7 +792,7 @@ const ColumnFilter = ({ columns, setColumnOrder, defaultColumnOrder = [], column
781
792
  setSearchText("");
782
793
  }
783
794
  }, placement: "bottom-start", children: () => {
784
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx(reactComponents.IconButton, { icon: jsxRuntime.jsx(reactComponents.Icon, { name: "Cog6Tooth", size: "small" }), size: "small", title: t("table.columnFilters.tooltip"), variant: "secondary" }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { children: () => (jsxRuntime.jsx(reactDnd.DndProvider, { backend: reactDndHtml5Backend.HTML5Backend, children: jsxRuntime.jsxs(reactComponents.MenuList, { className: "relative max-h-[55vh] w-72 overflow-y-auto", children: [jsxRuntime.jsxs("div", { className: "mb-2 flex items-center gap-1", children: [jsxRuntime.jsx(reactFormComponents.Search, { autoFocus: true, className: "flex-1", fieldSize: "small", id: "column-search", onChange: e => setSearchText(e.currentTarget.value.toLowerCase()), onClear: () => setSearchText(""), placeholder: t("table.search.placeholder"), value: searchText }), jsxRuntime.jsx(reactComponents.Button, { "data-testid": "resetColumnState", onClick: resetColumnState, size: "small", variant: "ghost-neutral", children: t("layout.actions.reset") })] }), jsxRuntime.jsx(ColumnFiltersDragAndDrop, { columns: sortedColumns, isSortDisabled: !!searchText, onUserEvent: onUserEvent, setColumnOrder: setColumnOrder, visibleColumnsCount: visibleColumnsCount })] }) })) })] }));
795
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx(reactComponents.IconButton, { icon: jsxRuntime.jsx(reactComponents.Icon, { name: "Cog6Tooth", size: "small" }), size: "small", title: t("table.columnFilters.tooltip"), variant: "secondary" }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { children: () => (jsxRuntime.jsx(reactDnd.DndProvider, { backend: reactDndHtml5Backend.HTML5Backend, children: jsxRuntime.jsxs(reactComponents.MenuContent, { className: "relative max-h-[55vh] w-72 overflow-y-auto", children: [jsxRuntime.jsxs("div", { className: "mb-2 flex items-center gap-1", children: [jsxRuntime.jsx(reactFormComponents.Search, { autoFocus: true, className: "flex-1", fieldSize: "small", id: "column-search", onChange: e => setSearchText(e.currentTarget.value.toLowerCase()), onClear: () => setSearchText(""), placeholder: t("table.search.placeholder"), value: searchText }), jsxRuntime.jsx(reactComponents.Button, { "data-testid": "resetColumnState", onClick: resetColumnState, size: "small", variant: "ghost-neutral", children: t("layout.actions.reset") })] }), jsxRuntime.jsx(ColumnFiltersDragAndDrop, { columns: sortedColumns, isSortDisabled: !!searchText, onUserEvent: onUserEvent, setColumnOrder: setColumnOrder, visibleColumnsCount: visibleColumnsCount })] }) })) })] }));
785
796
  } }));
786
797
  };
787
798
  const ColumnFiltersDragAndDrop = ({ columns, setColumnOrder, onUserEvent, isSortDisabled, visibleColumnsCount, }) => {
@@ -860,7 +871,7 @@ const Sorting = ({ columns, }) => {
860
871
  const chosenColumn = sortableColumns.find(column => column.id === currentSortValue);
861
872
  chosenColumn?.toggleSorting(selectedValue === "desc");
862
873
  };
863
- return (jsxRuntime.jsx(reactComponents.Tooltip, { label: t("table.sorting.toolip"), placement: "bottom", children: jsxRuntime.jsxs(reactComponents.Popover, { placement: "bottom-start", children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx(reactComponents.Button, { prefix: currentSortDirection === "asc" ? (jsxRuntime.jsx(reactComponents.Icon, { name: "BarsArrowUp", size: "small" })) : (jsxRuntime.jsx(reactComponents.Icon, { name: "BarsArrowDown", size: "small" })), size: "small", variant: "ghost-neutral", children: jsxRuntime.jsx("span", { className: "hidden sm:block", children: t("table.sorting.label") }) }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { children: jsxRuntime.jsxs(reactComponents.MenuList, { className: "max-h-[55vh] overflow-y-auto", children: [jsxRuntime.jsx(reactFormComponents.RadioGroup, { id: "sortProperty", onChange: handleSelectionChange, value: currentSortValue ?? "", children: sortableColumns.map(column => (jsxRuntime.jsx(reactFormComponents.RadioItem, { className: "w-full", label: column.columnDef.header?.toString() ?? "", value: column.id }, column.id))) }), jsxRuntime.jsxs(reactFormComponents.RadioGroup, { id: "sortOrder", onChange: onSelectOrder, value: currentSortDirection, children: [jsxRuntime.jsx(reactFormComponents.RadioItem, { className: "w-full", label: t("table.sorting.ascending"), value: "asc" }), jsxRuntime.jsx(reactFormComponents.RadioItem, { className: "w-full", label: t("table.sorting.descending"), value: "desc" })] })] }) })] }) }));
874
+ return (jsxRuntime.jsx(reactComponents.Tooltip, { label: t("table.sorting.toolip"), placement: "bottom", children: jsxRuntime.jsxs(reactComponents.Popover, { placement: "bottom-start", children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx(reactComponents.Button, { prefix: currentSortDirection === "asc" ? (jsxRuntime.jsx(reactComponents.Icon, { name: "BarsArrowUp", size: "small" })) : (jsxRuntime.jsx(reactComponents.Icon, { name: "BarsArrowDown", size: "small" })), size: "small", variant: "ghost-neutral", children: jsxRuntime.jsx("span", { className: "hidden sm:block", children: t("table.sorting.label") }) }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { children: jsxRuntime.jsx("div", { className: reactComponents.cvaMenu({ className: "max-h-[55vh] overflow-y-auto", limitWidth: true }), children: jsxRuntime.jsxs("div", { className: reactComponents.cvaMenuList(), children: [jsxRuntime.jsx(reactFormComponents.RadioGroup, { id: "sortProperty", onChange: handleSelectionChange, value: currentSortValue ?? "", children: sortableColumns.map(column => (jsxRuntime.jsx(reactFormComponents.RadioItem, { className: "w-full", label: column.columnDef.header?.toString() ?? "", value: column.id }, column.id))) }), jsxRuntime.jsxs(reactFormComponents.RadioGroup, { id: "sortOrder", onChange: onSelectOrder, value: currentSortDirection, children: [jsxRuntime.jsx(reactFormComponents.RadioItem, { className: "w-full", label: t("table.sorting.ascending"), value: "asc" }), jsxRuntime.jsx(reactFormComponents.RadioItem, { className: "w-full", label: t("table.sorting.descending"), value: "desc" })] })] }) }) })] }) }));
864
875
  };
865
876
 
866
877
  /**
@@ -1276,7 +1287,7 @@ const ColumnActions = ({ header, visibleColumnsCount, setSorting, getColumn }) =
1276
1287
  if (!isAnyActionAvailable) {
1277
1288
  return null;
1278
1289
  }
1279
- return (jsxRuntime.jsx(reactComponents.MoreMenu, { customButton: jsxRuntime.jsx(reactComponents.IconButton, { className: "h-8 w-5 p-0", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "EllipsisVertical", size: "small" }), onClick: event => event.stopPropagation(), title: t("table.columnActions.moreActions"), variant: "ghost-neutral" }), children: close => (jsxRuntime.jsxs(reactComponents.MenuList, { children: [showSortAscending ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t("table.columnActions.sortAscending"), onClick: handleSortAscending, prefix: jsxRuntime.jsx(reactComponents.Icon, { name: "ArrowUp", size: "medium" }) })) : null, showSortDescending ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t("table.columnActions.sortDescending"), onClick: handleSortDescending, prefix: jsxRuntime.jsx(reactComponents.Icon, { name: "ArrowDown", size: "medium" }) })) : null, showClearSorting !== false && showClearSorting !== undefined ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t("table.columnActions.clearSorting"), onClick: handleClearSorting, prefix: jsxRuntime.jsx(reactComponents.Icon, { name: "ArrowsUpDown", size: "medium" }) })) : null, showHideColumn ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t("table.columnActions.hideColumn"), onClick: () => header.column.toggleVisibility(false), prefix: jsxRuntime.jsx(reactComponents.Icon, { name: "EyeSlash", size: "medium" }) })) : null, canPin ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t(isPinned !== false ? "table.columnActions.unPinColumn" : "table.columnActions.pinColumn"), onClick: () => {
1290
+ return (jsxRuntime.jsx(reactComponents.MoreMenu, { customButton: jsxRuntime.jsx(reactComponents.IconButton, { className: "h-8 w-5 p-0", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "EllipsisVertical", size: "small" }), onClick: event => event.stopPropagation(), title: t("table.columnActions.moreActions"), variant: "ghost-neutral" }), children: close => (jsxRuntime.jsxs(reactComponents.MenuContent, { children: [showSortAscending ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t("table.columnActions.sortAscending"), onClick: handleSortAscending, prefix: jsxRuntime.jsx(reactComponents.Icon, { name: "ArrowUp", size: "medium" }) })) : null, showSortDescending ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t("table.columnActions.sortDescending"), onClick: handleSortDescending, prefix: jsxRuntime.jsx(reactComponents.Icon, { name: "ArrowDown", size: "medium" }) })) : null, showClearSorting !== false && showClearSorting !== undefined ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t("table.columnActions.clearSorting"), onClick: handleClearSorting, prefix: jsxRuntime.jsx(reactComponents.Icon, { name: "ArrowsUpDown", size: "medium" }) })) : null, showHideColumn ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t("table.columnActions.hideColumn"), onClick: () => header.column.toggleVisibility(false), prefix: jsxRuntime.jsx(reactComponents.Icon, { name: "EyeSlash", size: "medium" }) })) : null, canPin ? (jsxRuntime.jsx(reactComponents.MenuItem, { label: t(isPinned !== false ? "table.columnActions.unPinColumn" : "table.columnActions.pinColumn"), onClick: () => {
1280
1291
  header.column.pin(isPinned !== false ? false : "left");
1281
1292
  close();
1282
1293
  }, prefix: jsxRuntime.jsx(reactComponents.Icon, { name: isPinned !== false ? "Unpin" : "Pin", size: "medium" }) })) : null] })) }));
@@ -1708,6 +1719,13 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1708
1719
  * - Do **not** call `useReactTable` directly — `useTable` adds required column-state
1709
1720
  * orchestration that the raw hook does not provide.
1710
1721
  *
1722
+ * Row expansion is managed like the column state: it starts from `initialState.expanded`, and
1723
+ * expand/collapse changes are reported to `onTableStateChange` so they can be persisted — unless
1724
+ * the consumer passes `state.expanded`, in which case it owns what is worth storing.
1725
+ *
1726
+ * `onTableStateChange` is called with the field that changed, not the whole state, so anything
1727
+ * storing it has to merge each report into what it already holds. `useTablePersistence` does.
1728
+ *
1711
1729
  * @template TData - The type representing the data model associated with the table.
1712
1730
  * @param {TableOptionsProps<TData>} props - The props object containing configuration options.
1713
1731
  * @returns {object} An object containing the React Table instance and associated state management functions.
@@ -1732,7 +1750,7 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1732
1750
  * data,
1733
1751
  * columns,
1734
1752
  * initialState: { sorting: [{ id: "name", desc: false }] },
1735
- * onTableStateChange: state => saveTablePreferences(state),
1753
+ * onTableStateChange: changedField => setPreferences(previous => ({ ...previous, ...changedField })),
1736
1754
  * });
1737
1755
  *
1738
1756
  * return <Table table={table} />;
@@ -1828,20 +1846,27 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
1828
1846
  const columnPinning = react.useMemo(() => userEdits.columnPinning ?? computeMergedPinning(initialState?.columnPinning, stableUpdatedInitialColumnPinning), [userEdits.columnPinning, initialState?.columnPinning, stableUpdatedInitialColumnPinning]);
1829
1847
  const sorting = react.useMemo(() => userEdits.sorting ?? initialState?.sorting ?? [], [userEdits.sorting, initialState?.sorting]);
1830
1848
  const columnSizing = react.useMemo(() => userEdits.columnSizing ?? initialState?.columnSizing ?? {}, [userEdits.columnSizing, initialState?.columnSizing]);
1831
- const baseState = react.useMemo(() => ({ columnVisibility, columnOrder, sorting, columnSizing, columnPinning }), [columnVisibility, columnOrder, sorting, columnSizing, columnPinning]);
1849
+ const expanded = react.useMemo(() => userEdits.expanded ?? initialState?.expanded ?? {}, [userEdits.expanded, initialState?.expanded]);
1850
+ const baseState = react.useMemo(() => ({ columnVisibility, columnOrder, sorting, columnSizing, columnPinning, expanded }), [columnVisibility, columnOrder, sorting, columnSizing, columnPinning, expanded]);
1832
1851
  const state = react.useMemo(() => ({ ...baseState, ...reactTableProps.state }), [baseState, reactTableProps.state]);
1833
1852
  // A ref exposes the latest derived state to the change callbacks without making them depend on
1834
- // it (callbacks run on user events, outside render). Every column-state key on `state` is always
1853
+ // it (callbacks run on user events, outside render). Every managed key on `state` is always
1835
1854
  // defined — it originates from `baseState` — so the callbacks read it directly.
1836
1855
  const stateRef = react.useRef(state);
1837
1856
  stateRef.current = state;
1838
- // Persist on user actions only (not on mount or prop-driven recomputation): drop column sizes
1839
- // that match their default so only meaningful overrides are stored.
1857
+ // Reports only the field that changed `useTablePersistence` merges partial reports so a
1858
+ // change can never store another field's unsettled or transient value. Column sizes matching
1859
+ // their default are dropped so only meaningful overrides are stored.
1840
1860
  const persistTableState = react.useCallback((nextState) => {
1841
1861
  if (!onTableStateChange) {
1842
1862
  return;
1843
1863
  }
1844
- const columnSizingToPersist = Object.fromEntries(Object.entries(nextState.columnSizing ?? {}).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
1864
+ const nextColumnSizing = nextState.columnSizing;
1865
+ if (!nextColumnSizing) {
1866
+ onTableStateChange(nextState);
1867
+ return;
1868
+ }
1869
+ const columnSizingToPersist = Object.fromEntries(sharedUtils.objectEntries(nextColumnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
1845
1870
  onTableStateChange({ ...nextState, columnSizing: columnSizingToPersist });
1846
1871
  }, [onTableStateChange, defaultColumnSizingById]);
1847
1872
  const table = reactTable.useReactTable({
@@ -1855,37 +1880,46 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
1855
1880
  const current = stateRef.current.columnVisibility;
1856
1881
  const next = typeof value === "function" ? value(current) : value;
1857
1882
  setUserEdits(prev => ({ ...prev, columnVisibility: next }));
1858
- persistTableState({ ...stateRef.current, columnVisibility: next });
1883
+ persistTableState({ columnVisibility: next });
1859
1884
  reactTableProps.onColumnVisibilityChange?.(value);
1860
1885
  },
1861
1886
  onColumnSizingChange: value => {
1862
1887
  const current = stateRef.current.columnSizing;
1863
1888
  const next = typeof value === "function" ? value(current) : value;
1864
1889
  setUserEdits(prev => ({ ...prev, columnSizing: next }));
1865
- persistTableState({ ...stateRef.current, columnSizing: next });
1890
+ persistTableState({ columnSizing: next });
1866
1891
  reactTableProps.onColumnSizingChange?.(value);
1867
1892
  },
1868
1893
  onColumnOrderChange: value => {
1869
1894
  const current = stateRef.current.columnOrder;
1870
1895
  const next = typeof value === "function" ? value(current) : value;
1871
1896
  setUserEdits(prev => ({ ...prev, columnOrder: next }));
1872
- persistTableState({ ...stateRef.current, columnOrder: next });
1897
+ persistTableState({ columnOrder: next });
1873
1898
  reactTableProps.onColumnOrderChange?.(value);
1874
1899
  },
1875
1900
  onSortingChange: value => {
1876
1901
  const current = stateRef.current.sorting;
1877
1902
  const next = typeof value === "function" ? value(current) : value;
1878
1903
  setUserEdits(prev => ({ ...prev, sorting: next }));
1879
- persistTableState({ ...stateRef.current, sorting: next });
1904
+ persistTableState({ sorting: next });
1880
1905
  reactTableProps.onSortingChange?.(value);
1881
1906
  },
1882
1907
  onColumnPinningChange: value => {
1883
1908
  const current = stateRef.current.columnPinning;
1884
1909
  const next = typeof value === "function" ? value(current) : value;
1885
1910
  setUserEdits(prev => ({ ...prev, columnPinning: next }));
1886
- persistTableState({ ...stateRef.current, columnPinning: next });
1911
+ persistTableState({ columnPinning: next });
1887
1912
  reactTableProps.onColumnPinningChange?.(value);
1888
1913
  },
1914
+ onExpandedChange: value => {
1915
+ const current = stateRef.current.expanded;
1916
+ const next = typeof value === "function" ? value(current) : value;
1917
+ setUserEdits(prev => ({ ...prev, expanded: next }));
1918
+ if (reactTableProps.state?.expanded === undefined) {
1919
+ persistTableState({ expanded: next });
1920
+ }
1921
+ reactTableProps.onExpandedChange?.(value);
1922
+ },
1889
1923
  columns,
1890
1924
  state,
1891
1925
  });
package/index.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
2
  import { registerTranslations, useNamespaceTranslation, NamespaceTrans } from '@trackunit/i18n-library-translation';
3
- import { Icon, MenuItem, Button, Tooltip, useOverflowItems, MoreMenu, MenuList, Spacer, usePersistedState, useDebounce, Text, cvaInteractableItem, Popover, PopoverTrigger, IconButton, PopoverContent, useBidirectionalScroll, noPagination, Card, Spinner, EmptyState } from '@trackunit/react-components';
4
- import { objectValues, VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, nonNullable, objectKeys, objectEntries } from '@trackunit/shared-utils';
3
+ import { Icon, MenuItem, Button, Tooltip, useOverflowItems, MoreMenu, MenuContent, Spacer, usePersistedState, useDebounce, Text, cvaInteractableItem, Popover, PopoverTrigger, IconButton, PopoverContent, cvaMenuList, cvaMenu, useBidirectionalScroll, noPagination, Card, Spinner, EmptyState } from '@trackunit/react-components';
4
+ import { objectValues, VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, objectKeys, nonNullable, objectEntries } from '@trackunit/shared-utils';
5
5
  import { useMemo, Children, cloneElement, useCallback, useState, useEffect, useRef, createElement } from 'react';
6
6
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
7
7
  import { Link } from '@tanstack/react-router';
@@ -107,6 +107,18 @@ const setupLibraryTranslations = () => {
107
107
  registerTranslations(translations);
108
108
  };
109
109
 
110
+ /**
111
+ * Renders the icon for an action button.
112
+ * Should not be used outside the ActionSheet component.
113
+ *
114
+ * @param {ActionIconProps} props - The properties for the action icon.
115
+ * @param {ActionModel} props.action - The action model defining what action to perform.
116
+ * @returns {ReactElement | null} - The icon for the action button, or null if no icon is defined.
117
+ */
118
+ const ActionIcon = ({ action }) => {
119
+ return action.icon ? (jsx(Icon, { color: "white", "data-testid": "action-icon", name: action.icon, ref: action.ref, size: "small", style: action.style })) : null;
120
+ };
121
+
110
122
  const cvaActionSheet = cvaMerge([
111
123
  "w-[760px]",
112
124
  "max-w-[80%]",
@@ -164,18 +176,6 @@ const cvaMoreContainer = cvaMerge([
164
176
  },
165
177
  });
166
178
 
167
- /**
168
- * Renders the icon for an action button.
169
- * Should not be used outside the ActionSheet component.
170
- *
171
- * @param {ActionIconProps} props - The properties for the action icon.
172
- * @param {ActionModel} props.action - The action model defining what action to perform.
173
- * @returns {ReactElement | null} - The icon for the action button, or null if no icon is defined.
174
- */
175
- const ActionIcon = ({ action }) => {
176
- return action.icon ? (jsx(Icon, { color: "white", "data-testid": "action-icon", name: action.icon, ref: action.ref, size: "small", style: action.style })) : null;
177
- };
178
-
179
179
  /**
180
180
  * The button to show in the ActionSheet.
181
181
  * Should not be used outside the ActionSheet component.
@@ -257,7 +257,7 @@ const ActionContainerAndOverflow = ({ actions, dropdownActions, moreActions, "da
257
257
  ? dropdownActions.map(action => {
258
258
  return (jsx(MoreMenu, { customButton: jsx(Button, { "data-testid": action["data-testid"], id: action.id, loading: action.loading, onClick: action.method, prefix: jsx(ActionIcon, { action: action }), children: action.label }), "data-testid": `${dataTestId}-dropdown-more-menu`, popoverProps: { placement: "top-end" }, children: !action.loading ? action.dropdown : null }, action.id));
259
259
  })
260
- : null, Children.count(moreActions) > 0 || overflowItemCount ? (jsx(MoreMenu, { "data-testid": `${dataTestId}-more-menu`, iconButtonProps: { variant: "primary" }, iconProps: { color: "white" }, popoverProps: { placement: "top-end" }, children: close => (jsxs(MenuList, { className: "relative -right-2", onClick: close, children: [moreActions ? moreActions : null, actions.map(action => {
260
+ : null, Children.count(moreActions) > 0 || overflowItemCount ? (jsx(MoreMenu, { "data-testid": `${dataTestId}-more-menu`, iconButtonProps: { variant: "primary" }, iconProps: { color: "white" }, popoverProps: { placement: "top-end" }, children: close => (jsxs(MenuContent, { className: "relative -right-2", onClick: close, children: [moreActions ? moreActions : null, actions.map(action => {
261
261
  return itemOverflowMap[action.id] ? actionDataToMenuItem(action, dataTestId) : null;
262
262
  })] })) })) : null] })] }));
263
263
  };
@@ -342,7 +342,7 @@ const ActionContainerAndOverflow = ({ actions, dropdownActions, moreActions, "da
342
342
  * @example With dropdown actions
343
343
  * ```tsx
344
344
  * import { ActionSheet } from "@trackunit/react-table";
345
- * import { MenuList, MenuItem } from "@trackunit/react-components";
345
+ * import { MenuContent, MenuItem } from "@trackunit/react-components";
346
346
  *
347
347
  * <ActionSheet
348
348
  * selections={selectedIds}
@@ -355,10 +355,10 @@ const ActionContainerAndOverflow = ({ actions, dropdownActions, moreActions, "da
355
355
  * label: "Change Status",
356
356
  * iconName: "Flag",
357
357
  * dropdownContent: (close) => (
358
- * <MenuList>
358
+ * <MenuContent>
359
359
  * <MenuItem label="Active" onClick={() => { setStatus("active"); close(); }} />
360
360
  * <MenuItem label="Inactive" onClick={() => { setStatus("inactive"); close(); }} />
361
- * </MenuList>
361
+ * </MenuContent>
362
362
  * ),
363
363
  * },
364
364
  * ]}
@@ -567,6 +567,12 @@ const validateTableState = (raw) => {
567
567
  * the URL, such as `expanded`, still survive reloads. Changes are
568
568
  * debounced (300 ms) and synced to both the hash and localStorage.
569
569
  *
570
+ * `onTableStateChange` takes a *partial* state and merges it into whatever
571
+ * was reported earlier in the session, so a consumer can report a single
572
+ * slice (e.g. only `expanded`) without the omitted fields falling back to
573
+ * their mount-time values. A field reported empty is dropped from the stored
574
+ * state, and once every field is empty the entry is removed altogether.
575
+ *
570
576
  * @param persistenceKey - Unique key used to store and retrieve the table state.
571
577
  * Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
572
578
  * @returns {UseTablePersistenceResult} An object containing:
@@ -582,7 +588,7 @@ const useTablePersistence = (persistenceKey) => {
582
588
  const { clientSideUserId } = useCurrentUser();
583
589
  const localStorageEnabled = clientSideUserId !== undefined;
584
590
  const { toUrlValue, fromUrlValue } = useCompactTableUrl();
585
- const { initialState, persistState } = usePersistedState({
591
+ const { initialState, persistState, clearState } = usePersistedState({
586
592
  key: `${persistenceKey}Tp`,
587
593
  validate: validateTableState,
588
594
  toUrlValue,
@@ -601,6 +607,7 @@ const useTablePersistence = (persistenceKey) => {
601
607
  });
602
608
  const [pendingState, setPendingState] = useState(null);
603
609
  const debouncedPending = useDebounce(pendingState, { delay: 300 });
610
+ const hasReportedChange = debouncedPending !== null;
604
611
  const currentState = useMemo(() => {
605
612
  if (!debouncedPending && !initialState) {
606
613
  return undefined;
@@ -615,10 +622,10 @@ const useTablePersistence = (persistenceKey) => {
615
622
  if (columnOrder && columnOrder.length > 0) {
616
623
  state.columnOrder = columnOrder;
617
624
  }
618
- if (columnSizing && Object.keys(columnSizing).length > 0) {
625
+ if (columnSizing && objectKeys(columnSizing).length > 0) {
619
626
  state.columnSizing = columnSizing;
620
627
  }
621
- if (columnVisibility && Object.keys(columnVisibility).length > 0) {
628
+ if (columnVisibility && objectKeys(columnVisibility).length > 0) {
622
629
  state.columnVisibility = columnVisibility;
623
630
  }
624
631
  if (sorting && sorting.length > 0) {
@@ -627,23 +634,27 @@ const useTablePersistence = (persistenceKey) => {
627
634
  if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
628
635
  state.columnPinning = columnPinning;
629
636
  }
630
- if (expanded === true || (typeof expanded === "object" && Object.keys(expanded).length > 0)) {
637
+ if (expanded === true || (typeof expanded === "object" && objectKeys(expanded).length > 0)) {
631
638
  state.expanded = expanded;
632
639
  }
633
- return Object.keys(state).length > 0 ? state : undefined;
634
- }, [debouncedPending, initialState]);
640
+ return objectKeys(state).length > 0 || hasReportedChange ? state : undefined;
641
+ }, [debouncedPending, hasReportedChange, initialState]);
635
642
  const onTableStateChange = useCallback((newTableState) => {
636
643
  if (!newTableState) {
637
644
  return;
638
645
  }
639
- setPendingState(newTableState);
646
+ setPendingState(previousState => ({ ...previousState, ...newTableState }));
640
647
  }, []);
641
648
  useEffect(() => {
642
649
  if (!currentState) {
643
650
  return;
644
651
  }
652
+ if (objectKeys(currentState).length === 0) {
653
+ clearState();
654
+ return;
655
+ }
645
656
  persistState(currentState);
646
- }, [currentState, persistState]);
657
+ }, [clearState, currentState, persistState]);
647
658
  return useMemo(() => ({
648
659
  onTableStateChange,
649
660
  initialState,
@@ -780,7 +791,7 @@ const ColumnFilter = ({ columns, setColumnOrder, defaultColumnOrder = [], column
780
791
  setSearchText("");
781
792
  }
782
793
  }, placement: "bottom-start", children: () => {
783
- return (jsxs(Fragment, { children: [jsx(PopoverTrigger, { children: jsx(IconButton, { icon: jsx(Icon, { name: "Cog6Tooth", size: "small" }), size: "small", title: t("table.columnFilters.tooltip"), variant: "secondary" }) }), jsx(PopoverContent, { children: () => (jsx(DndProvider, { backend: HTML5Backend, children: jsxs(MenuList, { className: "relative max-h-[55vh] w-72 overflow-y-auto", children: [jsxs("div", { className: "mb-2 flex items-center gap-1", children: [jsx(Search, { autoFocus: true, className: "flex-1", fieldSize: "small", id: "column-search", onChange: e => setSearchText(e.currentTarget.value.toLowerCase()), onClear: () => setSearchText(""), placeholder: t("table.search.placeholder"), value: searchText }), jsx(Button, { "data-testid": "resetColumnState", onClick: resetColumnState, size: "small", variant: "ghost-neutral", children: t("layout.actions.reset") })] }), jsx(ColumnFiltersDragAndDrop, { columns: sortedColumns, isSortDisabled: !!searchText, onUserEvent: onUserEvent, setColumnOrder: setColumnOrder, visibleColumnsCount: visibleColumnsCount })] }) })) })] }));
794
+ return (jsxs(Fragment, { children: [jsx(PopoverTrigger, { children: jsx(IconButton, { icon: jsx(Icon, { name: "Cog6Tooth", size: "small" }), size: "small", title: t("table.columnFilters.tooltip"), variant: "secondary" }) }), jsx(PopoverContent, { children: () => (jsx(DndProvider, { backend: HTML5Backend, children: jsxs(MenuContent, { className: "relative max-h-[55vh] w-72 overflow-y-auto", children: [jsxs("div", { className: "mb-2 flex items-center gap-1", children: [jsx(Search, { autoFocus: true, className: "flex-1", fieldSize: "small", id: "column-search", onChange: e => setSearchText(e.currentTarget.value.toLowerCase()), onClear: () => setSearchText(""), placeholder: t("table.search.placeholder"), value: searchText }), jsx(Button, { "data-testid": "resetColumnState", onClick: resetColumnState, size: "small", variant: "ghost-neutral", children: t("layout.actions.reset") })] }), jsx(ColumnFiltersDragAndDrop, { columns: sortedColumns, isSortDisabled: !!searchText, onUserEvent: onUserEvent, setColumnOrder: setColumnOrder, visibleColumnsCount: visibleColumnsCount })] }) })) })] }));
784
795
  } }));
785
796
  };
786
797
  const ColumnFiltersDragAndDrop = ({ columns, setColumnOrder, onUserEvent, isSortDisabled, visibleColumnsCount, }) => {
@@ -859,7 +870,7 @@ const Sorting = ({ columns, }) => {
859
870
  const chosenColumn = sortableColumns.find(column => column.id === currentSortValue);
860
871
  chosenColumn?.toggleSorting(selectedValue === "desc");
861
872
  };
862
- return (jsx(Tooltip, { label: t("table.sorting.toolip"), placement: "bottom", children: jsxs(Popover, { placement: "bottom-start", children: [jsx(PopoverTrigger, { children: jsx(Button, { prefix: currentSortDirection === "asc" ? (jsx(Icon, { name: "BarsArrowUp", size: "small" })) : (jsx(Icon, { name: "BarsArrowDown", size: "small" })), size: "small", variant: "ghost-neutral", children: jsx("span", { className: "hidden sm:block", children: t("table.sorting.label") }) }) }), jsx(PopoverContent, { children: jsxs(MenuList, { className: "max-h-[55vh] overflow-y-auto", children: [jsx(RadioGroup, { id: "sortProperty", onChange: handleSelectionChange, value: currentSortValue ?? "", children: sortableColumns.map(column => (jsx(RadioItem, { className: "w-full", label: column.columnDef.header?.toString() ?? "", value: column.id }, column.id))) }), jsxs(RadioGroup, { id: "sortOrder", onChange: onSelectOrder, value: currentSortDirection, children: [jsx(RadioItem, { className: "w-full", label: t("table.sorting.ascending"), value: "asc" }), jsx(RadioItem, { className: "w-full", label: t("table.sorting.descending"), value: "desc" })] })] }) })] }) }));
873
+ return (jsx(Tooltip, { label: t("table.sorting.toolip"), placement: "bottom", children: jsxs(Popover, { placement: "bottom-start", children: [jsx(PopoverTrigger, { children: jsx(Button, { prefix: currentSortDirection === "asc" ? (jsx(Icon, { name: "BarsArrowUp", size: "small" })) : (jsx(Icon, { name: "BarsArrowDown", size: "small" })), size: "small", variant: "ghost-neutral", children: jsx("span", { className: "hidden sm:block", children: t("table.sorting.label") }) }) }), jsx(PopoverContent, { children: jsx("div", { className: cvaMenu({ className: "max-h-[55vh] overflow-y-auto", limitWidth: true }), children: jsxs("div", { className: cvaMenuList(), children: [jsx(RadioGroup, { id: "sortProperty", onChange: handleSelectionChange, value: currentSortValue ?? "", children: sortableColumns.map(column => (jsx(RadioItem, { className: "w-full", label: column.columnDef.header?.toString() ?? "", value: column.id }, column.id))) }), jsxs(RadioGroup, { id: "sortOrder", onChange: onSelectOrder, value: currentSortDirection, children: [jsx(RadioItem, { className: "w-full", label: t("table.sorting.ascending"), value: "asc" }), jsx(RadioItem, { className: "w-full", label: t("table.sorting.descending"), value: "desc" })] })] }) }) })] }) }));
863
874
  };
864
875
 
865
876
  /**
@@ -1275,7 +1286,7 @@ const ColumnActions = ({ header, visibleColumnsCount, setSorting, getColumn }) =
1275
1286
  if (!isAnyActionAvailable) {
1276
1287
  return null;
1277
1288
  }
1278
- return (jsx(MoreMenu, { customButton: jsx(IconButton, { className: "h-8 w-5 p-0", icon: jsx(Icon, { name: "EllipsisVertical", size: "small" }), onClick: event => event.stopPropagation(), title: t("table.columnActions.moreActions"), variant: "ghost-neutral" }), children: close => (jsxs(MenuList, { children: [showSortAscending ? (jsx(MenuItem, { label: t("table.columnActions.sortAscending"), onClick: handleSortAscending, prefix: jsx(Icon, { name: "ArrowUp", size: "medium" }) })) : null, showSortDescending ? (jsx(MenuItem, { label: t("table.columnActions.sortDescending"), onClick: handleSortDescending, prefix: jsx(Icon, { name: "ArrowDown", size: "medium" }) })) : null, showClearSorting !== false && showClearSorting !== undefined ? (jsx(MenuItem, { label: t("table.columnActions.clearSorting"), onClick: handleClearSorting, prefix: jsx(Icon, { name: "ArrowsUpDown", size: "medium" }) })) : null, showHideColumn ? (jsx(MenuItem, { label: t("table.columnActions.hideColumn"), onClick: () => header.column.toggleVisibility(false), prefix: jsx(Icon, { name: "EyeSlash", size: "medium" }) })) : null, canPin ? (jsx(MenuItem, { label: t(isPinned !== false ? "table.columnActions.unPinColumn" : "table.columnActions.pinColumn"), onClick: () => {
1289
+ return (jsx(MoreMenu, { customButton: jsx(IconButton, { className: "h-8 w-5 p-0", icon: jsx(Icon, { name: "EllipsisVertical", size: "small" }), onClick: event => event.stopPropagation(), title: t("table.columnActions.moreActions"), variant: "ghost-neutral" }), children: close => (jsxs(MenuContent, { children: [showSortAscending ? (jsx(MenuItem, { label: t("table.columnActions.sortAscending"), onClick: handleSortAscending, prefix: jsx(Icon, { name: "ArrowUp", size: "medium" }) })) : null, showSortDescending ? (jsx(MenuItem, { label: t("table.columnActions.sortDescending"), onClick: handleSortDescending, prefix: jsx(Icon, { name: "ArrowDown", size: "medium" }) })) : null, showClearSorting !== false && showClearSorting !== undefined ? (jsx(MenuItem, { label: t("table.columnActions.clearSorting"), onClick: handleClearSorting, prefix: jsx(Icon, { name: "ArrowsUpDown", size: "medium" }) })) : null, showHideColumn ? (jsx(MenuItem, { label: t("table.columnActions.hideColumn"), onClick: () => header.column.toggleVisibility(false), prefix: jsx(Icon, { name: "EyeSlash", size: "medium" }) })) : null, canPin ? (jsx(MenuItem, { label: t(isPinned !== false ? "table.columnActions.unPinColumn" : "table.columnActions.pinColumn"), onClick: () => {
1279
1290
  header.column.pin(isPinned !== false ? false : "left");
1280
1291
  close();
1281
1292
  }, prefix: jsx(Icon, { name: isPinned !== false ? "Unpin" : "Pin", size: "medium" }) })) : null] })) }));
@@ -1707,6 +1718,13 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1707
1718
  * - Do **not** call `useReactTable` directly — `useTable` adds required column-state
1708
1719
  * orchestration that the raw hook does not provide.
1709
1720
  *
1721
+ * Row expansion is managed like the column state: it starts from `initialState.expanded`, and
1722
+ * expand/collapse changes are reported to `onTableStateChange` so they can be persisted — unless
1723
+ * the consumer passes `state.expanded`, in which case it owns what is worth storing.
1724
+ *
1725
+ * `onTableStateChange` is called with the field that changed, not the whole state, so anything
1726
+ * storing it has to merge each report into what it already holds. `useTablePersistence` does.
1727
+ *
1710
1728
  * @template TData - The type representing the data model associated with the table.
1711
1729
  * @param {TableOptionsProps<TData>} props - The props object containing configuration options.
1712
1730
  * @returns {object} An object containing the React Table instance and associated state management functions.
@@ -1731,7 +1749,7 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
1731
1749
  * data,
1732
1750
  * columns,
1733
1751
  * initialState: { sorting: [{ id: "name", desc: false }] },
1734
- * onTableStateChange: state => saveTablePreferences(state),
1752
+ * onTableStateChange: changedField => setPreferences(previous => ({ ...previous, ...changedField })),
1735
1753
  * });
1736
1754
  *
1737
1755
  * return <Table table={table} />;
@@ -1827,20 +1845,27 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
1827
1845
  const columnPinning = useMemo(() => userEdits.columnPinning ?? computeMergedPinning(initialState?.columnPinning, stableUpdatedInitialColumnPinning), [userEdits.columnPinning, initialState?.columnPinning, stableUpdatedInitialColumnPinning]);
1828
1846
  const sorting = useMemo(() => userEdits.sorting ?? initialState?.sorting ?? [], [userEdits.sorting, initialState?.sorting]);
1829
1847
  const columnSizing = useMemo(() => userEdits.columnSizing ?? initialState?.columnSizing ?? {}, [userEdits.columnSizing, initialState?.columnSizing]);
1830
- const baseState = useMemo(() => ({ columnVisibility, columnOrder, sorting, columnSizing, columnPinning }), [columnVisibility, columnOrder, sorting, columnSizing, columnPinning]);
1848
+ const expanded = useMemo(() => userEdits.expanded ?? initialState?.expanded ?? {}, [userEdits.expanded, initialState?.expanded]);
1849
+ const baseState = useMemo(() => ({ columnVisibility, columnOrder, sorting, columnSizing, columnPinning, expanded }), [columnVisibility, columnOrder, sorting, columnSizing, columnPinning, expanded]);
1831
1850
  const state = useMemo(() => ({ ...baseState, ...reactTableProps.state }), [baseState, reactTableProps.state]);
1832
1851
  // A ref exposes the latest derived state to the change callbacks without making them depend on
1833
- // it (callbacks run on user events, outside render). Every column-state key on `state` is always
1852
+ // it (callbacks run on user events, outside render). Every managed key on `state` is always
1834
1853
  // defined — it originates from `baseState` — so the callbacks read it directly.
1835
1854
  const stateRef = useRef(state);
1836
1855
  stateRef.current = state;
1837
- // Persist on user actions only (not on mount or prop-driven recomputation): drop column sizes
1838
- // that match their default so only meaningful overrides are stored.
1856
+ // Reports only the field that changed `useTablePersistence` merges partial reports so a
1857
+ // change can never store another field's unsettled or transient value. Column sizes matching
1858
+ // their default are dropped so only meaningful overrides are stored.
1839
1859
  const persistTableState = useCallback((nextState) => {
1840
1860
  if (!onTableStateChange) {
1841
1861
  return;
1842
1862
  }
1843
- const columnSizingToPersist = Object.fromEntries(Object.entries(nextState.columnSizing ?? {}).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
1863
+ const nextColumnSizing = nextState.columnSizing;
1864
+ if (!nextColumnSizing) {
1865
+ onTableStateChange(nextState);
1866
+ return;
1867
+ }
1868
+ const columnSizingToPersist = Object.fromEntries(objectEntries(nextColumnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
1844
1869
  onTableStateChange({ ...nextState, columnSizing: columnSizingToPersist });
1845
1870
  }, [onTableStateChange, defaultColumnSizingById]);
1846
1871
  const table = useReactTable({
@@ -1854,37 +1879,46 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
1854
1879
  const current = stateRef.current.columnVisibility;
1855
1880
  const next = typeof value === "function" ? value(current) : value;
1856
1881
  setUserEdits(prev => ({ ...prev, columnVisibility: next }));
1857
- persistTableState({ ...stateRef.current, columnVisibility: next });
1882
+ persistTableState({ columnVisibility: next });
1858
1883
  reactTableProps.onColumnVisibilityChange?.(value);
1859
1884
  },
1860
1885
  onColumnSizingChange: value => {
1861
1886
  const current = stateRef.current.columnSizing;
1862
1887
  const next = typeof value === "function" ? value(current) : value;
1863
1888
  setUserEdits(prev => ({ ...prev, columnSizing: next }));
1864
- persistTableState({ ...stateRef.current, columnSizing: next });
1889
+ persistTableState({ columnSizing: next });
1865
1890
  reactTableProps.onColumnSizingChange?.(value);
1866
1891
  },
1867
1892
  onColumnOrderChange: value => {
1868
1893
  const current = stateRef.current.columnOrder;
1869
1894
  const next = typeof value === "function" ? value(current) : value;
1870
1895
  setUserEdits(prev => ({ ...prev, columnOrder: next }));
1871
- persistTableState({ ...stateRef.current, columnOrder: next });
1896
+ persistTableState({ columnOrder: next });
1872
1897
  reactTableProps.onColumnOrderChange?.(value);
1873
1898
  },
1874
1899
  onSortingChange: value => {
1875
1900
  const current = stateRef.current.sorting;
1876
1901
  const next = typeof value === "function" ? value(current) : value;
1877
1902
  setUserEdits(prev => ({ ...prev, sorting: next }));
1878
- persistTableState({ ...stateRef.current, sorting: next });
1903
+ persistTableState({ sorting: next });
1879
1904
  reactTableProps.onSortingChange?.(value);
1880
1905
  },
1881
1906
  onColumnPinningChange: value => {
1882
1907
  const current = stateRef.current.columnPinning;
1883
1908
  const next = typeof value === "function" ? value(current) : value;
1884
1909
  setUserEdits(prev => ({ ...prev, columnPinning: next }));
1885
- persistTableState({ ...stateRef.current, columnPinning: next });
1910
+ persistTableState({ columnPinning: next });
1886
1911
  reactTableProps.onColumnPinningChange?.(value);
1887
1912
  },
1913
+ onExpandedChange: value => {
1914
+ const current = stateRef.current.expanded;
1915
+ const next = typeof value === "function" ? value(current) : value;
1916
+ setUserEdits(prev => ({ ...prev, expanded: next }));
1917
+ if (reactTableProps.state?.expanded === undefined) {
1918
+ persistTableState({ expanded: next });
1919
+ }
1920
+ reactTableProps.onExpandedChange?.(value);
1921
+ },
1888
1922
  columns,
1889
1923
  state,
1890
1924
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-table",
3
- "version": "2.4.2-alpha-3b19e5fe058.0",
3
+ "version": "2.5.5-alpha-770d9994af7.0",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -11,15 +11,15 @@
11
11
  "react-dnd": "16.0.1",
12
12
  "react-dnd-html5-backend": "16.0.1",
13
13
  "tailwind-merge": "^2.0.0",
14
- "@trackunit/react-components": "2.5.2-alpha-3b19e5fe058.0",
15
- "@trackunit/react-core-hooks": "1.20.1-alpha-3b19e5fe058.0",
16
- "@trackunit/shared-utils": "1.16.1-alpha-3b19e5fe058.0",
17
- "@trackunit/css-class-variance-utilities": "1.14.1-alpha-3b19e5fe058.0",
18
- "@trackunit/ui-icons": "1.14.1-alpha-3b19e5fe058.0",
19
- "@trackunit/react-table-base-components": "2.4.2-alpha-3b19e5fe058.0",
20
- "@trackunit/react-form-components": "2.4.2-alpha-3b19e5fe058.0",
21
- "@trackunit/i18n-library-translation": "2.3.1-alpha-3b19e5fe058.0",
22
- "@trackunit/iris-app-runtime-core-api": "1.17.1-alpha-3b19e5fe058.0",
14
+ "@trackunit/react-components": "2.6.2-alpha-770d9994af7.0",
15
+ "@trackunit/react-core-hooks": "1.20.4-alpha-770d9994af7.0",
16
+ "@trackunit/shared-utils": "1.16.3-alpha-770d9994af7.0",
17
+ "@trackunit/css-class-variance-utilities": "1.14.3-alpha-770d9994af7.0",
18
+ "@trackunit/ui-icons": "1.14.3-alpha-770d9994af7.0",
19
+ "@trackunit/react-table-base-components": "2.4.7-alpha-770d9994af7.0",
20
+ "@trackunit/react-form-components": "2.4.7-alpha-770d9994af7.0",
21
+ "@trackunit/i18n-library-translation": "2.3.4-alpha-770d9994af7.0",
22
+ "@trackunit/iris-app-runtime-core-api": "1.17.3-alpha-770d9994af7.0",
23
23
  "zod": "^3.25.76"
24
24
  },
25
25
  "peerDependencies": {
@@ -10,7 +10,7 @@ export interface ActionSheetProps extends CommonProps, Styleable {
10
10
  */
11
11
  moreActions?: Array<ActionModel>;
12
12
  /**
13
- * Actions to be displayed as primary actions with capability to pass MenuList as dropdown
13
+ * Actions to be displayed as primary actions with capability to pass MenuContent as dropdown
14
14
  */
15
15
  dropdownActions?: Array<DropdownModel>;
16
16
  /**
@@ -102,7 +102,7 @@ export interface ActionSheetProps extends CommonProps, Styleable {
102
102
  * @example With dropdown actions
103
103
  * ```tsx
104
104
  * import { ActionSheet } from "@trackunit/react-table";
105
- * import { MenuList, MenuItem } from "@trackunit/react-components";
105
+ * import { MenuContent, MenuItem } from "@trackunit/react-components";
106
106
  *
107
107
  * <ActionSheet
108
108
  * selections={selectedIds}
@@ -115,10 +115,10 @@ export interface ActionSheetProps extends CommonProps, Styleable {
115
115
  * label: "Change Status",
116
116
  * iconName: "Flag",
117
117
  * dropdownContent: (close) => (
118
- * <MenuList>
118
+ * <MenuContent>
119
119
  * <MenuItem label="Active" onClick={() => { setStatus("active"); close(); }} />
120
120
  * <MenuItem label="Inactive" onClick={() => { setStatus("inactive"); close(); }} />
121
- * </MenuList>
121
+ * </MenuContent>
122
122
  * ),
123
123
  * },
124
124
  * ]}
@@ -84,6 +84,12 @@ type UseTablePersistenceResult = {
84
84
  * the URL, such as `expanded`, still survive reloads. Changes are
85
85
  * debounced (300 ms) and synced to both the hash and localStorage.
86
86
  *
87
+ * `onTableStateChange` takes a *partial* state and merges it into whatever
88
+ * was reported earlier in the session, so a consumer can report a single
89
+ * slice (e.g. only `expanded`) without the omitted fields falling back to
90
+ * their mount-time values. A field reported empty is dropped from the stored
91
+ * state, and once every field is empty the entry is removed altogether.
92
+ *
87
93
  * @param persistenceKey - Unique key used to store and retrieve the table state.
88
94
  * Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
89
95
  * @returns {UseTablePersistenceResult} An object containing:
@@ -1,7 +1,8 @@
1
1
  import { Column } from "@tanstack/react-table";
2
2
  import { ReactElement } from "react";
3
+ type SortableColumn<TSorting extends object, TSortingValue> = Pick<Column<TSorting, TSortingValue>, "id" | "getCanSort" | "getIsSorted" | "toggleSorting" | "columnDef">;
3
4
  interface SortingProps<TSorting extends object, TSortingValue> {
4
- columns: Array<Column<TSorting, TSortingValue>>;
5
+ columns: Array<SortableColumn<TSorting, TSortingValue>>;
5
6
  }
6
7
  /**
7
8
  * Sorting component for managing table column sorting.
package/src/useTable.d.ts CHANGED
@@ -16,6 +16,13 @@ export interface TableOptionsProps<TData extends object> extends Omit<TableOptio
16
16
  * - Do **not** call `useReactTable` directly — `useTable` adds required column-state
17
17
  * orchestration that the raw hook does not provide.
18
18
  *
19
+ * Row expansion is managed like the column state: it starts from `initialState.expanded`, and
20
+ * expand/collapse changes are reported to `onTableStateChange` so they can be persisted — unless
21
+ * the consumer passes `state.expanded`, in which case it owns what is worth storing.
22
+ *
23
+ * `onTableStateChange` is called with the field that changed, not the whole state, so anything
24
+ * storing it has to merge each report into what it already holds. `useTablePersistence` does.
25
+ *
19
26
  * @template TData - The type representing the data model associated with the table.
20
27
  * @param {TableOptionsProps<TData>} props - The props object containing configuration options.
21
28
  * @returns {object} An object containing the React Table instance and associated state management functions.
@@ -40,7 +47,7 @@ export interface TableOptionsProps<TData extends object> extends Omit<TableOptio
40
47
  * data,
41
48
  * columns,
42
49
  * initialState: { sorting: [{ id: "name", desc: false }] },
43
- * onTableStateChange: state => saveTablePreferences(state),
50
+ * onTableStateChange: changedField => setPreferences(previous => ({ ...previous, ...changedField })),
44
51
  * });
45
52
  *
46
53
  * return <Table table={table} />;