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.
- package/README.md +427 -0
- package/dist/PathRouter/Container/ModalsContainer.d.ts +17 -0
- package/dist/PathRouter/Container/ModalsContainer.d.ts.map +1 -0
- package/dist/PathRouter/Container/ModalsContainer.js +39 -0
- package/dist/PathRouter/Container/ModalsContainer.js.map +1 -0
- package/dist/PathRouter/Container/RouterContainer.d.ts +16 -0
- package/dist/PathRouter/Container/RouterContainer.d.ts.map +1 -0
- package/dist/PathRouter/Container/RouterContainer.js +20 -0
- package/dist/PathRouter/Container/RouterContainer.js.map +1 -0
- package/dist/PathRouter/Container/index.d.ts +2 -0
- package/dist/PathRouter/Container/index.d.ts.map +1 -0
- package/dist/PathRouter/Container/index.js +18 -0
- package/dist/PathRouter/Container/index.js.map +1 -0
- package/dist/PathRouter/NavLink/NavLink.d.ts +40 -0
- package/dist/PathRouter/NavLink/NavLink.d.ts.map +1 -0
- package/dist/PathRouter/NavLink/NavLink.js +67 -0
- package/dist/PathRouter/NavLink/NavLink.js.map +1 -0
- package/dist/PathRouter/NavLink/index.d.ts +3 -0
- package/dist/PathRouter/NavLink/index.d.ts.map +1 -0
- package/dist/PathRouter/NavLink/index.js +6 -0
- package/dist/PathRouter/NavLink/index.js.map +1 -0
- package/dist/PathRouter/Provider/PathProvider.d.ts +10 -0
- package/dist/PathRouter/Provider/PathProvider.d.ts.map +1 -0
- package/dist/PathRouter/Provider/PathProvider.js +158 -0
- package/dist/PathRouter/Provider/PathProvider.js.map +1 -0
- package/dist/PathRouter/Provider/context.d.ts +3 -0
- package/dist/PathRouter/Provider/context.d.ts.map +1 -0
- package/dist/PathRouter/Provider/context.js +34 -0
- package/dist/PathRouter/Provider/context.js.map +1 -0
- package/dist/PathRouter/Provider/index.d.ts +3 -0
- package/dist/PathRouter/Provider/index.d.ts.map +1 -0
- package/dist/PathRouter/Provider/index.js +19 -0
- package/dist/PathRouter/Provider/index.js.map +1 -0
- package/dist/PathRouter/Provider/usePath.d.ts +15 -0
- package/dist/PathRouter/Provider/usePath.d.ts.map +1 -0
- package/dist/PathRouter/Provider/usePath.js +22 -0
- package/dist/PathRouter/Provider/usePath.js.map +1 -0
- package/dist/PathRouter/createPathRouter.d.ts +52 -0
- package/dist/PathRouter/createPathRouter.d.ts.map +1 -0
- package/dist/PathRouter/createPathRouter.js +72 -0
- package/dist/PathRouter/createPathRouter.js.map +1 -0
- package/dist/PathRouter/index.d.ts +41 -0
- package/dist/PathRouter/index.d.ts.map +1 -0
- package/dist/PathRouter/index.js +49 -0
- package/dist/PathRouter/index.js.map +1 -0
- package/dist/PathRouter/types.d.ts +91 -0
- package/dist/PathRouter/types.d.ts.map +1 -0
- package/dist/PathRouter/types.js +3 -0
- package/dist/PathRouter/types.js.map +1 -0
- package/dist/PathRouter/utils/clearSlash.d.ts +8 -0
- package/dist/PathRouter/utils/clearSlash.d.ts.map +1 -0
- package/dist/PathRouter/utils/clearSlash.js +21 -0
- package/dist/PathRouter/utils/clearSlash.js.map +1 -0
- package/dist/PathRouter/utils/createRoute.d.ts +16 -0
- package/dist/PathRouter/utils/createRoute.d.ts.map +1 -0
- package/dist/PathRouter/utils/createRoute.js +36 -0
- package/dist/PathRouter/utils/createRoute.js.map +1 -0
- package/dist/PathRouter/utils/index.d.ts +5 -0
- package/dist/PathRouter/utils/index.d.ts.map +1 -0
- package/dist/PathRouter/utils/index.js +21 -0
- package/dist/PathRouter/utils/index.js.map +1 -0
- package/dist/PathRouter/utils/parseSearch.d.ts +4 -0
- package/dist/PathRouter/utils/parseSearch.d.ts.map +1 -0
- package/dist/PathRouter/utils/parseSearch.js +15 -0
- package/dist/PathRouter/utils/parseSearch.js.map +1 -0
- package/dist/PathRouter/utils/setters.d.ts +9 -0
- package/dist/PathRouter/utils/setters.d.ts.map +1 -0
- package/dist/PathRouter/utils/setters.js +25 -0
- package/dist/PathRouter/utils/setters.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/package.json +32 -0
- package/src/PathRouter/Container/ModalsContainer.tsx +92 -0
- package/src/PathRouter/Container/RouterContainer.tsx +66 -0
- package/src/PathRouter/Container/index.ts +1 -0
- package/src/PathRouter/NavLink/NavLink.tsx +146 -0
- package/src/PathRouter/NavLink/index.ts +2 -0
- package/src/PathRouter/Provider/PathProvider.tsx +220 -0
- package/src/PathRouter/Provider/context.ts +33 -0
- package/src/PathRouter/Provider/index.ts +2 -0
- package/src/PathRouter/Provider/usePath.ts +21 -0
- package/src/PathRouter/createPathRouter.tsx +104 -0
- package/src/PathRouter/index.ts +79 -0
- package/src/PathRouter/readme.md +427 -0
- package/src/PathRouter/types.ts +139 -0
- package/src/PathRouter/utils/clearSlash.ts +16 -0
- package/src/PathRouter/utils/createRoute.ts +53 -0
- package/src/PathRouter/utils/index.ts +4 -0
- package/src/PathRouter/utils/parseSearch.ts +15 -0
- package/src/PathRouter/utils/setters.ts +8 -0
- package/src/index.ts +1 -0
- 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,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
|
+
}
|