@trackunit/react-components 1.21.15 → 1.21.17

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
@@ -20,8 +20,9 @@ var reactVirtual = require('@tanstack/react-virtual');
20
20
  var reactHelmetAsync = require('react-helmet-async');
21
21
  var reactTabs = require('@radix-ui/react-tabs');
22
22
  var fflate = require('fflate');
23
- var zod = require('zod');
24
23
  var superjson = require('superjson');
24
+ var zod = require('zod');
25
+ var dequal = require('dequal');
25
26
 
26
27
  const cvaIcon = cssClassVarianceUtilities.cvaMerge(["aspect-square", "inline-grid", "relative", "shrink-0"], {
27
28
  variants: {
@@ -7829,14 +7830,15 @@ const getSheetPanelStyle = ({ snapHeight, isOpen, closePhase, variant, autoHeigh
7829
7830
  height: autoHeight ? "fit-content" : `var(--sheet-drag-height, ${snapHeight})`,
7830
7831
  maxHeight: maxHeight ?? `calc(100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px)`,
7831
7832
  pointerEvents: "auto",
7832
- transform: isOpen ? "translateY(0)" : "translateY(100%)",
7833
- transition: suppressTransition
7833
+ transform: isOpen
7834
+ ? "translateY(0) scale(var(--sheet-stack-scale, 1))"
7835
+ : "translateY(100%) scale(var(--sheet-stack-scale, 1))",
7836
+ transformOrigin: "bottom center",
7837
+ transition: isDragging
7834
7838
  ? "none"
7835
- : isDragging
7836
- ? "none"
7837
- : autoHeight || !isOpen
7838
- ? SHEET_TRANSFORM_TRANSITION
7839
- : SHEET_OPEN_TRANSITION,
7839
+ : suppressTransition || autoHeight || !isOpen
7840
+ ? SHEET_TRANSFORM_TRANSITION
7841
+ : SHEET_OPEN_TRANSITION,
7840
7842
  };
7841
7843
  };
7842
7844
 
@@ -10316,8 +10318,11 @@ const useCustomEncoding = () => {
10316
10318
  // If it's already a string, use it directly; otherwise stringify the object
10317
10319
  const json = typeof input === "string" ? input : JSON.stringify(input);
10318
10320
  const textInput = new TextEncoder().encode(json);
10319
- // Use fflate for synchronous gzip compression
10320
- const compressed = fflate.gzipSync(textInput);
10321
+ // Use fflate for synchronous gzip compression.
10322
+ // mtime: 0 ensures deterministic output — without it the gzip header
10323
+ // includes the current timestamp, making the same input produce a
10324
+ // different encoded string on every call.
10325
+ const compressed = fflate.gzipSync(textInput, { mtime: 0 });
10321
10326
  return b64urlEncode(compressed);
10322
10327
  }
10323
10328
  catch (_) {
@@ -10369,6 +10374,36 @@ const useCustomEncoding = () => {
10369
10374
  return react.useMemo(() => ({ encode, decode }), [encode, decode]);
10370
10375
  };
10371
10376
 
10377
+ /**
10378
+ * Internal envelope used to tag superjson-serialized data in web storage.
10379
+ *
10380
+ * `__serializer` is a **reserved internal key** — consumer state objects must
10381
+ * not include it as a top-level key. The double-underscore prefix is a
10382
+ * deliberate signal that this is a private implementation detail.
10383
+ *
10384
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
10385
+ * detected in the value being written.
10386
+ */
10387
+ const taggedSuperjsonEnvelopeSchema = zod.z.object({
10388
+ __serializer: zod.z.literal("superjson"),
10389
+ json: zod.z.custom(),
10390
+ meta: zod.z.custom().optional(),
10391
+ });
10392
+ const storageSerializer = {
10393
+ serialize: (value) => {
10394
+ const serialized = superjson.serialize(value);
10395
+ return JSON.stringify({ __serializer: "superjson", ...serialized });
10396
+ },
10397
+ deserialize: (value) => {
10398
+ const parsed = JSON.parse(value);
10399
+ const result = taggedSuperjsonEnvelopeSchema.safeParse(parsed);
10400
+ if (result.success) {
10401
+ return superjson.deserialize({ json: result.data.json, meta: result.data.meta });
10402
+ }
10403
+ return parsed;
10404
+ },
10405
+ };
10406
+
10372
10407
  /**
10373
10408
  * Runs a sequential migration pipeline on the provided data, applying
10374
10409
  * each migration whose version is in the range (fromVersion, toVersion].
@@ -10474,36 +10509,6 @@ const salvageState = (schema, rawData, defaultState) => {
10474
10509
  }
10475
10510
  };
10476
10511
 
10477
- /**
10478
- * Internal envelope used to tag superjson-serialized data in web storage.
10479
- *
10480
- * `__serializer` is a **reserved internal key** — consumer state objects must
10481
- * not include it as a top-level key. The double-underscore prefix is a
10482
- * deliberate signal that this is a private implementation detail.
10483
- *
10484
- * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
10485
- * detected in the value being written.
10486
- */
10487
- const taggedSuperjsonEnvelopeSchema = zod.z.object({
10488
- __serializer: zod.z.literal("superjson"),
10489
- json: zod.z.custom(),
10490
- meta: zod.z.custom().optional(),
10491
- });
10492
- const storageSerializer = {
10493
- serialize: (value) => {
10494
- const serialized = superjson.serialize(value);
10495
- return JSON.stringify({ __serializer: "superjson", ...serialized });
10496
- },
10497
- deserialize: (value) => {
10498
- const parsed = JSON.parse(value);
10499
- const result = taggedSuperjsonEnvelopeSchema.safeParse(parsed);
10500
- if (result.success) {
10501
- return superjson.deserialize({ json: result.data.json, meta: result.data.meta });
10502
- }
10503
- return parsed;
10504
- },
10505
- };
10506
-
10507
10512
  /**
10508
10513
  * Internal envelope that pairs stored data with a schema version number.
10509
10514
  *
@@ -10979,6 +10984,202 @@ const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage,
10979
10984
  */
10980
10985
  const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
10981
10986
 
10987
+ const MAX_URL_LENGTH = 5000;
10988
+ /**
10989
+ * Syncs an encoded string value with a URL search parameter via Tanstack Router.
10990
+ *
10991
+ * Provides a write function that updates the URL through the application
10992
+ * router (preserving other search params), guards against exceeding
10993
+ * {@link MAX_URL_LENGTH}, and detects external URL changes (browser
10994
+ * back/forward, shared links) via an optional callback.
10995
+ *
10996
+ * @param options - Configuration for the search param sync.
10997
+ * @param options.key - The URL search parameter name.
10998
+ * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
10999
+ * @param options.onExternalChange - Called when the param changes externally.
11000
+ * @param options.replace - `true` to always use `replaceState`.
11001
+ */
11002
+ const useSearchParamSync = ({ key, enabled = true, onExternalChange, replace: replaceOption, }) => {
11003
+ const navigate = reactRouter.useNavigate();
11004
+ const location = reactRouter.useLocation();
11005
+ const search = reactRouter.useSearch({ strict: false, shouldThrow: false });
11006
+ const lastWrittenRef = react.useRef(undefined);
11007
+ const onExternalChangeRef = react.useRef(onExternalChange);
11008
+ react.useEffect(() => {
11009
+ onExternalChangeRef.current = onExternalChange;
11010
+ }, [onExternalChange]);
11011
+ const currentSearchValue = react.useMemo(() => {
11012
+ if (!enabled) {
11013
+ return undefined;
11014
+ }
11015
+ const value = search?.[key];
11016
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
11017
+ return String(value);
11018
+ }
11019
+ return undefined;
11020
+ }, [enabled, search, key]);
11021
+ react.useEffect(() => {
11022
+ if (!enabled) {
11023
+ return;
11024
+ }
11025
+ if (currentSearchValue === undefined) {
11026
+ return;
11027
+ }
11028
+ if (currentSearchValue === lastWrittenRef.current) {
11029
+ return;
11030
+ }
11031
+ if (lastWrittenRef.current === undefined) {
11032
+ return;
11033
+ }
11034
+ requestAnimationFrame(() => {
11035
+ onExternalChangeRef.current?.();
11036
+ });
11037
+ }, [currentSearchValue, enabled]);
11038
+ const getUrlLengthWithSearchParam = react.useCallback((paramKey, paramValue, params) => {
11039
+ const otherParamsLength = sharedUtils.objectKeys(params)
11040
+ .filter(k => k !== paramKey)
11041
+ .reduce((totalLength, k) => {
11042
+ const kLen = encodeURIComponent(String(k)).length;
11043
+ const v = params[k];
11044
+ const vLen = v !== null && v !== undefined ? encodeURIComponent(String(v)).length : 0;
11045
+ return totalLength + kLen + vLen + (totalLength > 0 ? 2 : 1);
11046
+ }, 0);
11047
+ const urlBase = location.href.length - (location.searchStr.length || 0) + (location.hash.length || 0);
11048
+ return urlBase + otherParamsLength + 1 + paramKey.length + 1 + paramValue.length;
11049
+ }, [location]);
11050
+ const updateSearchParam = react.useCallback((encodedValue) => {
11051
+ if (!enabled) {
11052
+ return;
11053
+ }
11054
+ if (encodedValue !== undefined && encodedValue === lastWrittenRef.current) {
11055
+ return;
11056
+ }
11057
+ lastWrittenRef.current = encodedValue;
11058
+ if (currentSearchValue === encodedValue) {
11059
+ return;
11060
+ }
11061
+ requestAnimationFrame(() => {
11062
+ const shouldReplace = replaceOption ?? !Boolean(currentSearchValue);
11063
+ void navigate({
11064
+ to: ".",
11065
+ search: (prev) => {
11066
+ if (getUrlLengthWithSearchParam(key, encodedValue || "", prev) <= MAX_URL_LENGTH) {
11067
+ return { ...prev, [key]: encodedValue };
11068
+ }
11069
+ else {
11070
+ return { ...prev, [key]: undefined };
11071
+ }
11072
+ },
11073
+ hash: location.hash,
11074
+ replace: shouldReplace,
11075
+ });
11076
+ });
11077
+ }, [enabled, navigate, key, replaceOption, location.hash, getUrlLengthWithSearchParam, currentSearchValue]);
11078
+ return react.useMemo(() => ({ searchValue: currentSearchValue, updateSearchParam }), [currentSearchValue, updateSearchParam]);
11079
+ };
11080
+
11081
+ /**
11082
+ * Generates a localStorage key, optionally scoped to a specific user.
11083
+ *
11084
+ * When `userId` is provided the key is `"key-userId"`.
11085
+ * When omitted the key is returned as-is (unscoped).
11086
+ *
11087
+ * @param key - Unique persistence identifier.
11088
+ * @param userId - Client-side user id to scope storage per user.
11089
+ * @returns {string} The combined storage key.
11090
+ */
11091
+ const useStorageKey = (key, userId) => {
11092
+ return react.useMemo(() => (userId ? `${key}-${userId}` : key), [key, userId]);
11093
+ };
11094
+
11095
+ /**
11096
+ * Generic persistence hook that loads state from URL search params (with
11097
+ * localStorage fallback) and writes changes to both.
11098
+ *
11099
+ * On mount the hook tries, in order:
11100
+ * 1. Decode the URL search param and pass it through `validate`.
11101
+ * 2. If that yields nothing, read localStorage and pass the parsed JSON through `validate`.
11102
+ *
11103
+ * `persistState` writes the state to localStorage (via `serialize`, default
11104
+ * `JSON.stringify`) and syncs to the URL (via `toUrlValue` + encoding).
11105
+ * Duplicate writes where the state has not changed (deep equality) are skipped.
11106
+ *
11107
+ * @param options - Configuration for the persisted state.
11108
+ * @param options.key - Unique identifier used for both the URL search param and the localStorage key.
11109
+ * @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
11110
+ * @param options.serialize - Custom localStorage serialiser (default `storageSerializer.serialize`).
11111
+ * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
11112
+ * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
11113
+ * @param options.enabled - When `false` the URL is neither read nor written (default `true`).
11114
+ * @param options.onExternalChange - Fired when the URL param changes externally (e.g. browser back).
11115
+ * @param options.replace - Forwarded to `useSearchParamSync`.
11116
+ * @param options.clientSideUserId - The user ID to use for the localStorage key.
11117
+ */
11118
+ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, onExternalChange, replace, clientSideUserId, }) => {
11119
+ const { encode, decode } = useCustomEncoding();
11120
+ const { searchValue, updateSearchParam } = useSearchParamSync({
11121
+ key,
11122
+ enabled,
11123
+ onExternalChange,
11124
+ replace,
11125
+ });
11126
+ const storageKey = useStorageKey(key, clientSideUserId);
11127
+ const validateRef = react.useRef(validate);
11128
+ react.useEffect(() => {
11129
+ validateRef.current = validate;
11130
+ }, [validate]);
11131
+ const toUrlValueRef = react.useRef(toUrlValue);
11132
+ react.useEffect(() => {
11133
+ toUrlValueRef.current = toUrlValue;
11134
+ }, [toUrlValue]);
11135
+ const fromUrlValueRef = react.useRef(fromUrlValue);
11136
+ react.useEffect(() => {
11137
+ fromUrlValueRef.current = fromUrlValue;
11138
+ }, [fromUrlValue]);
11139
+ const serializeRef = react.useRef(serialize);
11140
+ react.useEffect(() => {
11141
+ serializeRef.current = serialize;
11142
+ }, [serialize]);
11143
+ const [initialState] = react.useState(() => {
11144
+ if (enabled && searchValue) {
11145
+ try {
11146
+ const decoded = decode(searchValue);
11147
+ const transformed = fromUrlValue ? fromUrlValue(decoded) : decoded;
11148
+ const validated = validate(transformed);
11149
+ if (validated !== undefined) {
11150
+ return validated;
11151
+ }
11152
+ }
11153
+ catch {
11154
+ // fall through to localStorage
11155
+ }
11156
+ }
11157
+ try {
11158
+ const raw = localStorage.getItem(storageKey);
11159
+ if (raw) {
11160
+ const parsed = storageSerializer.deserialize(raw);
11161
+ return validate(parsed);
11162
+ }
11163
+ }
11164
+ catch {
11165
+ // no valid stored state
11166
+ }
11167
+ return undefined;
11168
+ });
11169
+ const lastPersistedRef = react.useRef(initialState);
11170
+ const persistState = react.useCallback((state) => {
11171
+ if (dequal.dequal(lastPersistedRef.current, state)) {
11172
+ return;
11173
+ }
11174
+ lastPersistedRef.current = state;
11175
+ const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
11176
+ localStorage.setItem(storageKey, serialized);
11177
+ const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
11178
+ updateSearchParam(encode(urlValue));
11179
+ }, [storageKey, encode, updateSearchParam]);
11180
+ return react.useMemo(() => ({ initialState, persistState }), [initialState, persistState]);
11181
+ };
11182
+
10982
11183
  const OVERSCAN = 10;
10983
11184
  const DEFAULT_ROW_HEIGHT = 50;
10984
11185
  /**
@@ -12154,6 +12355,7 @@ exports.KPICardSkeleton = KPICardSkeleton;
12154
12355
  exports.KPISkeleton = KPISkeleton;
12155
12356
  exports.List = List;
12156
12357
  exports.ListItem = ListItem;
12358
+ exports.MAX_URL_LENGTH = MAX_URL_LENGTH;
12157
12359
  exports.MenuDivider = MenuDivider;
12158
12360
  exports.MenuItem = MenuItem;
12159
12361
  exports.MenuList = MenuList;
@@ -12255,6 +12457,7 @@ exports.iconColorNames = iconColorNames;
12255
12457
  exports.iconPalette = iconPalette;
12256
12458
  exports.noPagination = noPagination;
12257
12459
  exports.preferenceCardGrid = preferenceCardGrid;
12460
+ exports.storageSerializer = storageSerializer;
12258
12461
  exports.useBidirectionalScroll = useBidirectionalScroll;
12259
12462
  exports.useClickOutside = useClickOutside;
12260
12463
  exports.useContainerBreakpoints = useContainerBreakpoints;
@@ -12282,6 +12485,7 @@ exports.useMergeRefs = useMergeRefs;
12282
12485
  exports.useModifierKey = useModifierKey;
12283
12486
  exports.useOverflowBorder = useOverflowBorder;
12284
12487
  exports.useOverflowItems = useOverflowItems;
12488
+ exports.usePersistedState = usePersistedState;
12285
12489
  exports.usePopoverContext = usePopoverContext;
12286
12490
  exports.usePrevious = usePrevious;
12287
12491
  exports.usePrompt = usePrompt;
@@ -12290,11 +12494,13 @@ exports.useRelayPagination = useRelayPagination;
12290
12494
  exports.useResize = useResize;
12291
12495
  exports.useScrollBlock = useScrollBlock;
12292
12496
  exports.useScrollDetection = useScrollDetection;
12497
+ exports.useSearchParamSync = useSearchParamSync;
12293
12498
  exports.useSelfUpdatingRef = useSelfUpdatingRef;
12294
12499
  exports.useSessionStorage = useSessionStorage;
12295
12500
  exports.useSessionStorageReducer = useSessionStorageReducer;
12296
12501
  exports.useSheet = useSheet;
12297
12502
  exports.useSheetSnap = useSheetSnap;
12503
+ exports.useStorageKey = useStorageKey;
12298
12504
  exports.useTextSearch = useTextSearch;
12299
12505
  exports.useTimeout = useTimeout;
12300
12506
  exports.useViewportBreakpoints = useViewportBreakpoints;
package/index.esm.js CHANGED
@@ -10,7 +10,7 @@ import IconSpriteSolid from '@trackunit/ui-icons/icons-sprite-solid.svg';
10
10
  import { snakeCase, titleCase } from 'string-ts';
11
11
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
12
12
  import { Slot, Slottable } from '@radix-ui/react-slot';
13
- import { Link, useBlocker, useSearch, useNavigate } from '@tanstack/react-router';
13
+ import { Link, useBlocker, useNavigate, useLocation, useSearch } from '@tanstack/react-router';
14
14
  import { isEqual, omit } from 'es-toolkit';
15
15
  import { useFloating, offset, flip, shift, size, autoUpdate, useClick, useDismiss, useHover as useHover$1, safePolygon, useRole, useInteractions, FloatingPortal, useMergeRefs as useMergeRefs$1, FloatingFocusManager, arrow, useTransitionStatus, FloatingArrow } from '@floating-ui/react';
16
16
  import { twMerge } from 'tailwind-merge';
@@ -18,8 +18,9 @@ import { useVirtualizer } from '@tanstack/react-virtual';
18
18
  import { HelmetProvider, Helmet } from 'react-helmet-async';
19
19
  import { Trigger, Content as Content$1, List as List$1, Root } from '@radix-ui/react-tabs';
20
20
  import { gzipSync, gunzipSync } from 'fflate';
21
- import { z } from 'zod';
22
21
  import superjson from 'superjson';
22
+ import { z } from 'zod';
23
+ import { dequal } from 'dequal';
23
24
 
24
25
  const cvaIcon = cvaMerge(["aspect-square", "inline-grid", "relative", "shrink-0"], {
25
26
  variants: {
@@ -7827,14 +7828,15 @@ const getSheetPanelStyle = ({ snapHeight, isOpen, closePhase, variant, autoHeigh
7827
7828
  height: autoHeight ? "fit-content" : `var(--sheet-drag-height, ${snapHeight})`,
7828
7829
  maxHeight: maxHeight ?? `calc(100cqh - ${FULL_HEIGHT_TOP_MARGIN_PX}px)`,
7829
7830
  pointerEvents: "auto",
7830
- transform: isOpen ? "translateY(0)" : "translateY(100%)",
7831
- transition: suppressTransition
7831
+ transform: isOpen
7832
+ ? "translateY(0) scale(var(--sheet-stack-scale, 1))"
7833
+ : "translateY(100%) scale(var(--sheet-stack-scale, 1))",
7834
+ transformOrigin: "bottom center",
7835
+ transition: isDragging
7832
7836
  ? "none"
7833
- : isDragging
7834
- ? "none"
7835
- : autoHeight || !isOpen
7836
- ? SHEET_TRANSFORM_TRANSITION
7837
- : SHEET_OPEN_TRANSITION,
7837
+ : suppressTransition || autoHeight || !isOpen
7838
+ ? SHEET_TRANSFORM_TRANSITION
7839
+ : SHEET_OPEN_TRANSITION,
7838
7840
  };
7839
7841
  };
7840
7842
 
@@ -10314,8 +10316,11 @@ const useCustomEncoding = () => {
10314
10316
  // If it's already a string, use it directly; otherwise stringify the object
10315
10317
  const json = typeof input === "string" ? input : JSON.stringify(input);
10316
10318
  const textInput = new TextEncoder().encode(json);
10317
- // Use fflate for synchronous gzip compression
10318
- const compressed = gzipSync(textInput);
10319
+ // Use fflate for synchronous gzip compression.
10320
+ // mtime: 0 ensures deterministic output — without it the gzip header
10321
+ // includes the current timestamp, making the same input produce a
10322
+ // different encoded string on every call.
10323
+ const compressed = gzipSync(textInput, { mtime: 0 });
10319
10324
  return b64urlEncode(compressed);
10320
10325
  }
10321
10326
  catch (_) {
@@ -10367,6 +10372,36 @@ const useCustomEncoding = () => {
10367
10372
  return useMemo(() => ({ encode, decode }), [encode, decode]);
10368
10373
  };
10369
10374
 
10375
+ /**
10376
+ * Internal envelope used to tag superjson-serialized data in web storage.
10377
+ *
10378
+ * `__serializer` is a **reserved internal key** — consumer state objects must
10379
+ * not include it as a top-level key. The double-underscore prefix is a
10380
+ * deliberate signal that this is a private implementation detail.
10381
+ *
10382
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
10383
+ * detected in the value being written.
10384
+ */
10385
+ const taggedSuperjsonEnvelopeSchema = z.object({
10386
+ __serializer: z.literal("superjson"),
10387
+ json: z.custom(),
10388
+ meta: z.custom().optional(),
10389
+ });
10390
+ const storageSerializer = {
10391
+ serialize: (value) => {
10392
+ const serialized = superjson.serialize(value);
10393
+ return JSON.stringify({ __serializer: "superjson", ...serialized });
10394
+ },
10395
+ deserialize: (value) => {
10396
+ const parsed = JSON.parse(value);
10397
+ const result = taggedSuperjsonEnvelopeSchema.safeParse(parsed);
10398
+ if (result.success) {
10399
+ return superjson.deserialize({ json: result.data.json, meta: result.data.meta });
10400
+ }
10401
+ return parsed;
10402
+ },
10403
+ };
10404
+
10370
10405
  /**
10371
10406
  * Runs a sequential migration pipeline on the provided data, applying
10372
10407
  * each migration whose version is in the range (fromVersion, toVersion].
@@ -10472,36 +10507,6 @@ const salvageState = (schema, rawData, defaultState) => {
10472
10507
  }
10473
10508
  };
10474
10509
 
10475
- /**
10476
- * Internal envelope used to tag superjson-serialized data in web storage.
10477
- *
10478
- * `__serializer` is a **reserved internal key** — consumer state objects must
10479
- * not include it as a top-level key. The double-underscore prefix is a
10480
- * deliberate signal that this is a private implementation detail.
10481
- *
10482
- * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
10483
- * detected in the value being written.
10484
- */
10485
- const taggedSuperjsonEnvelopeSchema = z.object({
10486
- __serializer: z.literal("superjson"),
10487
- json: z.custom(),
10488
- meta: z.custom().optional(),
10489
- });
10490
- const storageSerializer = {
10491
- serialize: (value) => {
10492
- const serialized = superjson.serialize(value);
10493
- return JSON.stringify({ __serializer: "superjson", ...serialized });
10494
- },
10495
- deserialize: (value) => {
10496
- const parsed = JSON.parse(value);
10497
- const result = taggedSuperjsonEnvelopeSchema.safeParse(parsed);
10498
- if (result.success) {
10499
- return superjson.deserialize({ json: result.data.json, meta: result.data.meta });
10500
- }
10501
- return parsed;
10502
- },
10503
- };
10504
-
10505
10510
  /**
10506
10511
  * Internal envelope that pairs stored data with a schema version number.
10507
10512
  *
@@ -10977,6 +10982,202 @@ const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage,
10977
10982
  */
10978
10983
  const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
10979
10984
 
10985
+ const MAX_URL_LENGTH = 5000;
10986
+ /**
10987
+ * Syncs an encoded string value with a URL search parameter via Tanstack Router.
10988
+ *
10989
+ * Provides a write function that updates the URL through the application
10990
+ * router (preserving other search params), guards against exceeding
10991
+ * {@link MAX_URL_LENGTH}, and detects external URL changes (browser
10992
+ * back/forward, shared links) via an optional callback.
10993
+ *
10994
+ * @param options - Configuration for the search param sync.
10995
+ * @param options.key - The URL search parameter name.
10996
+ * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
10997
+ * @param options.onExternalChange - Called when the param changes externally.
10998
+ * @param options.replace - `true` to always use `replaceState`.
10999
+ */
11000
+ const useSearchParamSync = ({ key, enabled = true, onExternalChange, replace: replaceOption, }) => {
11001
+ const navigate = useNavigate();
11002
+ const location = useLocation();
11003
+ const search = useSearch({ strict: false, shouldThrow: false });
11004
+ const lastWrittenRef = useRef(undefined);
11005
+ const onExternalChangeRef = useRef(onExternalChange);
11006
+ useEffect(() => {
11007
+ onExternalChangeRef.current = onExternalChange;
11008
+ }, [onExternalChange]);
11009
+ const currentSearchValue = useMemo(() => {
11010
+ if (!enabled) {
11011
+ return undefined;
11012
+ }
11013
+ const value = search?.[key];
11014
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
11015
+ return String(value);
11016
+ }
11017
+ return undefined;
11018
+ }, [enabled, search, key]);
11019
+ useEffect(() => {
11020
+ if (!enabled) {
11021
+ return;
11022
+ }
11023
+ if (currentSearchValue === undefined) {
11024
+ return;
11025
+ }
11026
+ if (currentSearchValue === lastWrittenRef.current) {
11027
+ return;
11028
+ }
11029
+ if (lastWrittenRef.current === undefined) {
11030
+ return;
11031
+ }
11032
+ requestAnimationFrame(() => {
11033
+ onExternalChangeRef.current?.();
11034
+ });
11035
+ }, [currentSearchValue, enabled]);
11036
+ const getUrlLengthWithSearchParam = useCallback((paramKey, paramValue, params) => {
11037
+ const otherParamsLength = objectKeys(params)
11038
+ .filter(k => k !== paramKey)
11039
+ .reduce((totalLength, k) => {
11040
+ const kLen = encodeURIComponent(String(k)).length;
11041
+ const v = params[k];
11042
+ const vLen = v !== null && v !== undefined ? encodeURIComponent(String(v)).length : 0;
11043
+ return totalLength + kLen + vLen + (totalLength > 0 ? 2 : 1);
11044
+ }, 0);
11045
+ const urlBase = location.href.length - (location.searchStr.length || 0) + (location.hash.length || 0);
11046
+ return urlBase + otherParamsLength + 1 + paramKey.length + 1 + paramValue.length;
11047
+ }, [location]);
11048
+ const updateSearchParam = useCallback((encodedValue) => {
11049
+ if (!enabled) {
11050
+ return;
11051
+ }
11052
+ if (encodedValue !== undefined && encodedValue === lastWrittenRef.current) {
11053
+ return;
11054
+ }
11055
+ lastWrittenRef.current = encodedValue;
11056
+ if (currentSearchValue === encodedValue) {
11057
+ return;
11058
+ }
11059
+ requestAnimationFrame(() => {
11060
+ const shouldReplace = replaceOption ?? !Boolean(currentSearchValue);
11061
+ void navigate({
11062
+ to: ".",
11063
+ search: (prev) => {
11064
+ if (getUrlLengthWithSearchParam(key, encodedValue || "", prev) <= MAX_URL_LENGTH) {
11065
+ return { ...prev, [key]: encodedValue };
11066
+ }
11067
+ else {
11068
+ return { ...prev, [key]: undefined };
11069
+ }
11070
+ },
11071
+ hash: location.hash,
11072
+ replace: shouldReplace,
11073
+ });
11074
+ });
11075
+ }, [enabled, navigate, key, replaceOption, location.hash, getUrlLengthWithSearchParam, currentSearchValue]);
11076
+ return useMemo(() => ({ searchValue: currentSearchValue, updateSearchParam }), [currentSearchValue, updateSearchParam]);
11077
+ };
11078
+
11079
+ /**
11080
+ * Generates a localStorage key, optionally scoped to a specific user.
11081
+ *
11082
+ * When `userId` is provided the key is `"key-userId"`.
11083
+ * When omitted the key is returned as-is (unscoped).
11084
+ *
11085
+ * @param key - Unique persistence identifier.
11086
+ * @param userId - Client-side user id to scope storage per user.
11087
+ * @returns {string} The combined storage key.
11088
+ */
11089
+ const useStorageKey = (key, userId) => {
11090
+ return useMemo(() => (userId ? `${key}-${userId}` : key), [key, userId]);
11091
+ };
11092
+
11093
+ /**
11094
+ * Generic persistence hook that loads state from URL search params (with
11095
+ * localStorage fallback) and writes changes to both.
11096
+ *
11097
+ * On mount the hook tries, in order:
11098
+ * 1. Decode the URL search param and pass it through `validate`.
11099
+ * 2. If that yields nothing, read localStorage and pass the parsed JSON through `validate`.
11100
+ *
11101
+ * `persistState` writes the state to localStorage (via `serialize`, default
11102
+ * `JSON.stringify`) and syncs to the URL (via `toUrlValue` + encoding).
11103
+ * Duplicate writes where the state has not changed (deep equality) are skipped.
11104
+ *
11105
+ * @param options - Configuration for the persisted state.
11106
+ * @param options.key - Unique identifier used for both the URL search param and the localStorage key.
11107
+ * @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
11108
+ * @param options.serialize - Custom localStorage serialiser (default `storageSerializer.serialize`).
11109
+ * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
11110
+ * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
11111
+ * @param options.enabled - When `false` the URL is neither read nor written (default `true`).
11112
+ * @param options.onExternalChange - Fired when the URL param changes externally (e.g. browser back).
11113
+ * @param options.replace - Forwarded to `useSearchParamSync`.
11114
+ * @param options.clientSideUserId - The user ID to use for the localStorage key.
11115
+ */
11116
+ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, onExternalChange, replace, clientSideUserId, }) => {
11117
+ const { encode, decode } = useCustomEncoding();
11118
+ const { searchValue, updateSearchParam } = useSearchParamSync({
11119
+ key,
11120
+ enabled,
11121
+ onExternalChange,
11122
+ replace,
11123
+ });
11124
+ const storageKey = useStorageKey(key, clientSideUserId);
11125
+ const validateRef = useRef(validate);
11126
+ useEffect(() => {
11127
+ validateRef.current = validate;
11128
+ }, [validate]);
11129
+ const toUrlValueRef = useRef(toUrlValue);
11130
+ useEffect(() => {
11131
+ toUrlValueRef.current = toUrlValue;
11132
+ }, [toUrlValue]);
11133
+ const fromUrlValueRef = useRef(fromUrlValue);
11134
+ useEffect(() => {
11135
+ fromUrlValueRef.current = fromUrlValue;
11136
+ }, [fromUrlValue]);
11137
+ const serializeRef = useRef(serialize);
11138
+ useEffect(() => {
11139
+ serializeRef.current = serialize;
11140
+ }, [serialize]);
11141
+ const [initialState] = useState(() => {
11142
+ if (enabled && searchValue) {
11143
+ try {
11144
+ const decoded = decode(searchValue);
11145
+ const transformed = fromUrlValue ? fromUrlValue(decoded) : decoded;
11146
+ const validated = validate(transformed);
11147
+ if (validated !== undefined) {
11148
+ return validated;
11149
+ }
11150
+ }
11151
+ catch {
11152
+ // fall through to localStorage
11153
+ }
11154
+ }
11155
+ try {
11156
+ const raw = localStorage.getItem(storageKey);
11157
+ if (raw) {
11158
+ const parsed = storageSerializer.deserialize(raw);
11159
+ return validate(parsed);
11160
+ }
11161
+ }
11162
+ catch {
11163
+ // no valid stored state
11164
+ }
11165
+ return undefined;
11166
+ });
11167
+ const lastPersistedRef = useRef(initialState);
11168
+ const persistState = useCallback((state) => {
11169
+ if (dequal(lastPersistedRef.current, state)) {
11170
+ return;
11171
+ }
11172
+ lastPersistedRef.current = state;
11173
+ const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
11174
+ localStorage.setItem(storageKey, serialized);
11175
+ const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
11176
+ updateSearchParam(encode(urlValue));
11177
+ }, [storageKey, encode, updateSearchParam]);
11178
+ return useMemo(() => ({ initialState, persistState }), [initialState, persistState]);
11179
+ };
11180
+
10980
11181
  const OVERSCAN = 10;
10981
11182
  const DEFAULT_ROW_HEIGHT = 50;
10982
11183
  /**
@@ -12121,4 +12322,4 @@ const useWindowActivity = ({ onFocus, onBlur, skip = false } = { onBlur: undefin
12121
12322
  return useMemo(() => ({ focused }), [focused]);
12122
12323
  };
12123
12324
 
12124
- export { ActionRenderer, Alert, Badge, Breadcrumb, BreadcrumbContainer, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyableText, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, List, ListItem, MenuDivider, MenuItem, MenuList, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMergeRefs, useModifierKey, useOverflowBorder, useOverflowItems, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
12325
+ export { ActionRenderer, Alert, Badge, Breadcrumb, BreadcrumbContainer, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyableText, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, List, ListItem, MAX_URL_LENGTH, MenuDivider, MenuItem, MenuList, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, storageSerializer, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMergeRefs, useModifierKey, useOverflowBorder, useOverflowItems, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "1.21.15",
3
+ "version": "1.21.17",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -19,9 +19,10 @@
19
19
  "@trackunit/ui-icons": "1.11.93",
20
20
  "es-toolkit": "^1.39.10",
21
21
  "@tanstack/react-virtual": "3.13.12",
22
+ "dequal": "^2.0.3",
22
23
  "fflate": "^0.8.2",
23
24
  "superjson": "^2.2.6",
24
- "zod": "^3.23.8"
25
+ "zod": "^3.25.76"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "react": "^19.0.0",
@@ -0,0 +1,40 @@
1
+ type UsePersistedStateOptions<TState> = {
2
+ readonly key: string;
3
+ readonly validate: (raw: unknown) => TState | undefined;
4
+ readonly serialize?: (state: TState) => string;
5
+ readonly toUrlValue?: (state: TState) => string | object | Array<object>;
6
+ readonly fromUrlValue?: (decoded: unknown) => unknown;
7
+ readonly enabled?: boolean;
8
+ readonly onExternalChange?: () => void;
9
+ readonly replace?: boolean;
10
+ readonly clientSideUserId?: string;
11
+ };
12
+ type UsePersistedStateReturn<TState> = {
13
+ readonly initialState: TState | undefined;
14
+ readonly persistState: (state: TState) => void;
15
+ };
16
+ /**
17
+ * Generic persistence hook that loads state from URL search params (with
18
+ * localStorage fallback) and writes changes to both.
19
+ *
20
+ * On mount the hook tries, in order:
21
+ * 1. Decode the URL search param and pass it through `validate`.
22
+ * 2. If that yields nothing, read localStorage and pass the parsed JSON through `validate`.
23
+ *
24
+ * `persistState` writes the state to localStorage (via `serialize`, default
25
+ * `JSON.stringify`) and syncs to the URL (via `toUrlValue` + encoding).
26
+ * Duplicate writes where the state has not changed (deep equality) are skipped.
27
+ *
28
+ * @param options - Configuration for the persisted state.
29
+ * @param options.key - Unique identifier used for both the URL search param and the localStorage key.
30
+ * @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
31
+ * @param options.serialize - Custom localStorage serialiser (default `storageSerializer.serialize`).
32
+ * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
33
+ * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
34
+ * @param options.enabled - When `false` the URL is neither read nor written (default `true`).
35
+ * @param options.onExternalChange - Fired when the URL param changes externally (e.g. browser back).
36
+ * @param options.replace - Forwarded to `useSearchParamSync`.
37
+ * @param options.clientSideUserId - The user ID to use for the localStorage key.
38
+ */
39
+ export declare const usePersistedState: <TState extends object>({ key, validate, serialize, toUrlValue, fromUrlValue, enabled, onExternalChange, replace, clientSideUserId, }: UsePersistedStateOptions<TState>) => UsePersistedStateReturn<TState>;
40
+ export {};
@@ -0,0 +1,27 @@
1
+ export declare const MAX_URL_LENGTH = 5000;
2
+ type UseSearchParamSyncOptions = {
3
+ readonly key: string;
4
+ readonly enabled?: boolean;
5
+ readonly onExternalChange?: () => void;
6
+ readonly replace?: boolean;
7
+ };
8
+ type UseSearchParamSyncReturn = {
9
+ readonly searchValue: string | undefined;
10
+ readonly updateSearchParam: (encodedValue: string | undefined) => void;
11
+ };
12
+ /**
13
+ * Syncs an encoded string value with a URL search parameter via Tanstack Router.
14
+ *
15
+ * Provides a write function that updates the URL through the application
16
+ * router (preserving other search params), guards against exceeding
17
+ * {@link MAX_URL_LENGTH}, and detects external URL changes (browser
18
+ * back/forward, shared links) via an optional callback.
19
+ *
20
+ * @param options - Configuration for the search param sync.
21
+ * @param options.key - The URL search parameter name.
22
+ * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
23
+ * @param options.onExternalChange - Called when the param changes externally.
24
+ * @param options.replace - `true` to always use `replaceState`.
25
+ */
26
+ export declare const useSearchParamSync: ({ key, enabled, onExternalChange, replace: replaceOption, }: UseSearchParamSyncOptions) => UseSearchParamSyncReturn;
27
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Generates a localStorage key, optionally scoped to a specific user.
3
+ *
4
+ * When `userId` is provided the key is `"key-userId"`.
5
+ * When omitted the key is returned as-is (unscoped).
6
+ *
7
+ * @param key - Unique persistence identifier.
8
+ * @param userId - Client-side user id to scope storage per user.
9
+ * @returns {string} The combined storage key.
10
+ */
11
+ export declare const useStorageKey: (key: string, userId?: string) => string;
package/src/index.d.ts CHANGED
@@ -110,12 +110,16 @@ export * from "./components/ValueBar/ValueBarTypes";
110
110
  export * from "./components/ZStack/ZStack";
111
111
  export * from "./components/ZStack/ZStack.variants";
112
112
  export * from "./hooks/encoding/useCustomEncoding";
113
+ export * from "./hooks/localStorage/storageSerializer";
113
114
  export * from "./hooks/localStorage/types";
114
115
  export * from "./hooks/localStorage/useLocalStorage";
115
116
  export * from "./hooks/localStorage/useLocalStorageReducer";
116
117
  export * from "./hooks/localStorage/useSessionStorage";
117
118
  export * from "./hooks/localStorage/useSessionStorageReducer";
118
119
  export * from "./hooks/noPagination";
120
+ export * from "./hooks/persistence/usePersistedState";
121
+ export * from "./hooks/persistence/useSearchParamSync";
122
+ export * from "./hooks/persistence/useStorageKey";
119
123
  export * from "./hooks/useBidirectionalScroll";
120
124
  export * from "./hooks/useClickOutside";
121
125
  export * from "./hooks/useContainerBreakpoints";