@trackunit/react-router-components 0.0.0-stage → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +40 -2
  2. package/index.cjs.js +1102 -0
  3. package/index.d.ts +1 -0
  4. package/index.esm.js +1094 -0
  5. package/migrations/entry.js +3 -0
  6. package/migrations/entry.js.map +1 -0
  7. package/migrations.json +3 -0
  8. package/package.json +23 -3
  9. package/src/Breadcrumb/Breadcrumb.d.ts +39 -0
  10. package/src/Breadcrumb/Breadcrumb.variant.d.ts +45 -0
  11. package/src/Breadcrumb/BreadcrumbContainer.d.ts +8 -0
  12. package/src/Breadcrumb/BreadcrumbForLargeScreen.d.ts +6 -0
  13. package/src/Breadcrumb/BreadcrumbForMediumScreen.d.ts +6 -0
  14. package/src/Breadcrumb/BreadcrumbForSmallScreen.d.ts +6 -0
  15. package/src/Breadcrumb/BreadcrumbItem.d.ts +6 -0
  16. package/src/Breadcrumb/utils/types.d.ts +26 -0
  17. package/src/Breadcrumb/utils/useBreadcrumbItemsToRender.d.ts +6 -0
  18. package/src/RouteLink/RouteLink.d.ts +17 -0
  19. package/src/index.d.ts +6 -0
  20. package/src/persistence/readPrimitiveSearchParam.d.ts +8 -0
  21. package/src/persistence/usePersistedState.d.ts +117 -0
  22. package/src/persistence/useSearchParamSync.d.ts +17 -0
  23. package/src/persistence/useUrlParamSync.d.ts +71 -0
  24. package/src/translation.d.ts +30 -0
  25. package/src/useCursorUrlSync/useCursorUrlSync.d.ts +23 -0
  26. package/src/useRouterDrawer/useRouterDrawer.d.ts +100 -0
  27. package/translation.cjs.js +8 -0
  28. package/translation.esm.js +6 -0
  29. package/translation10.cjs.js +8 -0
  30. package/translation10.esm.js +6 -0
  31. package/translation11.cjs.js +8 -0
  32. package/translation11.esm.js +6 -0
  33. package/translation12.cjs.js +8 -0
  34. package/translation12.esm.js +6 -0
  35. package/translation13.cjs.js +8 -0
  36. package/translation13.esm.js +6 -0
  37. package/translation14.cjs.js +8 -0
  38. package/translation14.esm.js +6 -0
  39. package/translation15.cjs.js +8 -0
  40. package/translation15.esm.js +6 -0
  41. package/translation16.cjs.js +8 -0
  42. package/translation16.esm.js +6 -0
  43. package/translation17.cjs.js +8 -0
  44. package/translation17.esm.js +6 -0
  45. package/translation2.cjs.js +8 -0
  46. package/translation2.esm.js +6 -0
  47. package/translation3.cjs.js +8 -0
  48. package/translation3.esm.js +6 -0
  49. package/translation4.cjs.js +8 -0
  50. package/translation4.esm.js +6 -0
  51. package/translation5.cjs.js +8 -0
  52. package/translation5.esm.js +6 -0
  53. package/translation6.cjs.js +8 -0
  54. package/translation6.esm.js +6 -0
  55. package/translation7.cjs.js +8 -0
  56. package/translation7.esm.js +6 -0
  57. package/translation8.cjs.js +8 -0
  58. package/translation8.esm.js +6 -0
  59. package/translation9.cjs.js +8 -0
  60. package/translation9.esm.js +6 -0
package/index.cjs.js ADDED
@@ -0,0 +1,1102 @@
1
+ 'use strict';
2
+
3
+ var jsxRuntime = require('react/jsx-runtime');
4
+ var i18nLibraryTranslation = require('@trackunit/i18n-library-translation');
5
+ var reactComponents = require('@trackunit/react-components');
6
+ var cssClassVarianceUtilities = require('@trackunit/css-class-variance-utilities');
7
+ var sharedUtils = require('@trackunit/shared-utils');
8
+ var react = require('react');
9
+ var reactRouter = require('@tanstack/react-router');
10
+ var dequal = require('dequal');
11
+ var zod = require('zod');
12
+ var reactDrawer = require('@trackunit/react-drawer');
13
+
14
+ var defaultTranslations = {
15
+ "breadcrumb.backButton": "Back",
16
+ "breadcrumb.expandButton": "expand"
17
+ };
18
+
19
+ /** The translation namespace for this library */
20
+ const namespace = "react.react-router-components";
21
+ /**
22
+ * The TranslationResource for this Library.
23
+ * Holds lazy loaded imports for all languages supported by the library.
24
+ *
25
+ * This is used to register the translations for this library before initializing i18next.
26
+ */
27
+ const translations = {
28
+ ns: namespace,
29
+ default: defaultTranslations,
30
+ languages: {
31
+ de: () => Promise.resolve().then(function () { return require('./translation.cjs.js'); }),
32
+ da: () => Promise.resolve().then(function () { return require('./translation2.cjs.js'); }),
33
+ cs: () => Promise.resolve().then(function () { return require('./translation3.cjs.js'); }),
34
+ nl: () => Promise.resolve().then(function () { return require('./translation4.cjs.js'); }),
35
+ fr: () => Promise.resolve().then(function () { return require('./translation5.cjs.js'); }),
36
+ fi: () => Promise.resolve().then(function () { return require('./translation6.cjs.js'); }),
37
+ hu: () => Promise.resolve().then(function () { return require('./translation7.cjs.js'); }),
38
+ it: () => Promise.resolve().then(function () { return require('./translation8.cjs.js'); }),
39
+ nb: () => Promise.resolve().then(function () { return require('./translation9.cjs.js'); }),
40
+ pl: () => Promise.resolve().then(function () { return require('./translation10.cjs.js'); }),
41
+ pt: () => Promise.resolve().then(function () { return require('./translation11.cjs.js'); }),
42
+ ru: () => Promise.resolve().then(function () { return require('./translation12.cjs.js'); }),
43
+ ro: () => Promise.resolve().then(function () { return require('./translation13.cjs.js'); }),
44
+ es: () => Promise.resolve().then(function () { return require('./translation14.cjs.js'); }),
45
+ sv: () => Promise.resolve().then(function () { return require('./translation15.cjs.js'); }),
46
+ ja: () => Promise.resolve().then(function () { return require('./translation16.cjs.js'); }),
47
+ th: () => Promise.resolve().then(function () { return require('./translation17.cjs.js'); }),
48
+ },
49
+ };
50
+ /**
51
+ * Local useTranslation for this specific library
52
+ */
53
+ const useTranslation = () => i18nLibraryTranslation.useNamespaceTranslation(namespace);
54
+ /**
55
+ * Registers the translations for this library
56
+ */
57
+ const setupLibraryTranslations = () => {
58
+ i18nLibraryTranslation.registerTranslations(translations);
59
+ };
60
+
61
+ const cvaBreadcrumb = cssClassVarianceUtilities.cva({ base: ["my-4", "flex", "place-items-center"] });
62
+ const cvaBreadcrumbItem = cssClassVarianceUtilities.cva({ base: ["flex", "items-center"] });
63
+ const cvaBreadcrumbForLargeScreen = cssClassVarianceUtilities.cva({ base: ["flex", "flex-nowrap", "items-center"] });
64
+ const cvaBreadcrumbForMediumScreen = cssClassVarianceUtilities.cva({
65
+ base: ["flex", "items-center"],
66
+ variants: {
67
+ expanded: {
68
+ true: "flex-wrap",
69
+ false: "flex-nowrap",
70
+ },
71
+ },
72
+ defaultVariants: { expanded: false },
73
+ });
74
+
75
+ /**
76
+ * BreadcrumbItem is a helper component that renders the individual breadcrumb item.
77
+ */
78
+ const BreadcrumbItem = ({ item, "data-testid": dataTestId, className, style, }) => {
79
+ const id = sharedUtils.uuidv4();
80
+ return (jsxRuntime.jsx("div", { className: cvaBreadcrumbItem({ className }), "data-testid": dataTestId, id: `${item.props.label}-${id}`, style: style, children: item }));
81
+ };
82
+
83
+ /**
84
+ * BreadcrumbForLargeScreen is a helper component that renders the breadcrumb items for large screens.
85
+ */
86
+ const BreadcrumbForLargeScreen = ({ "data-testid": dataTestId, breadcrumbItems, className, style, }) => {
87
+ return (jsxRuntime.jsx("div", { className: cvaBreadcrumbForLargeScreen({ className }), "data-testid": dataTestId, style: style, children: breadcrumbItems.map((item, index) => (jsxRuntime.jsx(BreadcrumbItem, { "data-testid": `BreadcrumbItem-${dataTestId}`, item: item }, `breadcrumbItem-${index}`))) }));
88
+ };
89
+
90
+ /**
91
+ * BreadcrumbForMediumScreen is a helper component that renders the breadcrumb items for medium screens.
92
+ */
93
+ const BreadcrumbForMediumScreen = ({ "data-testid": dataTestId, breadcrumbItems, className, style, }) => {
94
+ const [t] = useTranslation();
95
+ const [expanded, setExpanded] = react.useState(false);
96
+ const getReducedArray = react.useCallback(() => {
97
+ let reducedArrayElements = [];
98
+ const collapsibleButton = (jsxRuntime.jsxs("div", { className: cvaBreadcrumbItem(), children: [jsxRuntime.jsx(reactComponents.IconButton, { "data-testid": `collapsibleButton-${dataTestId}`, icon: jsxRuntime.jsx(reactComponents.Icon, { name: "EllipsisHorizontal" }), onClick: () => setExpanded(true), size: "small", title: t("breadcrumb.expandButton"), variant: "ghost-neutral" }), jsxRuntime.jsx(reactComponents.Icon, { className: "text-neutral-300", name: "Slash", size: "small" })] }));
99
+ const firstBreadcrumbItem = breadcrumbItems[0];
100
+ if (firstBreadcrumbItem && breadcrumbItems.length > 3) {
101
+ const lastTwoBreadcrumbItem = breadcrumbItems.slice(-2);
102
+ reducedArrayElements = [firstBreadcrumbItem, collapsibleButton, ...lastTwoBreadcrumbItem];
103
+ }
104
+ return reducedArrayElements;
105
+ }, [breadcrumbItems, dataTestId, t]);
106
+ const reducedArray = getReducedArray();
107
+ return (jsxRuntime.jsx("div", { className: cvaBreadcrumbForMediumScreen({ className, expanded }), "data-testid": dataTestId, style: style, children: (!expanded && reducedArray.length !== 0 ? reducedArray : breadcrumbItems).map(item => (jsxRuntime.jsx(BreadcrumbItem, { "data-testid": `breadcrumbItem-${dataTestId}`, item: item }, `${item.props.label}-${sharedUtils.uuidv4()}`))) }));
108
+ };
109
+
110
+ /**
111
+ * BreadcrumbForSmallScreen is a helper component that renders the breadcrumb items for small screens.
112
+ */
113
+ const BreadcrumbForSmallScreen = ({ "data-testid": dataTestId, breadcrumbItems, className, style, }) => {
114
+ const lastBreadcrumbItem = breadcrumbItems.slice(-1);
115
+ return (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId, style: style, children: lastBreadcrumbItem.map((item, index) => (jsxRuntime.jsx(BreadcrumbItem, { "data-testid": `breadcrumbItem-${dataTestId}`, item: item }, `breadcrumbItem-${index}`))) }));
116
+ };
117
+
118
+ /**
119
+ * BreadcrumbContainer is a helper component that renders the breadcrumb items based on the screen size.
120
+ *
121
+ * @param {BreadcrumbContainerProps} props - The props for the BreadcrumbContainer component
122
+ */
123
+ const BreadcrumbContainer = ({ className, "data-testid": dataTestId, breadcrumbItems, style, ref, }) => {
124
+ const { isLg: isLargeScreen, isMd: isMediumScreen, isXs: isSmallScreen } = reactComponents.useViewportBreakpoints();
125
+ const sharedContainerProps = { className, "data-testid": dataTestId, ref, style };
126
+ if (isLargeScreen) {
127
+ return (jsxRuntime.jsx("div", { ...sharedContainerProps, children: jsxRuntime.jsx(BreadcrumbForLargeScreen, { breadcrumbItems: breadcrumbItems, "data-testid": `largeScreen-${dataTestId}` }) }));
128
+ }
129
+ if (isMediumScreen) {
130
+ return (jsxRuntime.jsx("div", { ...sharedContainerProps, children: jsxRuntime.jsx(BreadcrumbForMediumScreen, { breadcrumbItems: breadcrumbItems, "data-testid": `mediumScreen-${dataTestId}` }) }));
131
+ }
132
+ if (isSmallScreen) {
133
+ return (jsxRuntime.jsx("div", { ...sharedContainerProps, children: jsxRuntime.jsx(BreadcrumbForSmallScreen, { breadcrumbItems: breadcrumbItems, "data-testid": `smallScreen-${dataTestId}` }) }));
134
+ }
135
+ return (jsxRuntime.jsx("div", { ...sharedContainerProps, children: jsxRuntime.jsx(BreadcrumbForLargeScreen, { breadcrumbItems: breadcrumbItems, "data-testid": `largeScreen-${dataTestId}` }) }));
136
+ };
137
+
138
+ /**
139
+ * useBreadcrumbItemsToRender is a custom hook that takes an array of BreadcrumbItemProps and returns an array of React.ReactElement
140
+ */
141
+ const useBreadcrumbItemsToRender = (breadcrumbItems) => {
142
+ return react.useMemo(() => breadcrumbItems.map((item, index, array) => {
143
+ const isLast = index === array.length - 1;
144
+ if (!isLast) {
145
+ return (jsxRuntime.jsxs("div", { className: "flex items-center", children: [jsxRuntime.jsx(reactComponents.Button, { asChild: true, size: "small", variant: "ghost-neutral", children: jsxRuntime.jsx(reactRouter.Link, { to: item.to, children: item.label }) }), jsxRuntime.jsx(reactComponents.Icon, { className: "text-neutral-300", name: "Slash", size: "small" })] }, index));
146
+ }
147
+ return (jsxRuntime.jsx(reactComponents.Text, { className: "text-nowrap pl-2 font-medium", size: "small", children: item.label }, index));
148
+ }), [breadcrumbItems]);
149
+ };
150
+
151
+ /**
152
+ * The breadcrumb component shows a user's location in a website or application. Breadcrumbs are particularly useful when a large amount of content is organized in a hierarchical manner. They streamline navigation, minimize the steps required to revisit previous pages, and offer contextual insights to the users.
153
+ *
154
+ * All items except the last are rendered as clickable links. The last item is rendered as plain text representing the current page.
155
+ * The component automatically adapts to the screen size: on large screens all items are visible, on medium screens middle items collapse behind an ellipsis button, and on small screens only the current page is shown.
156
+ *
157
+ * ### When to use
158
+ * Use breadcrumbs when deep navigation is available, on apps or pages where users might dive into content and could benefit from a quick way to return to higher levels. Breadcrumbs display the current screen as the final item in the list to make it clear where the user is.
159
+ *
160
+ * ### When not to use
161
+ * Do not use on the first hierarchal level, such as a home page or a main page, as there is no previous step the user can get to.
162
+ * Avoid using breadcrumbs to indicate forthcoming steps. For processes with sequential steps, use a wizard flow instead.
163
+ *
164
+ * @example Basic breadcrumb navigation
165
+ * ```tsx
166
+ * import { Breadcrumb } from "@trackunit/react-router-components";
167
+ * import { useNavigate } from "@tanstack/react-router";
168
+ *
169
+ * const AssetDetailBreadcrumb = () => {
170
+ * const navigate = useNavigate();
171
+ *
172
+ * return (
173
+ * <Breadcrumb
174
+ * breadcrumbItems={[
175
+ * { label: "Fleet", to: "/fleet" },
176
+ * { label: "Assets", to: "/fleet/assets" },
177
+ * { label: "Excavator 001", to: "/fleet/assets/123" },
178
+ * ]}
179
+ * onClickBack={() => navigate({ to: "/fleet/assets" })}
180
+ * />
181
+ * );
182
+ * };
183
+ * ```
184
+ * @param {BreadcrumbProps} props - The props for the Breadcrumb component
185
+ * @returns {ReactElement} Breadcrumb component
186
+ */
187
+ const Breadcrumb = ({ className, "data-testid": dataTestId, breadcrumbItems, onClickBack, style, ref, }) => {
188
+ const [t] = useTranslation();
189
+ const breadCrumbItemsToJSX = useBreadcrumbItemsToRender(breadcrumbItems);
190
+ return (jsxRuntime.jsxs("div", { className: cvaBreadcrumb({ className }), "data-testid": dataTestId, ref: ref, style: style, children: [jsxRuntime.jsx(reactComponents.IconButton, { "data-testid": `backButton-${dataTestId}`, icon: jsxRuntime.jsx(reactComponents.Icon, { name: "ArrowLeft", size: "small" }), onClick: onClickBack, size: "small", title: t("breadcrumb.backButton"), variant: "ghost-neutral" }), jsxRuntime.jsx("div", { children: jsxRuntime.jsx(BreadcrumbContainer, { breadcrumbItems: breadCrumbItemsToJSX, "data-testid": dataTestId }) })] }));
191
+ };
192
+
193
+ const searchRecordSchema = zod.z.record(zod.z.string(), zod.z.unknown());
194
+ const primitiveSearchValueSchema = zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean()]);
195
+ /**
196
+ * Reads one search param from a TanStack `useSearch({ strict: false })` result.
197
+ *
198
+ * The registered route tree often has an empty or closed search schema, so a
199
+ * string key cannot index that type. Parsing to a string record keeps the
200
+ * lookup valid for every app router.
201
+ */
202
+ const readPrimitiveSearchParam = (search, key) => {
203
+ const parsedSearch = searchRecordSchema.safeParse(search);
204
+ if (!parsedSearch.success) {
205
+ return undefined;
206
+ }
207
+ const parsedValue = primitiveSearchValueSchema.safeParse(parsedSearch.data[key]);
208
+ if (!parsedValue.success) {
209
+ return undefined;
210
+ }
211
+ return String(parsedValue.data);
212
+ };
213
+
214
+ /**
215
+ * Maximum full URL length after adding one persisted search param before the
216
+ * URL write is skipped (localStorage still persists the full state).
217
+ *
218
+ * Sized to stay safely below the AWS WAF managed-rule default
219
+ * `SizeRestrictions_QUERYSTRING` cap of 5000 bytes for the entire
220
+ * host + path + query + hash string.
221
+ */
222
+ const MAX_URL_LENGTH = 5000;
223
+ /**
224
+ * Maximum length of the URL fragment after adding one persisted entry.
225
+ * Oversized new entries are skipped; oversized replacements remove the stale
226
+ * entry so localStorage remains authoritative.
227
+ *
228
+ * The fragment is never sent to the server, so the WAF cap above does not
229
+ * apply. 64 KB stays well below typical browser URL limits (Chromium ≳ 100 KB,
230
+ * Firefox ≳ 64 KB on the address bar) while leaving generous headroom for any
231
+ * realistic table state.
232
+ */
233
+ const MAX_HASH_LENGTH = 64 * 1024;
234
+ /**
235
+ * Syncs an encoded string value with a single named entry in the URL — either
236
+ * a search param or an entry inside the hash fragment — via Tanstack Router.
237
+ *
238
+ * Writes go through the application router so other search params and
239
+ * foreign fragment content are preserved. Each slot has a size guard
240
+ * ({@link MAX_URL_LENGTH} for search, {@link MAX_HASH_LENGTH} for hash); an
241
+ * oversized value is dropped from the URL and localStorage stays
242
+ * authoritative. External changes to the entry (browser back/forward, a
243
+ * pasted link, a foreign router push) fire `onExternalChange`.
244
+ *
245
+ * Writes are dispatched via a pending-write queue rather than calling
246
+ * `navigate` directly. Tanstack Router's `router.state.status === "pending"`
247
+ * is common during the first paint of a route (loaders, suspense), and an
248
+ * implementation that simply returned in that case lost the write outright.
249
+ * `updateSearchParam` stores the latest attempted write and schedules an rAF
250
+ * flush; the flush only navigates when the router is idle, and an effect
251
+ * retries whenever the router settles. A queued write is dropped on unmount so
252
+ * it cannot drain onto the next route.
253
+ *
254
+ * @param options - Configuration for the URL entry sync.
255
+ * @param options.key - The search param name or fragment entry name.
256
+ * @param options.location - `"search"` or `"hash"`.
257
+ * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
258
+ * @param options.onExternalChange - Called when the entry changes externally.
259
+ * @param options.replace - `true` to always use `replaceState`; `false` to always push.
260
+ * Default: replace when the entry is being added, push when it already has a value.
261
+ */
262
+ const useUrlParamSync = ({ key, location: urlLocation, enabled = true, onExternalChange, replace: replaceOption, }) => {
263
+ const navigate = reactRouter.useNavigate();
264
+ const location = reactRouter.useLocation();
265
+ const router = reactRouter.useRouter();
266
+ const search = reactRouter.useSearch({ strict: false, shouldThrow: false });
267
+ const isHash = urlLocation === "hash";
268
+ const lastWrittenRef = react.useRef(undefined);
269
+ const pendingWriteRef = react.useRef(null);
270
+ const onExternalChangeRef = react.useRef(onExternalChange);
271
+ react.useEffect(() => {
272
+ onExternalChangeRef.current = onExternalChange;
273
+ }, [onExternalChange]);
274
+ const currentValue = react.useMemo(() => {
275
+ if (!enabled) {
276
+ return undefined;
277
+ }
278
+ return isHash ? sharedUtils.readHashKey(location.hash, key) : readPrimitiveSearchParam(search, key);
279
+ }, [enabled, isHash, location.hash, search, key]);
280
+ react.useEffect(() => {
281
+ if (!enabled || currentValue === undefined) {
282
+ return;
283
+ }
284
+ if (currentValue === lastWrittenRef.current || lastWrittenRef.current === undefined) {
285
+ return;
286
+ }
287
+ // A caller may have primed `lastWrittenRef` via `updateSearchParam` while
288
+ // the URL still carries the previous value (queued rAF / router pending).
289
+ // Treating that stale URL as an external change would reload state from
290
+ // persistence before the write lands.
291
+ if (pendingWriteRef.current !== null) {
292
+ return;
293
+ }
294
+ requestAnimationFrame(() => {
295
+ onExternalChangeRef.current?.();
296
+ });
297
+ }, [currentValue, enabled]);
298
+ // Read through refs so the write path does not change identity on every
299
+ // navigation; it only needs the location and current value at the moment it
300
+ // flushes, which may be an animation frame after it was queued.
301
+ const locationRef = react.useRef(location);
302
+ const currentValueRef = react.useRef(currentValue);
303
+ react.useEffect(() => {
304
+ locationRef.current = location;
305
+ currentValueRef.current = currentValue;
306
+ }, [location, currentValue]);
307
+ const getUrlLengthWithSearchParam = react.useCallback((paramValue, params) => {
308
+ const { href, searchStr, hash } = locationRef.current;
309
+ const otherParamsLength = sharedUtils.objectKeys(params)
310
+ .filter(k => k !== key)
311
+ .reduce((totalLength, k) => {
312
+ const kLen = encodeURIComponent(String(k)).length;
313
+ const v = params[k];
314
+ const vLen = v !== null && v !== undefined ? encodeURIComponent(String(v)).length : 0;
315
+ return totalLength + kLen + vLen + (totalLength > 0 ? 2 : 1);
316
+ }, 0);
317
+ const urlBase = href.length - (searchStr.length || 0) + (hash.length || 0);
318
+ return urlBase + otherParamsLength + 1 + key.length + 1 + paramValue.length;
319
+ }, [key]);
320
+ const flushPendingWrite = react.useCallback(() => {
321
+ const pending = pendingWriteRef.current;
322
+ if (pending === null) {
323
+ return;
324
+ }
325
+ if (router.state.status === "pending") {
326
+ // Keep the write queued; the status-watcher effect retries once idle.
327
+ return;
328
+ }
329
+ pendingWriteRef.current = null;
330
+ const { encodedValue } = pending;
331
+ const { hash } = locationRef.current;
332
+ const valueInUrl = currentValueRef.current;
333
+ const shouldReplace = replaceOption ?? !Boolean(valueInUrl);
334
+ if (!isHash) {
335
+ void navigate({
336
+ to: ".",
337
+ search: (prev) => getUrlLengthWithSearchParam(encodedValue ?? "", prev) <= MAX_URL_LENGTH
338
+ ? { ...prev, [key]: encodedValue }
339
+ : { ...prev, [key]: undefined },
340
+ hash,
341
+ replace: shouldReplace,
342
+ });
343
+ return;
344
+ }
345
+ let nextHash = sharedUtils.writeHashKey(hash, key, encodedValue);
346
+ let removedOversizedValue = false;
347
+ if (nextHash.length > MAX_HASH_LENGTH) {
348
+ if (valueInUrl === undefined) {
349
+ return;
350
+ }
351
+ nextHash = sharedUtils.writeHashKey(hash, key, undefined);
352
+ removedOversizedValue = true;
353
+ }
354
+ void navigate({
355
+ to: ".",
356
+ // Returning the same `prev` reference leaves the search params
357
+ // unchanged; `search` must still be passed for the router types.
358
+ search: (prev) => prev,
359
+ // The router adds the `#` itself; forwarding it would double it.
360
+ hash: nextHash.startsWith("#") ? nextHash.slice(1) : nextHash,
361
+ replace: removedOversizedValue ? true : shouldReplace,
362
+ });
363
+ }, [router.state.status, replaceOption, isHash, navigate, getUrlLengthWithSearchParam, key]);
364
+ react.useEffect(() => {
365
+ if (router.state.status === "pending" || pendingWriteRef.current === null) {
366
+ return;
367
+ }
368
+ flushPendingWrite();
369
+ }, [router.state.status, flushPendingWrite]);
370
+ react.useEffect(() => () => {
371
+ pendingWriteRef.current = null;
372
+ }, []);
373
+ const updateSearchParam = react.useCallback((encodedValue) => {
374
+ if (!enabled) {
375
+ return;
376
+ }
377
+ // Dedupe against the last committed write or the value already queued;
378
+ // both represent the caller's intent.
379
+ const inFlightOrLastWritten = pendingWriteRef.current?.encodedValue ?? lastWrittenRef.current;
380
+ if (encodedValue !== undefined && encodedValue === inFlightOrLastWritten) {
381
+ return;
382
+ }
383
+ // Prime here (not in the flush) so the external-change detector treats
384
+ // this value as the caller's state even when the flush is a no-op or
385
+ // stays queued during a pending router phase.
386
+ lastWrittenRef.current = encodedValue;
387
+ if (currentValueRef.current === encodedValue) {
388
+ return;
389
+ }
390
+ pendingWriteRef.current = { encodedValue };
391
+ requestAnimationFrame(() => {
392
+ flushPendingWrite();
393
+ });
394
+ }, [enabled, flushPendingWrite]);
395
+ return react.useMemo(() => ({ searchValue: currentValue, updateSearchParam }), [currentValue, updateSearchParam]);
396
+ };
397
+
398
+ /**
399
+ * Raw localStorage access, kept in one place so this file's exemption from the
400
+ * no-direct-storage-access rule is centralised rather than repeated at each call site.
401
+ *
402
+ * TODO: migrate to the useLocalStorage hook — this file was exempted when the
403
+ * no-direct-storage-access rule was introduced.
404
+ */
405
+ /* eslint-disable no-restricted-globals */
406
+ const rawLocalStorage = {
407
+ read: (key) => localStorage.getItem(key),
408
+ write: (key, value) => localStorage.setItem(key, value),
409
+ remove: (key) => localStorage.removeItem(key),
410
+ };
411
+ /**
412
+ * Generic persistence hook that loads state from a URL slot (search param
413
+ * or hash fragment, depending on `urlLocation`) with localStorage fallback,
414
+ * and writes changes to both.
415
+ *
416
+ * On mount the hook resolves the initial state as follows:
417
+ * - If the URL slot holds a value, decode it and pass through `validate`.
418
+ * - If localStorage is enabled, read and validate its value.
419
+ * - When `mergeWithStorageOnRead` is `false` (default): URL state wins
420
+ * entirely when present, otherwise fall back to localStorage.
421
+ * - When `mergeWithStorageOnRead` is `true`: shallow-merge URL fields over
422
+ * localStorage so fields stripped by `toUrlValue` survive via storage.
423
+ *
424
+ * A value that comes only from localStorage is never written to the URL on
425
+ * load. Copying it left a layout in history, bookmarks, and login redirects,
426
+ * which then replayed an older layout over the saved one (GLU-2396). The URL
427
+ * is written when the consumer persists a change.
428
+ *
429
+ * `persistState` writes to localStorage (`storageSerializer`) and to the URL
430
+ * (via `toUrlValue` + encoding).
431
+ * Each slot skips a value equal to its last write. The URL side compares what
432
+ * would land in the URL (the `toUrlValue` projection and the identity stamp),
433
+ * starting from the projection of the hydrated state and the stamp the URL
434
+ * carries: echoing `initialState`, or changing only a field the projection
435
+ * strips, writes no URL entry, while an equal state persisted after the
436
+ * identity resolves rewrites the URL with its stamp. The storage side starts
437
+ * from what localStorage held, so a consumer that persists `initialState`
438
+ * after opening a link saves that link's state for the account that opened
439
+ * it, and a reload of the account's own link writes nothing.
440
+ * `persistToStorage` and `persistToUrl` are the same two halves exposed
441
+ * separately for consumers that schedule them differently; passing `undefined`
442
+ * removes the entry rather than writing an empty value.
443
+ *
444
+ * The snapshot is re-read when the storage key or the storage/merge options
445
+ * change, in the same render, so a write in that commit already sees the
446
+ * state for the new key.
447
+ *
448
+ * @param options - Configuration for the persisted state.
449
+ * @param options.key - Unique identifier used for both the URL slot and the localStorage key.
450
+ * @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
451
+ * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
452
+ * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
453
+ * @param options.localStorageEnabled - When `false` localStorage is neither read nor written (default `true`).
454
+ * @param options.replace - Forwarded to the underlying URL sync hook.
455
+ * @param options.clientSideUserId - The user ID to use for the localStorage key.
456
+ * @param options.mergeWithStorageOnRead - Merge URL state on top of localStorage
457
+ * state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
458
+ * omits fields so they still survive via localStorage. Default `false`.
459
+ * @param options.urlLocation - Which URL slot backs the value (`"search"` or `"hash"`). Default `"search"`.
460
+ * @param options.stampUrlWithIdentity - Stamp URL writes with the current identity so the login
461
+ * redirect can drop entries left by another account on this browser. Default `false`.
462
+ */
463
+ const usePersistedState = ({ key, validate, toUrlValue, fromUrlValue, localStorageEnabled = true, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", stampUrlWithIdentity = false, }) => {
464
+ const { encode, decode } = reactComponents.useCustomEncoding();
465
+ // Fingerprint of the identity this hook writes URL values for. `undefined`
466
+ // while the identity is unresolved (or when stamping is off): writes are
467
+ // then unstamped.
468
+ const currentUrlOwner = react.useMemo(() => (stampUrlWithIdentity && clientSideUserId !== undefined ? sharedUtils.identityFingerprint(clientSideUserId) : undefined), [stampUrlWithIdentity, clientSideUserId]);
469
+ const { searchValue: urlValue, updateSearchParam: updateUrlValue } = useUrlParamSync({
470
+ key,
471
+ location: urlLocation,
472
+ replace,
473
+ });
474
+ const storageKey = reactComponents.useStorageKey(key, clientSideUserId);
475
+ // `toUrlValue` is read through a ref so the write callbacks stay stable
476
+ // when a consumer passes an inline function.
477
+ const toUrlValueRef = react.useRef(toUrlValue);
478
+ react.useEffect(() => {
479
+ toUrlValueRef.current = toUrlValue;
480
+ }, [toUrlValue]);
481
+ const readStoredState = react.useCallback((validateState) => {
482
+ if (!localStorageEnabled) {
483
+ return undefined;
484
+ }
485
+ try {
486
+ const raw = rawLocalStorage.read(storageKey);
487
+ if (!raw) {
488
+ return undefined;
489
+ }
490
+ return validateState(sharedUtils.storageSerializer.deserialize(raw));
491
+ }
492
+ catch {
493
+ return undefined;
494
+ }
495
+ }, [localStorageEnabled, storageKey]);
496
+ const readPersistedState = react.useCallback(({ valueFromUrl, validateState, transformFromUrlValue, }) => {
497
+ let urlState;
498
+ let urlStamp;
499
+ if (valueFromUrl) {
500
+ try {
501
+ // The ownership stamp decides nothing on read (the login redirect
502
+ // does that, see `useStripForeignIdentityHash`); it is kept only to
503
+ // seed the URL dedupe with what the URL is currently stamped with.
504
+ const { value, owner } = sharedUtils.unwrapUrlOwner(decode(valueFromUrl));
505
+ urlState = validateState(transformFromUrlValue ? transformFromUrlValue(value) : value);
506
+ urlStamp = urlState === undefined ? undefined : { owner };
507
+ }
508
+ catch {
509
+ urlState = undefined;
510
+ }
511
+ }
512
+ // Storage is read even when the URL wins outright: it seeds the storage
513
+ // dedupe, so persisting the hydrated state writes only when the URL
514
+ // changed something.
515
+ const stored = readStoredState(validateState);
516
+ let hydrated;
517
+ if (urlState === undefined) {
518
+ hydrated = stored;
519
+ }
520
+ else if (mergeWithStorageOnRead && stored !== undefined) {
521
+ // URL fields take precedence; storage fills in whatever the URL
522
+ // omits (e.g. fields stripped by `toUrlValue`).
523
+ hydrated = { ...stored, ...urlState };
524
+ }
525
+ else {
526
+ hydrated = urlState;
527
+ }
528
+ return { initialState: hydrated, storedState: stored, urlValue: urlStamp };
529
+ }, [decode, mergeWithStorageOnRead, readStoredState]);
530
+ // The shareable projection of `state`: what a URL write carries before
531
+ // encoding. The URL dedupe compares this, not the whole state, so a change
532
+ // confined to a field `toUrlValue` strips is not a URL change.
533
+ const projectForUrl = react.useCallback((state) => (toUrlValueRef.current ? toUrlValueRef.current(state) : state), []);
534
+ // Every URL write goes through here so the ownership stamp is applied uniformly.
535
+ const encodeUrlWrite = react.useCallback(({ projected, owner }) => encode(owner === undefined ? projected : sharedUtils.wrapUrlOwner(projected, owner)), [encode]);
536
+ // `readPersistedState` changes identity exactly when an input that changes
537
+ // what a read returns does (storage key, storage/merge options), so the
538
+ // snapshot is re-read then and only then — synchronously, in the render
539
+ // where the input changed, by deriving state from the changed input. A URL
540
+ // change after mount or an inline `validate` deliberately does not
541
+ // re-hydrate `initialState`: `useUrlParamSync` owns URL changes.
542
+ const readSnapshot = () => ({
543
+ readWith: readPersistedState,
544
+ ...readPersistedState({ valueFromUrl: urlValue, validateState: validate, transformFromUrlValue: fromUrlValue }),
545
+ });
546
+ const [snapshot, setSnapshot] = react.useState(readSnapshot);
547
+ if (snapshot.readWith !== readPersistedState) {
548
+ setSnapshot(readSnapshot());
549
+ }
550
+ const { initialState, storedState } = snapshot;
551
+ // Last value written to each slot, for deduping. Two refs, not one, because
552
+ // a consumer may schedule the two writes differently (storage at once, URL
553
+ // debounced). Both are re-seeded whenever the snapshot is re-read so the
554
+ // first write to a newly scoped key is never skipped.
555
+ //
556
+ // The storage side starts as what storage held, so persisting the hydrated
557
+ // state after opening a link writes only when the URL changed something.
558
+ //
559
+ // The URL side holds what a write would put in the URL: the shareable
560
+ // projection plus the owner stamp. It starts as the projection of the
561
+ // hydrated value, so a consumer that echoes `initialState` on mount, or
562
+ // changes only a field the projection strips, writes nothing: a layout read
563
+ // from storage must not grow a URL entry (GLU-2396). Its owner starts as the
564
+ // stamp the URL carries (or the current owner when the URL is empty), so an
565
+ // equal state persisted after the identity resolves still rewrites the URL
566
+ // with its stamp.
567
+ const urlOwnerSeed = snapshot.urlValue ? snapshot.urlValue.owner : currentUrlOwner;
568
+ const seedUrlWrite = react.useCallback((project) => initialState === undefined ? undefined : { projected: project(initialState), owner: urlOwnerSeed }, [initialState, urlOwnerSeed]);
569
+ const lastStoredRef = react.useRef(storedState);
570
+ // The first seed is computed during render, so it projects with the
571
+ // `toUrlValue` prop rather than reading `toUrlValueRef` (refs are not read
572
+ // in render); the projection is cheap, and later seeds come from the effect.
573
+ const lastUrlRef = react.useRef(seedUrlWrite(state => (toUrlValue ? toUrlValue(state) : state)));
574
+ react.useEffect(() => {
575
+ lastStoredRef.current = storedState;
576
+ lastUrlRef.current = seedUrlWrite(projectForUrl);
577
+ }, [seedUrlWrite, projectForUrl, storedState]);
578
+ const persistToStorage = react.useCallback((state) => {
579
+ if (!localStorageEnabled) {
580
+ return;
581
+ }
582
+ if (state === undefined) {
583
+ lastStoredRef.current = undefined;
584
+ rawLocalStorage.remove(storageKey);
585
+ return;
586
+ }
587
+ if (dequal.dequal(lastStoredRef.current, state)) {
588
+ return;
589
+ }
590
+ try {
591
+ rawLocalStorage.write(storageKey, sharedUtils.storageSerializer.serialize(state));
592
+ lastStoredRef.current = state;
593
+ }
594
+ catch {
595
+ // Quota or a disabled store: the next write retries; the URL is unaffected.
596
+ }
597
+ }, [localStorageEnabled, storageKey]);
598
+ const persistToUrl = react.useCallback((state) => {
599
+ if (state === undefined) {
600
+ lastUrlRef.current = undefined;
601
+ updateUrlValue(undefined);
602
+ return;
603
+ }
604
+ const write = { projected: projectForUrl(state), owner: currentUrlOwner };
605
+ const last = lastUrlRef.current;
606
+ // Same projection: write only to add or change a stamp. An unresolved
607
+ // identity never rewrites a stamped URL just to strip the stamp; it is
608
+ // re-stamped once the identity resolves.
609
+ if (last !== undefined &&
610
+ dequal.dequal(last.projected, write.projected) &&
611
+ (last.owner === write.owner || write.owner === undefined)) {
612
+ return;
613
+ }
614
+ lastUrlRef.current = write;
615
+ updateUrlValue(encodeUrlWrite(write));
616
+ }, [currentUrlOwner, encodeUrlWrite, projectForUrl, updateUrlValue]);
617
+ const persistState = react.useCallback((state) => {
618
+ persistToStorage(state);
619
+ persistToUrl(state);
620
+ }, [persistToStorage, persistToUrl]);
621
+ return react.useMemo(() => ({ initialState, persistState, persistToStorage, persistToUrl }), [initialState, persistState, persistToStorage, persistToUrl]);
622
+ };
623
+
624
+ /**
625
+ * Syncs an encoded string value with a URL search parameter via Tanstack Router.
626
+ *
627
+ * Thin wrapper over {@link useUrlParamSync} with `location: "search"`; see it
628
+ * for the write queue, the size guard (`MAX_URL_LENGTH`), and external-change
629
+ * detection.
630
+ *
631
+ * @param options - Configuration for the search param sync.
632
+ * @param options.key - The URL search parameter name.
633
+ * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
634
+ * @param options.onExternalChange - Called when the param changes externally.
635
+ * @param options.replace - `true` to always use `replaceState`.
636
+ */
637
+ const useSearchParamSync = (options) => useUrlParamSync({ ...options, location: "search" });
638
+
639
+ /**
640
+ * A text link to a route inside the app, rendered through the router's `Link`.
641
+ * Use `ExternalLink` from `@trackunit/react-components` for links that leave the app.
642
+ *
643
+ * @param {RouteLinkProps} props The props for the RouteLink component.
644
+ * @returns {ReactElement} RouteLink component
645
+ */
646
+ const RouteLink = ({ to, text, title, onClick, className, "data-testid": dataTestId, }) => {
647
+ return (jsxRuntime.jsx(reactRouter.Link, { className: cvaStyledLink({ className }), "data-testid": dataTestId, onClick: onClick, title: title, to: to, children: text }));
648
+ };
649
+ const cvaStyledLink = cssClassVarianceUtilities.cva({ base: ["text-primary-600", "hover:text-primary-700"] });
650
+
651
+ const DEFAULT_PARAM_NAME = "c";
652
+ const DEFAULT_DEBOUNCE_MS = 300;
653
+ /**
654
+ * Reads a cursor from the URL search params on mount and provides a debounced
655
+ * updater for writing it back. Uses `replace: true` to avoid polluting browser
656
+ * history with every scroll position change.
657
+ *
658
+ * Wire `initialCursor` into `usePaginationQuery` and `updateCursor` into
659
+ * `useBidirectionalScroll`'s `onTopItemChange` callback (mapping index to cursor).
660
+ *
661
+ * A pending write is dropped whenever a path-changing navigation starts or the
662
+ * location has moved on: the write targets `to: "."`, so landing it after the
663
+ * user opened a row would replace their destination with the list they came
664
+ * from.
665
+ */
666
+ const useCursorUrlSync = ({ paramName = DEFAULT_PARAM_NAME, debounceMs = DEFAULT_DEBOUNCE_MS, } = {}) => {
667
+ const router = reactRouter.useRouter();
668
+ const navigate = reactRouter.useNavigate();
669
+ // Read the cursor off the router rather than `useSearch`, which would
670
+ // subscribe the table to its own writes and re-render every row mid-scroll.
671
+ const [initialCursor] = react.useState(() => {
672
+ const entry = Object.entries(router.latestLocation.search).find(([key]) => key === paramName);
673
+ return typeof entry?.[1] === "string" ? entry[1] : undefined;
674
+ });
675
+ const timerRef = react.useRef(undefined);
676
+ const cancelPendingUpdate = react.useCallback(() => {
677
+ if (timerRef.current !== undefined) {
678
+ clearTimeout(timerRef.current);
679
+ timerRef.current = undefined;
680
+ }
681
+ }, []);
682
+ const paramNameRef = react.useRef(paramName);
683
+ react.useEffect(() => {
684
+ paramNameRef.current = paramName;
685
+ }, [paramName]);
686
+ const updateCursor = react.useCallback((cursor) => {
687
+ cancelPendingUpdate();
688
+ const scheduledAtPathname = router.latestLocation.pathname;
689
+ timerRef.current = setTimeout(() => {
690
+ timerRef.current = undefined;
691
+ if (router.latestLocation.pathname !== scheduledAtPathname) {
692
+ return;
693
+ }
694
+ void navigate({
695
+ to: ".",
696
+ search: (prev) => ({
697
+ ...prev,
698
+ [paramNameRef.current]: cursor,
699
+ }),
700
+ // Without this the router drops the fragment, and with it the table layout.
701
+ hash: true,
702
+ replace: true,
703
+ });
704
+ }, debounceMs);
705
+ }, [cancelPendingUpdate, debounceMs, navigate, router]);
706
+ // Only path-changing navigations invalidate a pending write. Same-pathname
707
+ // navigations (filter bar, table persistence, map viewBox) share this URL and
708
+ // their writes must not cancel ours. Cancelling here rather than relying on
709
+ // the pathname check alone also covers navigating away and back within the
710
+ // debounce window, which would otherwise land a stale cursor on a fresh list.
711
+ react.useEffect(() => router.subscribe("onBeforeNavigate", event => {
712
+ if (event.pathChanged) {
713
+ cancelPendingUpdate();
714
+ }
715
+ }), [cancelPendingUpdate, router]);
716
+ react.useEffect(() => {
717
+ return cancelPendingUpdate;
718
+ }, [cancelPendingUpdate]);
719
+ return react.useMemo(() => ({ initialCursor, updateCursor }), [initialCursor, updateCursor]);
720
+ };
721
+
722
+ const readOwnValue = (record, key) => {
723
+ if (record === null || record === undefined) {
724
+ return undefined;
725
+ }
726
+ if (!Object.prototype.hasOwnProperty.call(record, key)) {
727
+ return undefined;
728
+ }
729
+ return Reflect.get(record, key);
730
+ };
731
+ // The only place a router value becomes `TId`, and the narrowing is unchecked: the `from` route's
732
+ // `params.parse` / `validateSearch` owns the id's shape, and `TId` has no runtime form the hook
733
+ // could check against. This only drops values that cannot be an id at all. TanStack's default
734
+ // search parser JSON-parses values, so `?rowId=42` arrives as the number 42; integers beyond
735
+ // Number.MAX_SAFE_INTEGER have already lost digits, so they are no selection.
736
+ const trustRouteValidatedId = (value) => {
737
+ const isRouteValidatedId = (candidate) => (typeof candidate === "string" && candidate.length > 0) ||
738
+ (typeof candidate === "number" && Number.isSafeInteger(candidate));
739
+ return isRouteValidatedId(value) ? value : undefined;
740
+ };
741
+ const findFromMatch = (matches, fromIds) => matches.find(match => fromIds.includes(match.routeId));
742
+ const readSelection = (match, param, paramKind) => readOwnValue(paramKind === "path" ? match?.params : match?.search, param);
743
+ /** The route `routeId` names and its ancestors, root first. */
744
+ const routeChain = (routesById, routeId) => {
745
+ const routes = [];
746
+ for (let route = routesById[routeId]; route !== undefined; route = route.parentRoute) {
747
+ routes.unshift(route);
748
+ }
749
+ return routes;
750
+ };
751
+ // The blocker's locations and `getMatchedRoutes` give raw path params, while route matches carry
752
+ // them after every matched route's `params.parse`, root first. Applying the same parses keeps the
753
+ // ids compared and written equal to the ones read from `router.state.matches`.
754
+ const parsePathParams = (routes, rawParams) => {
755
+ const params = { ...rawParams };
756
+ routes.forEach(route => {
757
+ const parse = route.options.params?.parse ?? route.options.parseParams;
758
+ if (parse === undefined) {
759
+ return;
760
+ }
761
+ try {
762
+ Object.assign(params, parse(params));
763
+ }
764
+ catch {
765
+ // Like the router, a failed parse leaves the params as they were.
766
+ }
767
+ });
768
+ return params;
769
+ };
770
+ const applySearchValidator = (validator, input) => {
771
+ if (validator === undefined) {
772
+ return {};
773
+ }
774
+ if ("~standard" in validator) {
775
+ const result = validator["~standard"].validate(input);
776
+ if (result instanceof Promise || result.issues !== undefined) {
777
+ throw new Error("useRouterDrawer: search validation failed");
778
+ }
779
+ return result.value;
780
+ }
781
+ if ("parse" in validator) {
782
+ return validator.parse(input);
783
+ }
784
+ return validator(input);
785
+ };
786
+ // The blocker's locations and `router.latestLocation` give the raw parsed search, while route
787
+ // matches carry it after every matched route's `validateSearch`, root first, each over the search
788
+ // validated so far. Applying the same validators keeps search ids equal to the rendered ones.
789
+ // This mirrors `validateSearch` and `matchRoutes` in `@tanstack/router-core` 1.114; re-check it on
790
+ // upgrade. Unlike the router, it records no `searchError` and ignores a non-object result.
791
+ const validateSearch = (routes, rawSearch) => routes.reduce((search, route) => {
792
+ try {
793
+ const validated = applySearchValidator(route.options.validateSearch, { ...search });
794
+ return typeof validated === "object" && validated !== null ? { ...search, ...validated } : search;
795
+ }
796
+ catch {
797
+ // Like the router, a failed validation leaves the search as it was.
798
+ return search;
799
+ }
800
+ }, { ...rawSearch });
801
+ /** `routes` up to and including the first one in `fromIds`, or `undefined` if none is. */
802
+ const routesThroughFrom = (routes, fromIds) => {
803
+ const fromIndex = routes.findIndex(route => fromIds.includes(route.id));
804
+ return fromIndex === -1 ? undefined : routes.slice(0, fromIndex + 1);
805
+ };
806
+ // After this drawer's guard allows a move, `setId` navigates with a fresh token in the history
807
+ // state, and the blocker lets exactly the navigation carrying it through (once). The navigation
808
+ // itself is the identity: a token kept in the router would also bless the next leave when another
809
+ // blocker vetoes the allowed move, since a vetoed navigation never settles to clear it.
810
+ const ALLOWED_MOVE_STATE_KEY = "useRouterDrawerAllowedMove";
811
+ let allowedMoveCount = 0;
812
+ const createMoveToken = () => {
813
+ allowedMoveCount += 1;
814
+ return `${Date.now().toString(36)}-${allowedMoveCount}`;
815
+ };
816
+ const withMoveToken = (moveToken) => moveToken === undefined
817
+ ? {}
818
+ : { state: previous => Object.assign({}, previous, { [ALLOWED_MOVE_STATE_KEY]: moveToken }) };
819
+ const useRouterSelection = ({ from, param, paramKind = "search", onBeforeClose, }) => {
820
+ const router = reactRouter.useRouter();
821
+ const fromIds = typeof from === "string" ? [from] : from;
822
+ const unknownRouteId = fromIds.find(routeId => readOwnValue(router.routesById, routeId) === undefined);
823
+ if (unknownRouteId !== undefined) {
824
+ throw new Error(`useRouterDrawer: "from" route id "${unknownRouteId}" is not registered in the router.`);
825
+ }
826
+ const value = reactRouter.useRouterState({
827
+ select: state => readSelection(findFromMatch(state.matches, fromIds), param, paramKind),
828
+ });
829
+ const navigate = reactRouter.useNavigate();
830
+ const id = trustRouteValidatedId(value);
831
+ const navigateRef = react.useRef(navigate);
832
+ const routerRef = react.useRef(router);
833
+ const fromIdsRef = react.useRef(fromIds);
834
+ const onBeforeCloseRef = react.useRef(onBeforeClose);
835
+ const guardPendingRef = react.useRef(false);
836
+ const allowedMoveRef = react.useRef(undefined);
837
+ react.useLayoutEffect(() => {
838
+ navigateRef.current = navigate;
839
+ routerRef.current = router;
840
+ fromIdsRef.current = fromIds;
841
+ onBeforeCloseRef.current = onBeforeClose;
842
+ });
843
+ // Returns whether the guard allowed leaving the selection.
844
+ const askGuard = react.useCallback(async (guard) => {
845
+ guardPendingRef.current = true;
846
+ try {
847
+ return (await guard()) === true;
848
+ }
849
+ catch {
850
+ return false;
851
+ }
852
+ finally {
853
+ guardPendingRef.current = false;
854
+ }
855
+ }, []);
856
+ const blockerFn = react.useCallback(async ({ currentLocation, nextLocation }) => {
857
+ const allowedMove = allowedMoveRef.current;
858
+ if (allowedMove !== undefined && readOwnValue(nextLocation.state, ALLOWED_MOVE_STATE_KEY) === allowedMove) {
859
+ // Consumed here, as soon as this blocker accepts the move, not when its navigation settles:
860
+ // another blocker can still veto it, and then nothing settles.
861
+ allowedMoveRef.current = undefined;
862
+ return false;
863
+ }
864
+ const guard = onBeforeCloseRef.current;
865
+ if (guard === undefined) {
866
+ return false;
867
+ }
868
+ // Same id alone is not "same selection": a route that reuses `param`'s name for something
869
+ // else must not be mistaken for the selection's own route, so its owner must match too. The
870
+ // owner is the matched `from` route, so child routes under it keep the selection.
871
+ const selectionAt = (historyLocation) => {
872
+ const parsedLocation = routerRef.current.parseLocation(undefined, historyLocation);
873
+ const { foundRoute, routeParams } = routerRef.current.getMatchedRoutes(parsedLocation);
874
+ if (foundRoute === undefined) {
875
+ return undefined;
876
+ }
877
+ const location = {
878
+ routeId: foundRoute.id,
879
+ params: routeParams,
880
+ search: parsedLocation.search,
881
+ };
882
+ const routes = routeChain(routerRef.current.looseRoutesById, location.routeId);
883
+ const throughFrom = routesThroughFrom(routes, fromIdsRef.current);
884
+ const fromRoute = throughFrom?.at(-1);
885
+ if (throughFrom === undefined || fromRoute === undefined) {
886
+ return undefined;
887
+ }
888
+ const locationId = trustRouteValidatedId(paramKind === "path"
889
+ ? readOwnValue(parsePathParams(routes, location.params), param)
890
+ : readOwnValue(validateSearch(throughFrom, location.search), param));
891
+ if (locationId === undefined) {
892
+ return undefined;
893
+ }
894
+ return { id: locationId, ownerRouteId: fromRoute.id };
895
+ };
896
+ const currentSelection = selectionAt(currentLocation);
897
+ if (currentSelection === undefined) {
898
+ return false;
899
+ }
900
+ const nextSelection = selectionAt(nextLocation);
901
+ const sameSelection = nextSelection !== undefined &&
902
+ Object.is(currentSelection.id, nextSelection.id) &&
903
+ currentSelection.ownerRouteId === nextSelection.ownerRouteId;
904
+ if (sameSelection) {
905
+ return false;
906
+ }
907
+ // The pending short-circuit only applies once a navigation is known to leave the selection:
908
+ // checked above so an unrelated or same-selection event (e.g. a same-URL popstate) passes
909
+ // through instead of being blocked, which on the pop path would send the user forward.
910
+ if (guardPendingRef.current) {
911
+ return true;
912
+ }
913
+ return !(await askGuard(guard));
914
+ }, [askGuard, param, paramKind]);
915
+ const isGuarding = onBeforeClose !== undefined && id !== undefined;
916
+ react.useEffect(() => {
917
+ if (!isGuarding) {
918
+ return undefined;
919
+ }
920
+ return router.history.block({ blockerFn, enableBeforeUnload: false });
921
+ }, [isGuarding, blockerFn, router]);
922
+ // Resolved from `router.latestLocation` on every call: `router.state.matches` lags a navigation
923
+ // that has not loaded yet, and a guard can settle long after `setId` was called. Every write
924
+ // passes `hash: true`, since TanStack drops the hash from a navigation that does not ask for it.
925
+ const planWrite = react.useCallback((nextId) => {
926
+ const latestRouter = routerRef.current;
927
+ const { latestLocation } = latestRouter;
928
+ const { matchedRoutes, routeParams } = latestRouter.getMatchedRoutes(latestLocation);
929
+ const throughFrom = routesThroughFrom(matchedRoutes, fromIdsRef.current);
930
+ const fromRoute = throughFrom?.at(-1);
931
+ if (paramKind === "search") {
932
+ const currentSearchId = trustRouteValidatedId(throughFrom === undefined
933
+ ? undefined
934
+ : readOwnValue(validateSearch(throughFrom, latestLocation.search), param));
935
+ if (Object.is(nextId, currentSearchId)) {
936
+ return undefined;
937
+ }
938
+ return {
939
+ currentId: currentSearchId,
940
+ commit: moveToken => navigateRef.current({
941
+ to: ".",
942
+ search: (prev) => ({ ...prev, [param]: nextId }),
943
+ hash: true,
944
+ replace: true,
945
+ ...withMoveToken(moveToken),
946
+ }),
947
+ };
948
+ }
949
+ const params = parsePathParams(matchedRoutes, routeParams);
950
+ const currentId = trustRouteValidatedId(fromRoute === undefined ? undefined : readOwnValue(params, param));
951
+ if (Object.is(nextId, currentId)) {
952
+ return undefined;
953
+ }
954
+ // Targets are absolute: navigate's `from` is looked up in the lagging `router.state.matches`
955
+ // and throws when the latest location's from match is not there yet.
956
+ const fromPath = fromRoute?.fullPath;
957
+ if (fromPath !== undefined) {
958
+ return {
959
+ currentId,
960
+ commit: moveToken => navigateRef.current({
961
+ to: nextId === undefined ? `${fromPath}/..` : fromPath,
962
+ params: { ...params, [param]: nextId },
963
+ search: true,
964
+ hash: true,
965
+ replace: true,
966
+ ...withMoveToken(moveToken),
967
+ }),
968
+ };
969
+ }
970
+ if (nextId === undefined) {
971
+ return undefined;
972
+ }
973
+ const [onlyFromId, ...otherFromIds] = fromIdsRef.current;
974
+ const onlyFromPath = onlyFromId === undefined ? undefined : latestRouter.routesById[onlyFromId]?.fullPath;
975
+ if (onlyFromPath === undefined || otherFromIds.length > 0) {
976
+ throw new Error(`useRouterDrawer: opening "${param}" from closed needs a single "from" route id to navigate to; got ${JSON.stringify(fromIdsRef.current)}.`);
977
+ }
978
+ return {
979
+ currentId,
980
+ commit: moveToken => navigateRef.current({
981
+ to: onlyFromPath,
982
+ params: { ...params, [param]: nextId },
983
+ search: true,
984
+ hash: true,
985
+ replace: true,
986
+ ...withMoveToken(moveToken),
987
+ }),
988
+ };
989
+ }, [param, paramKind]);
990
+ const setId = react.useCallback((nextId) => {
991
+ const write = planWrite(nextId);
992
+ if (write === undefined) {
993
+ return;
994
+ }
995
+ if (guardPendingRef.current) {
996
+ return;
997
+ }
998
+ const guard = onBeforeCloseRef.current;
999
+ const fromId = write.currentId;
1000
+ if (guard === undefined || fromId === undefined) {
1001
+ void write.commit();
1002
+ return;
1003
+ }
1004
+ // The guard runs here rather than in the blocker: a navigation handed to the blocker is built
1005
+ // before the guard settles, so allowing it would undo URL changes made while it was pending.
1006
+ // On allow the write is planned again from the latest location. Only this drawer's blocker
1007
+ // lets that one navigation through, so other blockers still run and the guard is not asked twice.
1008
+ void askGuard(guard).then(allowed => {
1009
+ if (!allowed) {
1010
+ return;
1011
+ }
1012
+ let allowedWrite;
1013
+ try {
1014
+ allowedWrite = planWrite(nextId);
1015
+ }
1016
+ catch {
1017
+ // `planWrite` only throws when nothing is selected any more, which makes this attempt stale.
1018
+ return;
1019
+ }
1020
+ // The selection moved while the guard was pending, so this attempt no longer applies.
1021
+ if (allowedWrite === undefined || !Object.is(allowedWrite.currentId, fromId)) {
1022
+ return;
1023
+ }
1024
+ const moveToken = createMoveToken();
1025
+ allowedMoveRef.current = moveToken;
1026
+ void allowedWrite.commit(moveToken);
1027
+ });
1028
+ }, [askGuard, planWrite]);
1029
+ return { id, isOpen: id !== undefined, setId };
1030
+ };
1031
+ /**
1032
+ * Ties a `Drawer`'s selection to a URL param. Wraps `useDrawer` and `useHold` so callers spread the
1033
+ * result onto `Drawer` and never call either themselves.
1034
+ *
1035
+ * `id` is held through the close animation, so a query called as `useX(id, { skip: !id })` keeps
1036
+ * serving the closing id's data while the drawer slides out, and is skipped once it has exited.
1037
+ *
1038
+ * When one component owns the URL state and a child renders `Drawer`, call this hook in the parent
1039
+ * and pass the result down as props. The child spreads them onto `Drawer` and does not call `useDrawer`.
1040
+ *
1041
+ * Call the hook from a component that stays rendered while the `from` route is matched, such as the
1042
+ * list or layout route that is an ancestor of `from`; otherwise the drawer is never shown.
1043
+ *
1044
+ * There is no `decode` option. `TId` is declared by the caller and not checked by the hook: the id
1045
+ * is taken as the `from` route's `validateSearch` or `params.parse` produced it, so `TId` must match
1046
+ * that output. Validate there, to produce numbers or reject bad values. A missing or empty param is
1047
+ * treated as no selection, including a `params.parse` that falls back to `""` for invalid input.
1048
+ *
1049
+ * Every write (open, switch, close) replaces the current history entry, so Back never steps through
1050
+ * drawer selections. Other search params and parent path params are kept. In path mode, switching
1051
+ * ids navigates to the `from` route, dropping any deeper child segments, and closing navigates one
1052
+ * path segment up from it.
1053
+ *
1054
+ * `onClose`, `onOpenChange` and `onBeforeClose` from `useDrawer` are not exposed: `onClose` is always
1055
+ * `setId(undefined)`, and `onBeforeClose` runs through the router blocker, where forwarding it to
1056
+ * `useDrawer` as well would guard twice. `onOpen` fires when the selection goes from none to an id, not on mount.
1057
+ *
1058
+ * @example
1059
+ * ```tsx
1060
+ * // Rendered by the `/templates` list route; `/templates/$templateId` parses the id.
1061
+ * const { id, setId, ...drawer } = useRouterDrawer({
1062
+ * from: "/templates/$templateId",
1063
+ * param: "templateId",
1064
+ * paramKind: "path",
1065
+ * position: "right",
1066
+ * onBeforeClose: async () => confirmDiscard(),
1067
+ * });
1068
+ * const { data, loading } = useTemplate(id, { skip: !id });
1069
+ * return <Drawer {...drawer}>...</Drawer>;
1070
+ * ```
1071
+ */
1072
+ const useRouterDrawer = ({ from, param, paramKind, onBeforeClose, variant, trapFocus, position, dismiss, onOpen, }) => {
1073
+ const { id: selectedId, isOpen, setId } = useRouterSelection({ from, param, paramKind, onBeforeClose });
1074
+ const drawer = reactDrawer.useDrawer({ isOpen, onClose: () => setId(undefined), variant, trapFocus, position, dismiss });
1075
+ const id = reactComponents.useHold(selectedId, drawer.isExiting);
1076
+ reactComponents.useWatch({
1077
+ value: isOpen,
1078
+ onChange: nowOpen => {
1079
+ if (nowOpen) {
1080
+ onOpen?.();
1081
+ }
1082
+ },
1083
+ });
1084
+ return react.useMemo(() => ({ ...drawer, id, setId }), [drawer, id, setId]);
1085
+ };
1086
+
1087
+ /*
1088
+ * ----------------------------
1089
+ * | SETUP TRANSLATIONS START |
1090
+ * ----------------------------
1091
+ * This import and function call is needed to register translations for this library.
1092
+ * Do not remove this if this library has translations.
1093
+ */
1094
+ setupLibraryTranslations();
1095
+
1096
+ exports.Breadcrumb = Breadcrumb;
1097
+ exports.MAX_URL_LENGTH = MAX_URL_LENGTH;
1098
+ exports.RouteLink = RouteLink;
1099
+ exports.useCursorUrlSync = useCursorUrlSync;
1100
+ exports.usePersistedState = usePersistedState;
1101
+ exports.useRouterDrawer = useRouterDrawer;
1102
+ exports.useSearchParamSync = useSearchParamSync;