path-router-red 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +427 -0
  2. package/dist/PathRouter/Container/ModalsContainer.d.ts +17 -0
  3. package/dist/PathRouter/Container/ModalsContainer.d.ts.map +1 -0
  4. package/dist/PathRouter/Container/ModalsContainer.js +39 -0
  5. package/dist/PathRouter/Container/ModalsContainer.js.map +1 -0
  6. package/dist/PathRouter/Container/RouterContainer.d.ts +16 -0
  7. package/dist/PathRouter/Container/RouterContainer.d.ts.map +1 -0
  8. package/dist/PathRouter/Container/RouterContainer.js +20 -0
  9. package/dist/PathRouter/Container/RouterContainer.js.map +1 -0
  10. package/dist/PathRouter/Container/index.d.ts +2 -0
  11. package/dist/PathRouter/Container/index.d.ts.map +1 -0
  12. package/dist/PathRouter/Container/index.js +18 -0
  13. package/dist/PathRouter/Container/index.js.map +1 -0
  14. package/dist/PathRouter/NavLink/NavLink.d.ts +40 -0
  15. package/dist/PathRouter/NavLink/NavLink.d.ts.map +1 -0
  16. package/dist/PathRouter/NavLink/NavLink.js +67 -0
  17. package/dist/PathRouter/NavLink/NavLink.js.map +1 -0
  18. package/dist/PathRouter/NavLink/index.d.ts +3 -0
  19. package/dist/PathRouter/NavLink/index.d.ts.map +1 -0
  20. package/dist/PathRouter/NavLink/index.js +6 -0
  21. package/dist/PathRouter/NavLink/index.js.map +1 -0
  22. package/dist/PathRouter/Provider/PathProvider.d.ts +10 -0
  23. package/dist/PathRouter/Provider/PathProvider.d.ts.map +1 -0
  24. package/dist/PathRouter/Provider/PathProvider.js +158 -0
  25. package/dist/PathRouter/Provider/PathProvider.js.map +1 -0
  26. package/dist/PathRouter/Provider/context.d.ts +3 -0
  27. package/dist/PathRouter/Provider/context.d.ts.map +1 -0
  28. package/dist/PathRouter/Provider/context.js +34 -0
  29. package/dist/PathRouter/Provider/context.js.map +1 -0
  30. package/dist/PathRouter/Provider/index.d.ts +3 -0
  31. package/dist/PathRouter/Provider/index.d.ts.map +1 -0
  32. package/dist/PathRouter/Provider/index.js +19 -0
  33. package/dist/PathRouter/Provider/index.js.map +1 -0
  34. package/dist/PathRouter/Provider/usePath.d.ts +15 -0
  35. package/dist/PathRouter/Provider/usePath.d.ts.map +1 -0
  36. package/dist/PathRouter/Provider/usePath.js +22 -0
  37. package/dist/PathRouter/Provider/usePath.js.map +1 -0
  38. package/dist/PathRouter/createPathRouter.d.ts +52 -0
  39. package/dist/PathRouter/createPathRouter.d.ts.map +1 -0
  40. package/dist/PathRouter/createPathRouter.js +72 -0
  41. package/dist/PathRouter/createPathRouter.js.map +1 -0
  42. package/dist/PathRouter/index.d.ts +41 -0
  43. package/dist/PathRouter/index.d.ts.map +1 -0
  44. package/dist/PathRouter/index.js +49 -0
  45. package/dist/PathRouter/index.js.map +1 -0
  46. package/dist/PathRouter/types.d.ts +91 -0
  47. package/dist/PathRouter/types.d.ts.map +1 -0
  48. package/dist/PathRouter/types.js +3 -0
  49. package/dist/PathRouter/types.js.map +1 -0
  50. package/dist/PathRouter/utils/clearSlash.d.ts +8 -0
  51. package/dist/PathRouter/utils/clearSlash.d.ts.map +1 -0
  52. package/dist/PathRouter/utils/clearSlash.js +21 -0
  53. package/dist/PathRouter/utils/clearSlash.js.map +1 -0
  54. package/dist/PathRouter/utils/createRoute.d.ts +16 -0
  55. package/dist/PathRouter/utils/createRoute.d.ts.map +1 -0
  56. package/dist/PathRouter/utils/createRoute.js +36 -0
  57. package/dist/PathRouter/utils/createRoute.js.map +1 -0
  58. package/dist/PathRouter/utils/index.d.ts +5 -0
  59. package/dist/PathRouter/utils/index.d.ts.map +1 -0
  60. package/dist/PathRouter/utils/index.js +21 -0
  61. package/dist/PathRouter/utils/index.js.map +1 -0
  62. package/dist/PathRouter/utils/parseSearch.d.ts +4 -0
  63. package/dist/PathRouter/utils/parseSearch.d.ts.map +1 -0
  64. package/dist/PathRouter/utils/parseSearch.js +15 -0
  65. package/dist/PathRouter/utils/parseSearch.js.map +1 -0
  66. package/dist/PathRouter/utils/setters.d.ts +9 -0
  67. package/dist/PathRouter/utils/setters.d.ts.map +1 -0
  68. package/dist/PathRouter/utils/setters.js +25 -0
  69. package/dist/PathRouter/utils/setters.js.map +1 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +18 -0
  73. package/dist/index.js.map +1 -0
  74. package/package.json +32 -0
  75. package/src/PathRouter/Container/ModalsContainer.tsx +92 -0
  76. package/src/PathRouter/Container/RouterContainer.tsx +66 -0
  77. package/src/PathRouter/Container/index.ts +1 -0
  78. package/src/PathRouter/NavLink/NavLink.tsx +146 -0
  79. package/src/PathRouter/NavLink/index.ts +2 -0
  80. package/src/PathRouter/Provider/PathProvider.tsx +220 -0
  81. package/src/PathRouter/Provider/context.ts +33 -0
  82. package/src/PathRouter/Provider/index.ts +2 -0
  83. package/src/PathRouter/Provider/usePath.ts +21 -0
  84. package/src/PathRouter/createPathRouter.tsx +104 -0
  85. package/src/PathRouter/index.ts +79 -0
  86. package/src/PathRouter/readme.md +427 -0
  87. package/src/PathRouter/types.ts +139 -0
  88. package/src/PathRouter/utils/clearSlash.ts +16 -0
  89. package/src/PathRouter/utils/createRoute.ts +53 -0
  90. package/src/PathRouter/utils/index.ts +4 -0
  91. package/src/PathRouter/utils/parseSearch.ts +15 -0
  92. package/src/PathRouter/utils/setters.ts +8 -0
  93. package/src/index.ts +1 -0
  94. package/tsconfig.json +19 -0
@@ -0,0 +1,427 @@
1
+ # PathRouter
2
+
3
+ A small, type-safe routing layer built on top of `react-router-dom` that adds:
4
+
5
+ - A **declarative config** for pages and modals (`setPage` / `setModal`).
6
+ - A **factory** (`createPathRouter`) that binds your config to fully-typed router pieces — no manual generics on every call site.
7
+ - A **single React context** exposing the current page, the current modal and the search params, with imperative helpers (`navigate`, `open`, `close`, `set`, `change`, `delete`, `clear`).
8
+ - **URL-driven modals**: a modal is represented as a segment after `/modal/` inside the path, so it survives reloads, deep links and back/forward navigation.
9
+ - An optional **`ModalWrapper`** plugin (e.g. an animated popup) that can intercept the close action via a forwarded ref.
10
+
11
+ ---
12
+
13
+ ## File layout
14
+
15
+ ```text
16
+ PathRouter/
17
+ ├── index.ts # Public API (factory + builders + types)
18
+ ├── createPathRouter.tsx # The factory itself
19
+ ├── types.ts # All public types
20
+ ├── Container/
21
+ │ ├── RouterContainer.tsx # Renders <Routes> for pages + <ModalsContainer>
22
+ │ ├── ModalsContainer.tsx # Renders the currently open modal
23
+ │ └── index.ts
24
+ ├── Provider/
25
+ │ ├── PathProvider.tsx # BrowserRouter + PathContext provider
26
+ │ ├── context.ts # React context object
27
+ │ ├── usePath.ts # Internal hook (factory wraps it for users)
28
+ │ └── index.ts
29
+ ├── NavLink/
30
+ │ ├── NavLink.tsx # Internal NavLink (factory wraps it for users)
31
+ │ └── index.ts
32
+ └── utils/
33
+ ├── setters.ts # setPage / setModal helpers
34
+ ├── createRoute.ts # Flattens the nested page config
35
+ ├── clearSlash.ts # Path normalization
36
+ ├── parseSearch.ts # Search-params merge helper
37
+ └── index.ts
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Quick start
43
+
44
+ ```ts
45
+ // src/config/route.ts
46
+ import { setPage, setModal } from "@/modules/PathRouter";
47
+ import { HomePage, AddPage, NotFoundPage } from "@/Pages";
48
+ import { TestModal } from "@/Modals/Test";
49
+
50
+ export const route = {
51
+ pages: {
52
+ home: setPage({ component: HomePage }),
53
+ add: setPage({ component: AddPage }),
54
+ "*": setPage({ component: NotFoundPage }),
55
+ },
56
+ modals: {
57
+ test: setModal({ component: TestModal }),
58
+ },
59
+ } as const;
60
+ ```
61
+
62
+ ```ts
63
+ // src/containers/Router/PathProvider.tsx
64
+ import { route } from "@/config";
65
+ import { createPathRouter } from "@/modules/PathRouter";
66
+
67
+ export const {
68
+ PathProvider,
69
+ PathRouterContainer,
70
+ usePath,
71
+ NavLink,
72
+ getPath,
73
+ getModal,
74
+ } = createPathRouter(route);
75
+ ```
76
+
77
+ ```tsx
78
+ // somewhere near the root
79
+ import { PathProvider, PathRouterContainer } from "@/containers/Router";
80
+
81
+ <PathProvider>
82
+ <PathRouterContainer fallback={<Spinner />} />
83
+ </PathProvider>;
84
+ ```
85
+
86
+ ```tsx
87
+ // any component
88
+ import { usePath, NavLink } from "@/containers/Router";
89
+
90
+ const Foo = () => {
91
+ const { page, modal } = usePath(); // no <typeof config> generic needed!
92
+ page.navigate("add"); // ✓ autocompleted
93
+ modal.open("test"); // ✓ autocompleted
94
+ return <NavLink to="home" modal="test">Open</NavLink>;
95
+ };
96
+ ```
97
+
98
+ ---
99
+
100
+ ## How it works
101
+
102
+ ### 1. Configuration via `setPage` / `setModal`
103
+
104
+ The config is a plain object with two sections — `pages` and `modals`:
105
+
106
+ ```ts
107
+ import { setPage, setModal } from "@/modules/PathRouter";
108
+
109
+ export const route = {
110
+ pages: {
111
+ "/": setPage({ component: HomePage }),
112
+ add: setPage({ component: AddItemPage }),
113
+ users: {
114
+ "/": setPage({ component: UsersListPage }),
115
+ ":id": setPage({ component: UserPage }),
116
+ },
117
+ modules: {
118
+ ...setPage({ component: ModulesIndexPage }), // page at /modules
119
+ routing: setPage({ component: RoutingPage }), // page at /modules/routing
120
+ "*": setPage({ component: RoutingPage }), // page at /modules/*
121
+ },
122
+ "*": setPage({ component: NotFoundPage }),
123
+ },
124
+ modals: {
125
+ test: setModal({ component: TestModal }),
126
+ confirm: setModal({ component: ConfirmModal }),
127
+ },
128
+ } as const;
129
+ ```
130
+
131
+ - `setPage({ component, redirect? })` wraps the value as `{ data: {...} }`. The `data` field marks the node as a renderable page, but **does not stop** the route builder from descending — children of the same node are still discovered.
132
+ - Pages can be **nested** as plain objects — `createRoute` walks the tree and produces a flat `[{ pathName, data }]` list (see `utils/createRoute.ts`).
133
+ - A node may **simultaneously be a page and a container** for child routes. Spread the result of `setPage` into the node to attach a component at that path while keeping nested keys:
134
+
135
+ ```ts
136
+ modules: {
137
+ ...setPage({ component: ModulesIndexPage }), // /modules
138
+ routing: setPage({ component: RoutingPage }), // /modules/routing
139
+ "*": setPage({ component: RoutingPage }), // /modules/*
140
+ }
141
+ ```
142
+
143
+ This is what enables breadcrumb-style hierarchies where each ancestor segment is itself a page.
144
+ - Recursion into a node stops only when the node itself carries `component` or `redirect` at its top level (i.e. it is a bare leaf, not a `setPage(...)` result). `setPage` puts those fields under `data`, so spreading it never blocks descent.
145
+ - A page with `redirect` (or no `component`) becomes a `<Navigate to={redirect || "/"} replace />`.
146
+ - `setModal({ component })` is just an identity helper that preserves literal types for inference.
147
+ - `as const` is **required** — without it TS widens string keys to `string` and you lose autocompletion.
148
+
149
+ ### 2. The `createPathRouter` factory
150
+
151
+ `createPathRouter(route)` captures your config once and returns everything pre-bound:
152
+
153
+ ```ts
154
+ const {
155
+ PathProvider, // BrowserRouter + context
156
+ PathRouterContainer, // renders pages + modals (config injected)
157
+ usePath, // typed hook — no <typeof config> needed
158
+ NavLink, // typed link — no <typeof config> needed
159
+ getPath, // identity helper: <P extends PathNamesOf<C>>(p: P) => p
160
+ getModal, // identity helper: <M extends ModalNamesOf<C>>(m: M) => m
161
+ config, // the original config, re-exported
162
+ } = createPathRouter(route);
163
+ ```
164
+
165
+ Why re-exports rather than direct imports?
166
+
167
+ - TypeScript cannot infer generics from a literal `typeof route` _unless_ you pass it explicitly each time. The factory passes it once for you.
168
+ - Every consumer of the returned API gets autocompletion of routes / modal names with **zero ceremony**.
169
+ - The package itself stays generic and reusable; the binding lives in your app code.
170
+
171
+ > The factory return values are not re-exported from `@/modules/PathRouter`. The only way to obtain `PathProvider`, `PathRouterContainer`, `usePath`, `NavLink`, `getPath`, `getModal` is via `createPathRouter(config)`.
172
+
173
+ ### 3. Mounting
174
+
175
+ ```tsx
176
+ import { PathProvider, PathRouterContainer } from "@/containers/Router";
177
+
178
+ <PathProvider>
179
+ <PathRouterContainer
180
+ ModalWrapper={MyModalWrapper} // optional
181
+ fallback={<Spinner />} // optional Suspense fallback
182
+ />
183
+ </PathProvider>;
184
+ ```
185
+
186
+ - `PathProvider` mounts a `BrowserRouter` and an inner provider that derives the page path, the modal state and the search params from `useLocation()` (`Provider/PathProvider.tsx`).
187
+ - `PathRouterContainer` renders the pages inside `<Suspense>` and, if a modal is open, mounts `ModalsContainer` next to the page tree. `config` is already injected by the factory — you only pass `ModalWrapper` / `fallback`.
188
+
189
+ ### 4. URL shape
190
+
191
+ A URL is split on the literal **`/modal/`** separator:
192
+
193
+ ```text
194
+ /users/42/modal/confirm/extra-crumb?tab=info
195
+ └── page path ──┘ └─ modal ──┘
196
+ ```
197
+
198
+ In `PathProvider`:
199
+
200
+ ```ts
201
+ const [rawPagePath, modalPath] = location.pathname.split("/modal/");
202
+ const segments = (modalPath || "").split("/").filter(Boolean);
203
+ const name = segments[0]; // "confirm"
204
+ const breadCrumbs = segments.slice(1); // ["extra-crumb"]
205
+ ```
206
+
207
+ So:
208
+
209
+ - `page.path` always points to the page route the user is on, regardless of whether a modal is open.
210
+ - `modal.name` is the first segment after `/modal/`.
211
+ - `modal.breadCrumbs` are additional path segments that the modal can use for its own internal navigation/steps.
212
+ - `modal.isOpen` is `true` iff `modal.name` is set.
213
+
214
+ This means modals are **bookmarkable and shareable** out of the box and the browser back button closes the modal naturally.
215
+
216
+ ### 5. The `usePath` hook (from the factory)
217
+
218
+ ```ts
219
+ import { usePath } from "@/containers/Router";
220
+
221
+ const { page, modal, searchParams } = usePath(); // already typed!
222
+
223
+ page.path; // current page pathname (no /modal/... suffix)
224
+ page.navigate("add"); // ✓ typed against config.pages
225
+ page.isHavePrevHistory; // true if history.key !== "default"
226
+
227
+ modal.isOpen;
228
+ modal.name; // current modal key
229
+ modal.breadCrumbs; // string[] after the modal name
230
+ modal.path; // "<name>/<crumb1>/<crumb2>"
231
+ modal.open("confirm", ["step-2"]); // ✓ typed against config.modals
232
+ modal.close();
233
+
234
+ searchParams.params; // Record<string, string[]>
235
+ searchParams.change({ tab: "info" }); // merge (string=set, string[]=append)
236
+ searchParams.set({ tab: ["a", "b"] }); // replace per-key
237
+ searchParams.delete("tab");
238
+ searchParams.clear();
239
+ ```
240
+
241
+ ### 6. `getPath` / `getModal` — typed identity helpers
242
+
243
+ When you need a typed path or modal-name literal somewhere outside JSX (e.g. inside a side-effect, a redux thunk, a `redirect` field of another page), use the identity helpers returned by the factory:
244
+
245
+ ```ts
246
+ import { getPath, getModal } from "@/containers/Router";
247
+
248
+ const target = getPath("home"); // type: "home"
249
+ const which = getModal("test"); // type: "test"
250
+
251
+ // Compile-time error: argument is not assignable to PathNamesOf<typeof route>
252
+ const bad = getPath("does-not-exist");
253
+ ```
254
+
255
+ These replace the previous `export type PathNames = NestedKeyOf<typeof routes, "data">` pattern that is impossible to express from inside an isolated package.
256
+
257
+ If you really need the type itself (e.g. as a function parameter), it is still available via `typeof getPath`:
258
+
259
+ ```ts
260
+ type PathNames = Parameters<typeof getPath>[0];
261
+ type ModalNames = Parameters<typeof getModal>[0];
262
+ ```
263
+
264
+ …or use the package-level generics with an explicit config:
265
+
266
+ ```ts
267
+ import type { PathNamesOf, ModalNamesOf } from "@/modules/PathRouter";
268
+ import type { route } from "@/config";
269
+
270
+ type PathNames = PathNamesOf<typeof route>;
271
+ type ModalNames = ModalNamesOf<typeof route>;
272
+ ```
273
+
274
+ ### 7. `NavLink` (from the factory)
275
+
276
+ ```tsx
277
+ import { NavLink } from "@/containers/Router";
278
+
279
+ <NavLink to="home">Home</NavLink>
280
+ <NavLink modal="test">Open test modal</NavLink>
281
+ <NavLink to="users" modal="confirm" modalBreadCrumbs={["step-2"]}>
282
+ Users + confirm at step 2
283
+ </NavLink>
284
+ ```
285
+
286
+ - Renders a real `<a href>` (right-click / “open in new tab” / SSR work as expected).
287
+ - Intercepts the primary-button click and routes through `page.navigate(...)`.
288
+ - Adds `aria-current="page"` and `data-active` when active; you can override the active class via `activeClassName`.
289
+
290
+ ### 8. Page rendering (`RouterContainer.tsx`)
291
+
292
+ - Calls `createRoute(config)` once (memoised) to flatten the page tree.
293
+ - Renders a single `<Routes>` switch with one `<Route>` per leaf.
294
+ - If a leaf has no `component` or has a `redirect`, the element becomes `<Navigate to={redirect || "/"} replace />`.
295
+ - The whole switch is wrapped in `<Suspense fallback={fallback}>` so lazy components work transparently.
296
+ - Modals are rendered as a **sibling** of `<Routes>`, only when `modal.isOpen` — they overlay the current page rather than replacing it.
297
+
298
+ ### 9. Modal rendering (`ModalsContainer.tsx`)
299
+
300
+ - Builds a virtual location `"<pagePath>/<modalName>"` (normalized via `clearSlash`) and feeds it to a dedicated `<Routes location={routesLocation}>`. This is what makes the modal aware of the current page path.
301
+ - Each modal route is registered at `"<pagePath>/<modalName>"`, so modals can be **page-scoped** if needed.
302
+ - If the URL contains a modal name that does not exist in the config (no matching component), the container calls `modal.close()` automatically — broken/stale modal links self-heal.
303
+ - If a `ModalWrapper` is provided, it is rendered with `{ modalName, isOpen, onClose, children }` and a forwarded ref. When the user triggers close, the container prefers `ref.current.handleCloseWithAnimation()` (so the wrapper can run an exit animation), and only falls back to the raw `close()` if that method is not exposed.
304
+
305
+ The `ModalWrapper` contract:
306
+
307
+ ```ts
308
+ interface ModalWrapperRef {
309
+ handleCloseWithAnimation: () => void;
310
+ }
311
+
312
+ interface ModalWrapperProps {
313
+ modalName?: string;
314
+ isOpen: boolean;
315
+ onClose: () => void;
316
+ children?: ReactNode;
317
+ }
318
+
319
+ type ModalWrapperComponent = ForwardRefExoticComponent<
320
+ ModalWrapperProps & RefAttributes<ModalWrapperRef>
321
+ >;
322
+ ```
323
+
324
+ Modal components themselves receive `ModalProps` (`{ onClose: () => void }`):
325
+
326
+ ```tsx
327
+ import type { FC } from "react";
328
+ import type { ModalProps } from "@/modules/PathRouter";
329
+
330
+ export const TestModal: FC<ModalProps> = ({ onClose }) => (
331
+ <button onClick={onClose}>Close</button>
332
+ );
333
+ ```
334
+
335
+ ### 10. Path normalization (`clearSlash`)
336
+
337
+ All internal `navigate(...)` calls go through `clearSlash`:
338
+
339
+ - collapses repeated slashes (`a//b` → `a/b`);
340
+ - guarantees a single leading slash;
341
+ - strips trailing slashes (root `/` stays as `/`).
342
+
343
+ This keeps the URL canonical regardless of how the caller composed it.
344
+
345
+ ### 11. Search params
346
+
347
+ `PathProvider` derives `searchParams` from `location.search` and exposes four helpers:
348
+
349
+ | Method | Behaviour |
350
+ | -------- | ------------------------------------------------------------------------- |
351
+ | `change` | Merge: `string` value → set the key; `string[]` value → append values. |
352
+ | `set` | Replace each provided key: deletes existing values, then writes new ones. |
353
+ | `delete` | Removes a key entirely. |
354
+ | `clear` | Removes every search param. |
355
+
356
+ All mutations preserve `location.hash`.
357
+
358
+ ---
359
+
360
+ ## Public API of `@/modules/PathRouter`
361
+
362
+ The package exposes only what cannot depend on a concrete config:
363
+
364
+ ### Builders / factory / utilities
365
+
366
+ - `setPage`, `setModal` — config builders.
367
+ - `createPathRouter(config)` — returns `{ PathProvider, PathRouterContainer, usePath, NavLink, getPath, getModal, config }`.
368
+ - `clearSlash` — path normalizer.
369
+
370
+ ### Types — config-independent
371
+
372
+ - `RouterConfig`, `PageData`, `ModalData`
373
+ - `ModalProps` — props passed to a modal component.
374
+ - `ModalState`, `SearchParams`, `SearchParamsState`
375
+ - `ModalWrapperComponent`, `ModalWrapperProps`, `ModalWrapperRef`
376
+ - `BoundPathRouterContainerProps`, `BoundNavLinkProps<C>`, `PathRouter<C>`
377
+
378
+ ### Types — config-dependent (must be parametrised)
379
+
380
+ - `PathNamesOf<C>` — must be used as `PathNamesOf<typeof route>`.
381
+ - `ModalNamesOf<C>` — must be used as `ModalNamesOf<typeof route>`.
382
+
383
+ > `PathProvider`, `PathRouterContainer`, `usePath`, `NavLink`, `getPath`, `getModal` are **deliberately not exported** from the package — obtain them from `createPathRouter(config)`.
384
+ > `PathContextType` is also not re-exported; the typed shape is available via the return type of the factory's `usePath`.
385
+
386
+ ---
387
+
388
+ ## Minimal end-to-end example
389
+
390
+ ```tsx
391
+ import { setPage, setModal, createPathRouter } from "@/modules/PathRouter";
392
+
393
+ const route = {
394
+ pages: {
395
+ "/": setPage({ component: HomePage }),
396
+ add: setPage({ component: AddItemPage }),
397
+ "*": setPage({ redirect: "/" }),
398
+ },
399
+ modals: {
400
+ confirm: setModal({ component: ConfirmModal }),
401
+ },
402
+ } as const;
403
+
404
+ export const {
405
+ PathProvider,
406
+ PathRouterContainer,
407
+ usePath,
408
+ NavLink,
409
+ getPath,
410
+ getModal,
411
+ } = createPathRouter(route);
412
+
413
+ export const App = () => (
414
+ <PathProvider>
415
+ <PathRouterContainer />
416
+ </PathProvider>
417
+ );
418
+
419
+ const SomeButton = () => {
420
+ const { page, modal } = usePath();
421
+ return (
422
+ <button onClick={() => modal.open("confirm")}>
423
+ Open confirm (current page stays: {page.path})
424
+ </button>
425
+ );
426
+ };
427
+ ```
@@ -0,0 +1,139 @@
1
+ import type {
2
+ ComponentType,
3
+ LazyExoticComponent,
4
+ ReactNode,
5
+ RefAttributes,
6
+ } from "react";
7
+ import type { Location, NavigateOptions } from "react-router-dom";
8
+
9
+ /* ───────────────────────── Generic helpers ───────────────────────── */
10
+
11
+ export type NestedKeyOf<ObjectType extends object, StopKey extends string> = {
12
+ [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
13
+ ? Key extends StopKey
14
+ ? never
15
+ : `${Key}` | `${Key}/${NestedKeyOf<ObjectType[Key], StopKey>}`
16
+ : `${Key}`;
17
+ }[keyof ObjectType & (string | number)];
18
+
19
+ /* ───────────────────────── Page / Modal data ───────────────────────── */
20
+
21
+ export type PageData = {
22
+ component?: ComponentType<any> | LazyExoticComponent<ComponentType<any>>;
23
+ redirect?: string;
24
+ } & Record<string, any>;
25
+
26
+ export interface ModalProps {
27
+ onClose: () => void;
28
+ }
29
+
30
+ export interface ModalData {
31
+ component?:
32
+ | ComponentType<ModalProps>
33
+ | LazyExoticComponent<ComponentType<ModalProps>>;
34
+ }
35
+
36
+ export interface PageContent {
37
+ data?: PageData;
38
+ }
39
+
40
+ export interface BreadCrumbsPage {
41
+ [path: string]: ExtendedPage;
42
+ }
43
+
44
+ export type ExtendedPage = BreadCrumbsPage | PageContent;
45
+
46
+ export interface PagesRoute {
47
+ [path: string]: ExtendedPage;
48
+ }
49
+
50
+ export type ModalRoutes = Record<string, ModalData>;
51
+
52
+ /* ───────────────────────── Router config ───────────────────────── */
53
+
54
+ export interface RouterConfig<
55
+ P extends PagesRoute = PagesRoute,
56
+ M extends ModalRoutes = ModalRoutes,
57
+ > {
58
+ pages: P;
59
+ modals?: M;
60
+ }
61
+
62
+ /** Extract typed page paths from a user config. */
63
+ export type PathNamesOf<C extends RouterConfig<any, any>> =
64
+ C extends RouterConfig<infer P, any> ? NestedKeyOf<P, "data"> : string;
65
+
66
+ /** Extract typed modal names from a user config. */
67
+ export type ModalNamesOf<C extends RouterConfig<any, any>> =
68
+ C extends RouterConfig<any, infer M> ? Extract<keyof M, string> : string;
69
+
70
+ /* ───────────────────────── Path context ───────────────────────── */
71
+
72
+ export type SearchParams = Record<string, string[]>;
73
+
74
+ export interface SearchParamsState {
75
+ [key: string]: string | string[];
76
+ }
77
+
78
+ export interface ModalState {
79
+ path: string;
80
+ name?: string;
81
+ breadCrumbs: string[];
82
+ isOpen: boolean;
83
+ }
84
+
85
+ export interface PathContextType<
86
+ C extends RouterConfig<any, any> = RouterConfig<any, any>,
87
+ > {
88
+ page: {
89
+ path: string;
90
+ navigate: (path: PathNamesOf<C>, options?: NavigateOptions) => void;
91
+ isHavePrevHistory: boolean;
92
+ };
93
+ modal: {
94
+ open: (name: ModalNamesOf<C>, breadCrumbs?: string[]) => void;
95
+ close: () => void;
96
+ /** Full modal path including modal name, e.g. "test/sub" */
97
+ path: string;
98
+ name?: string;
99
+ /** Sub-crumbs without modal name */
100
+ breadCrumbs: string[];
101
+ isOpen: boolean;
102
+ };
103
+ searchParams: {
104
+ params: SearchParams;
105
+ /** Merge: string -> set; array -> append */
106
+ change: (searchParams: SearchParamsState) => void;
107
+ /** Replace fully: each key is set to provided value(s) */
108
+ set: (searchParams: SearchParamsState) => void;
109
+ delete: (key: string) => void;
110
+ clear: () => void;
111
+ };
112
+ defaultLocation: Location;
113
+ }
114
+
115
+ /* ───────────────────────── Modal wrapper plugin ───────────────────────── */
116
+
117
+ export interface ModalWrapperRef {
118
+ handleCloseWithAnimation: () => void;
119
+ }
120
+
121
+ export interface ModalWrapperProps {
122
+ modalName?: string;
123
+ isOpen: boolean;
124
+ onClose: () => void;
125
+ children?: ReactNode;
126
+ }
127
+
128
+ /**
129
+ * Wrapper plugin component.
130
+ *
131
+ * Accepts both a `forwardRef`-wrapped component (the container will call
132
+ * `ref.current.handleCloseWithAnimation()` when available) and a plain
133
+ * function component that simply ignores the ref. `RefAttributes` keeps
134
+ * `ref` as an optional prop, so any `ComponentType<ModalWrapperProps>`
135
+ * structurally satisfies this signature in React 19.
136
+ */
137
+ export type ModalWrapperComponent = ComponentType<
138
+ ModalWrapperProps & RefAttributes<ModalWrapperRef>
139
+ >;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Normalize path:
3
+ * - collapse internal "//" sequences to a single "/"
4
+ * - guarantee a single leading "/"
5
+ * - strip trailing "/" (root "/" stays as "/")
6
+ */
7
+ export const clearSlash = (path: string): string => {
8
+ if (!path) return "/";
9
+
10
+ let p = path.replace(/\/{2,}/g, "/");
11
+
12
+ if (!p.startsWith("/")) p = "/" + p;
13
+ if (p.length > 1 && p.endsWith("/")) p = p.replace(/\/+$/, "");
14
+
15
+ return p;
16
+ };
@@ -0,0 +1,53 @@
1
+ import {
2
+ type ExtendedPage,
3
+ type ModalRoutes,
4
+ type PageData,
5
+ type PagesRoute,
6
+ type RouterConfig,
7
+ } from "../types";
8
+
9
+ const objectIsEmpty = (obj: Record<string, any>) =>
10
+ Object.keys(obj).length === 0;
11
+
12
+ /**
13
+ * Flatten a nested page config into a list of `{ pathName, data }`,
14
+ * and produce a flat list for modals as well.
15
+ */
16
+ export const createRoute = (config: RouterConfig<any, any>) => {
17
+ const pagesRoutes: { pathName: string; data: PageData }[] = [];
18
+
19
+ const walk = (route: PagesRoute | ExtendedPage, currentPath: string) => {
20
+ Object.entries(route).forEach(([pathName, content]: [string, any]) => {
21
+ if (content?.data) {
22
+ pagesRoutes.push({
23
+ pathName: currentPath + pathName,
24
+ data: { ...content.data } as PageData,
25
+ });
26
+ }
27
+
28
+ if (
29
+ content &&
30
+ !objectIsEmpty(content) &&
31
+ !content.component &&
32
+ !content.redirect
33
+ ) {
34
+ const newPath = currentPath + pathName;
35
+ walk(
36
+ content,
37
+ newPath + (newPath[newPath.length - 1] !== "/" ? "/" : ""),
38
+ );
39
+ }
40
+ });
41
+ };
42
+
43
+ walk(config.pages, "");
44
+
45
+ const modalsList = Object.entries(
46
+ (config.modals || {}) as ModalRoutes,
47
+ ).map(([pathName, data]) => ({ pathName, data }));
48
+
49
+ return {
50
+ pages: pagesRoutes,
51
+ modals: modalsList,
52
+ };
53
+ };
@@ -0,0 +1,4 @@
1
+ export * from "./clearSlash";
2
+ export * from "./parseSearch";
3
+ export * from "./setters";
4
+ export * from "./createRoute";
@@ -0,0 +1,15 @@
1
+ import { type Location } from "react-router-dom";
2
+ import { type SearchParamsState } from "../types";
3
+
4
+ export const parseSearchParams = (
5
+ searchParams: SearchParamsState,
6
+ location: Location,
7
+ ) => {
8
+ const params = new URLSearchParams(location.search);
9
+ Object.entries(searchParams).forEach(([key, values]) => {
10
+ if (typeof values === "string") params.set(key, values);
11
+ else values.forEach((value) => params.append(key, value));
12
+ });
13
+
14
+ return params;
15
+ };
@@ -0,0 +1,8 @@
1
+ import type { ModalData, PageData } from "../types";
2
+ export * from "./createRoute";
3
+
4
+ /** Wrap a page descriptor so the route tree can detect leaves via `data`. */
5
+ export const setPage = <const T extends PageData>(data: T) => ({ data });
6
+
7
+ /** Identity helper that gives nice inference for modal descriptors. */
8
+ export const setModal = <const T extends ModalData>(data: T): T => data;
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./PathRouter";
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "jsx": "react-jsx",
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true
16
+ },
17
+ "include": ["src"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }