void-snippets-monorepo 0.1.1
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 +2261 -0
- package/package.json +18 -0
- package/packages/client/package.json +47 -0
- package/packages/client/src/configure.ts +34 -0
- package/packages/client/src/index.ts +4 -0
- package/packages/client/src/services/base-api.service.ts +26 -0
- package/packages/client/src/services/resource-api.service.ts +117 -0
- package/packages/client/src/utils/handle-api-error.ts +20 -0
- package/packages/client/tsconfig.json +13 -0
- package/packages/client/tsup.config.ts +10 -0
- package/packages/core/package.json +41 -0
- package/packages/core/src/id.ts +19 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/string-to-id.ts +22 -0
- package/packages/core/src/types/index.ts +86 -0
- package/packages/core/src/utils/catch-error.ts +20 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/core/tsup.config.ts +9 -0
- package/packages/react/package.json +80 -0
- package/packages/react/src/hooks/createResourceHooks.ts +872 -0
- package/packages/react/src/hooks/useAlertMessage.ts +45 -0
- package/packages/react/src/hooks/useAsyncState.ts +110 -0
- package/packages/react/src/hooks/useCallTimer.ts +37 -0
- package/packages/react/src/hooks/useModal.ts +71 -0
- package/packages/react/src/hooks/usePagination.ts +57 -0
- package/packages/react/src/index.ts +43 -0
- package/packages/react/src/routing/createRouteContract.ts +483 -0
- package/packages/react/src/socket/createSocketHooks.ts +351 -0
- package/packages/react/tsconfig.json +14 -0
- package/packages/react/tsup.config.ts +10 -0
- package/pnpm-workspace.yaml +2 -0
- package/tsconfig.base.json +12 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { generatePath } from "react-router";
|
|
2
|
+
import { useSearchParams } from "react-router";
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// INTERNAL TYPE UTILITIES
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recursively extracts named path parameters from a route path string using
|
|
10
|
+
* TypeScript template literal inference. Handles both required and optional
|
|
11
|
+
* segments, and composes correctly across nested slashes.
|
|
12
|
+
*
|
|
13
|
+
* '/users/:userId/posts/:postId' → { userId: string | number; postId: string | number }
|
|
14
|
+
* '/files/:path?' → { path?: string | number }
|
|
15
|
+
*/
|
|
16
|
+
type ExtractRouteParams<T extends string> =
|
|
17
|
+
// :param/ — parameter followed by more segments
|
|
18
|
+
T extends `${infer _Start}:${infer Param}/${infer Rest}`
|
|
19
|
+
? (Param extends `${infer P}?`
|
|
20
|
+
? { [K in P]?: string | number } // optional param
|
|
21
|
+
: { [K in Param]: string | number }) // required param
|
|
22
|
+
& ExtractRouteParams<`/${Rest}`> // recurse into the remaining path
|
|
23
|
+
// :param — parameter at the end of the path
|
|
24
|
+
: T extends `${infer _Start}:${infer Param}`
|
|
25
|
+
? Param extends `${infer P}?`
|
|
26
|
+
? { [K in P]?: string | number } // optional terminal param
|
|
27
|
+
: { [K in Param]: string | number } // required terminal param
|
|
28
|
+
: {}; // no params
|
|
29
|
+
|
|
30
|
+
/** Collapses intersection types into a single object for cleaner IDE tooltips. */
|
|
31
|
+
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
32
|
+
|
|
33
|
+
/** All named path parameters for a given route path literal. */
|
|
34
|
+
type RouteParams<Path extends string> = Prettify<ExtractRouteParams<Path>>;
|
|
35
|
+
|
|
36
|
+
/** True when the path string contains at least one named parameter. */
|
|
37
|
+
type HasParams<Path extends string> =
|
|
38
|
+
keyof RouteParams<Path> extends never ? false : true;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True when Search is the `never` type — meaning no search params were
|
|
42
|
+
* declared on this route. Array wrapping avoids distributive evaluation.
|
|
43
|
+
*/
|
|
44
|
+
type SearchIsAbsent<Search> = [Search] extends [never] ? true : false;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* True when Search has at least one required (non-optional) key.
|
|
48
|
+
* { page: number; sort?: string } → true (page is required)
|
|
49
|
+
* { sort?: string } → false (all optional)
|
|
50
|
+
* never → false
|
|
51
|
+
*/
|
|
52
|
+
type HasRequiredSearchKeys<Search> =
|
|
53
|
+
[Search] extends [never] ? false : {} extends Search ? false : true;
|
|
54
|
+
|
|
55
|
+
// ---- Build option part types ------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** Params portion of the build() argument. Empty object when path has no params. */
|
|
58
|
+
type ParamsPart<Path extends string> =
|
|
59
|
+
HasParams<Path> extends true ? { params: RouteParams<Path> } : {};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Search portion of the build() argument.
|
|
63
|
+
* Absent when Search is never.
|
|
64
|
+
* Required when Search has at least one required key.
|
|
65
|
+
* Optional when all Search keys are optional.
|
|
66
|
+
*/
|
|
67
|
+
type SearchPart<Search> =
|
|
68
|
+
SearchIsAbsent<Search> extends true
|
|
69
|
+
? {}
|
|
70
|
+
: HasRequiredSearchKeys<Search> extends true
|
|
71
|
+
? { search: Search }
|
|
72
|
+
: { search?: Search };
|
|
73
|
+
|
|
74
|
+
/** Full combined build() options, flattened for IDE readability. */
|
|
75
|
+
type BuildArgs<Path extends string, Search> = Prettify<
|
|
76
|
+
ParamsPart<Path> & SearchPart<Search>
|
|
77
|
+
>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* True when build() can be called with zero arguments:
|
|
81
|
+
* no path params AND (no search OR all search keys are optional).
|
|
82
|
+
*/
|
|
83
|
+
type ArgIsOptional<Path extends string, Search> =
|
|
84
|
+
HasParams<Path> extends true
|
|
85
|
+
? false // path params are always required
|
|
86
|
+
: SearchIsAbsent<Search> extends true
|
|
87
|
+
? true // no search at all
|
|
88
|
+
: HasRequiredSearchKeys<Search> extends true
|
|
89
|
+
? false // a required search key exists
|
|
90
|
+
: true; // search exists but all keys are optional
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* True when BuildArgs produces an empty object — meaning build()
|
|
94
|
+
* takes no arguments whatsoever (no params, no search).
|
|
95
|
+
*/
|
|
96
|
+
type BuildArgIsEmpty<Path extends string, Search> =
|
|
97
|
+
HasParams<Path> extends false
|
|
98
|
+
? SearchIsAbsent<Search> extends true
|
|
99
|
+
? true
|
|
100
|
+
: false
|
|
101
|
+
: false;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The fully conditioned build() function signature.
|
|
105
|
+
*
|
|
106
|
+
* Four cases:
|
|
107
|
+
* no params + no search → () => string
|
|
108
|
+
* no params + all-optional search → (options?: { search?: S }) => string
|
|
109
|
+
* params (+ optional search) → (options: { params: P; search?: S }) => string
|
|
110
|
+
* required search key → (options: { search: S }) => string
|
|
111
|
+
*/
|
|
112
|
+
type BuildFn<Path extends string, Search> =
|
|
113
|
+
BuildArgIsEmpty<Path, Search> extends true
|
|
114
|
+
? () => string
|
|
115
|
+
: ArgIsOptional<Path, Search> extends true
|
|
116
|
+
? (options?: BuildArgs<Path, Search>) => string
|
|
117
|
+
: (options: BuildArgs<Path, Search>) => string;
|
|
118
|
+
|
|
119
|
+
// ============================================================================
|
|
120
|
+
// PUBLIC TYPES
|
|
121
|
+
// ============================================================================
|
|
122
|
+
|
|
123
|
+
/** Optional metadata that can be attached to any route definition. */
|
|
124
|
+
export interface RouteMetadata {
|
|
125
|
+
/**
|
|
126
|
+
* Permission identifiers required to access this route.
|
|
127
|
+
* Read via the `handle` property in your React Router config.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* permissions: ['ADMIN', 'SUPER_ADMIN']
|
|
131
|
+
*/
|
|
132
|
+
permissions?: string[];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Human-readable label used for breadcrumb navigation.
|
|
136
|
+
* Read via the `handle` property in your React Router config.
|
|
137
|
+
*/
|
|
138
|
+
breadcrumb?: string;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Document title for the page.
|
|
142
|
+
* Read via the `handle` property in your React Router config.
|
|
143
|
+
*/
|
|
144
|
+
title?: string;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Arbitrary custom metadata — analytics tags, loader IDs, feature flags, etc.
|
|
148
|
+
* Read via the `handle` property in your React Router config.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* meta: { analyticsId: 'user-detail-view', loaderKey: 'userLoader' }
|
|
152
|
+
*/
|
|
153
|
+
meta?: Record<string, unknown>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The intermediate type returned by `defineRoute()`.
|
|
158
|
+
* Consumed directly by `createRouteContract()` — you do not use this type
|
|
159
|
+
* directly in application code.
|
|
160
|
+
*
|
|
161
|
+
* @internal
|
|
162
|
+
*/
|
|
163
|
+
export type RouteDefinition<
|
|
164
|
+
Path extends string = string,
|
|
165
|
+
Search = never,
|
|
166
|
+
> = RouteMetadata & {
|
|
167
|
+
/** The absolute path string for this route. */
|
|
168
|
+
readonly path: Path;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Phantom type anchor — carries the Search type for downstream inference.
|
|
172
|
+
* Always `undefined` at runtime. Do not read or write this directly.
|
|
173
|
+
* @internal
|
|
174
|
+
*/
|
|
175
|
+
readonly _search: Search;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Declares typed search parameters for this route. Chain immediately after
|
|
179
|
+
* `defineRoute()`. The generic type argument is the only input — no value
|
|
180
|
+
* needs to be passed.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* defineRoute('/users').search<{ page: number; sort?: 'asc' | 'desc' }>()
|
|
184
|
+
*/
|
|
185
|
+
search<S>(): RouteDefinition<Path, S>;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A fully processed route node produced by `createRouteContract()`.
|
|
190
|
+
* This is the type you interact with throughout the application.
|
|
191
|
+
*/
|
|
192
|
+
export type ProcessedRoute<
|
|
193
|
+
Path extends string = string,
|
|
194
|
+
Search = never,
|
|
195
|
+
> = RouteMetadata & {
|
|
196
|
+
/** The absolute path string — use this in `createBrowserRouter`. */
|
|
197
|
+
readonly path: Path;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Phantom type anchor — used by `useTypedSearchParams` to infer `Search`.
|
|
201
|
+
* Always `undefined` at runtime. Do not read or write this directly.
|
|
202
|
+
* @internal
|
|
203
|
+
*/
|
|
204
|
+
readonly _search: Search;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Builds a fully qualified URL for this route.
|
|
208
|
+
*
|
|
209
|
+
* TypeScript enforces at compile time that:
|
|
210
|
+
* - `params` is provided and fully satisfied when the path has dynamic segments.
|
|
211
|
+
* - `search` matches the exact shape declared via `.search<T>()`.
|
|
212
|
+
* - No argument is needed for routes with neither params nor required search.
|
|
213
|
+
*/
|
|
214
|
+
readonly build: BuildFn<Path, Search>;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** The input shape `createRouteContract` accepts. */
|
|
218
|
+
type RouteTree = {
|
|
219
|
+
[K: string]: RouteDefinition<string, any> | RouteTree;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Maps a RouteTree recursively to a tree of ProcessedRoutes. */
|
|
223
|
+
type ProcessedTree<T> = {
|
|
224
|
+
[K in keyof T]: T[K] extends RouteDefinition<infer Path, infer Search>
|
|
225
|
+
? ProcessedRoute<Path, Search>
|
|
226
|
+
: T[K] extends RouteTree
|
|
227
|
+
? ProcessedTree<T[K]>
|
|
228
|
+
: never;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// RUNTIME: TYPE GUARD
|
|
233
|
+
// ============================================================================
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Differentiates a RouteDefinition leaf from a plain route group object.
|
|
237
|
+
* Checks for the three properties that only `defineRoute()` produces:
|
|
238
|
+
* `path` (string), `_search` (phantom), and `search` (type-setting method).
|
|
239
|
+
*/
|
|
240
|
+
function isRouteDefinition(node: unknown): node is RouteDefinition {
|
|
241
|
+
if (node === null || typeof node !== "object") return false;
|
|
242
|
+
const n = node as Record<string, unknown>;
|
|
243
|
+
return (
|
|
244
|
+
typeof n["path"] === "string" &&
|
|
245
|
+
"_search" in n &&
|
|
246
|
+
typeof n["search"] === "function"
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ============================================================================
|
|
251
|
+
// RUNTIME: defineRoute
|
|
252
|
+
// ============================================================================
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Defines a single route. Pass directly to `createRouteContract()`.
|
|
256
|
+
*
|
|
257
|
+
* The second argument is purely metadata — permissions, breadcrumbs, titles.
|
|
258
|
+
* Chain `.search<SearchType>()` to declare typed search parameters for the route.
|
|
259
|
+
*
|
|
260
|
+
* **Use absolute paths.** Concatenating parent/child paths via template literals
|
|
261
|
+
* causes TypeScript server slowdowns on large apps. Be explicit.
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* // Plain route — no params, no search
|
|
265
|
+
* defineRoute('/dashboard', { breadcrumb: 'Home', title: 'Dashboard' })
|
|
266
|
+
*
|
|
267
|
+
* // Route with typed search params
|
|
268
|
+
* defineRoute('/users', { permissions: ['ADMIN'] })
|
|
269
|
+
* .search<{ page: number; sort?: 'asc' | 'desc' }>()
|
|
270
|
+
*
|
|
271
|
+
* // Route with path params and optional search
|
|
272
|
+
* defineRoute('/users/:userId', { breadcrumb: 'User Detail' })
|
|
273
|
+
* .search<{ tab?: 'profile' | 'settings' }>()
|
|
274
|
+
*
|
|
275
|
+
* // Route with path params, no search
|
|
276
|
+
* defineRoute('/users/:userId/posts/:postId')
|
|
277
|
+
*/
|
|
278
|
+
export function defineRoute<Path extends string>(
|
|
279
|
+
path: Path,
|
|
280
|
+
config?: RouteMetadata,
|
|
281
|
+
): RouteDefinition<Path, never> {
|
|
282
|
+
const definition: RouteDefinition<Path, never> = {
|
|
283
|
+
...config,
|
|
284
|
+
path,
|
|
285
|
+
// Phantom anchor — undefined at runtime, typed as `never` for the base definition.
|
|
286
|
+
// The search<S>() method changes this to `S` at the TypeScript level only.
|
|
287
|
+
_search: undefined as unknown as never,
|
|
288
|
+
search<S>(): RouteDefinition<Path, S> {
|
|
289
|
+
// Pure type-level operation. The runtime object is returned unchanged.
|
|
290
|
+
// TypeScript sees the return type as RouteDefinition<Path, S> and uses
|
|
291
|
+
// that for all downstream generic inference.
|
|
292
|
+
return definition as unknown as RouteDefinition<Path, S>;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
return definition;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ============================================================================
|
|
299
|
+
// RUNTIME: createRouteContract
|
|
300
|
+
// ============================================================================
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Processes a tree of `defineRoute()` definitions into a fully typed contract.
|
|
304
|
+
*
|
|
305
|
+
* Every route leaf gains a `build()` function whose signature is automatically
|
|
306
|
+
* conditioned on the presence of path params and search params. Nested groups
|
|
307
|
+
* are preserved as plain objects. All metadata (`path`, `permissions`,
|
|
308
|
+
* `breadcrumb`, `title`, `meta`) flows through to the output unchanged.
|
|
309
|
+
*
|
|
310
|
+
* Call once at module level and export. Import `AppRoutes` wherever you need
|
|
311
|
+
* to build a URL, wire up the router, or access route metadata.
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* // routes.ts
|
|
315
|
+
* export const AppRoutes = createRouteContract({
|
|
316
|
+
* auth: {
|
|
317
|
+
* login: defineRoute('/auth/login').search<{ redirect?: string }>(),
|
|
318
|
+
* register: defineRoute('/auth/register'),
|
|
319
|
+
* },
|
|
320
|
+
* dashboard: {
|
|
321
|
+
* root: defineRoute('/dashboard', { breadcrumb: 'Home', title: 'Dashboard' }),
|
|
322
|
+
* users: {
|
|
323
|
+
* list: defineRoute('/dashboard/users', {
|
|
324
|
+
* permissions: ['ADMIN'],
|
|
325
|
+
* breadcrumb: 'Users',
|
|
326
|
+
* }).search<{ page: number; sort?: 'asc' | 'desc' }>(),
|
|
327
|
+
* detail: defineRoute('/dashboard/users/:userId', {
|
|
328
|
+
* permissions: ['ADMIN'],
|
|
329
|
+
* breadcrumb: 'User Detail',
|
|
330
|
+
* }).search<{ tab?: 'profile' | 'settings' }>(),
|
|
331
|
+
* },
|
|
332
|
+
* },
|
|
333
|
+
* });
|
|
334
|
+
*/
|
|
335
|
+
export function createRouteContract<T extends RouteTree>(
|
|
336
|
+
tree: T,
|
|
337
|
+
): ProcessedTree<T> {
|
|
338
|
+
const result: Record<string, unknown> = {};
|
|
339
|
+
|
|
340
|
+
for (const key in tree) {
|
|
341
|
+
const node = tree[key];
|
|
342
|
+
|
|
343
|
+
if (isRouteDefinition(node)) {
|
|
344
|
+
// Strip the phantom _search and the type-only search() method.
|
|
345
|
+
// They exist only for TypeScript — the processed node doesn't expose them.
|
|
346
|
+
const {
|
|
347
|
+
_search: _phantom,
|
|
348
|
+
search: _searchFn,
|
|
349
|
+
...metadata
|
|
350
|
+
} = node as RouteDefinition & Record<string, unknown>;
|
|
351
|
+
|
|
352
|
+
result[key] = {
|
|
353
|
+
...metadata,
|
|
354
|
+
// Preserve the phantom anchor on the ProcessedRoute for useTypedSearchParams.
|
|
355
|
+
_search: undefined as unknown as never,
|
|
356
|
+
|
|
357
|
+
build(
|
|
358
|
+
options: {
|
|
359
|
+
params?: Record<string, string | number>;
|
|
360
|
+
search?: Record<string, unknown>;
|
|
361
|
+
} = {},
|
|
362
|
+
): string {
|
|
363
|
+
const { params, search } = options;
|
|
364
|
+
|
|
365
|
+
// 1. Resolve path params via React Router's battle-tested generatePath.
|
|
366
|
+
// Handles :param, :param?, and wildcard segments correctly.
|
|
367
|
+
const pathname: string = params
|
|
368
|
+
? generatePath(
|
|
369
|
+
node.path,
|
|
370
|
+
Object.fromEntries(
|
|
371
|
+
Object.entries(params).map(([k, v]) => [k, String(v)]),
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
: node.path;
|
|
375
|
+
|
|
376
|
+
// 2. Serialize search params into a query string.
|
|
377
|
+
// undefined and null values are silently dropped.
|
|
378
|
+
if (search) {
|
|
379
|
+
const defined = Object.entries(search).filter(
|
|
380
|
+
([, v]) => v !== undefined && v !== null,
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
if (defined.length > 0) {
|
|
384
|
+
const qs = new URLSearchParams(
|
|
385
|
+
defined.map(([k, v]) => [k, String(v)]),
|
|
386
|
+
).toString();
|
|
387
|
+
return `${pathname}?${qs}`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return pathname;
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
} else {
|
|
395
|
+
// Recurse into nested route groups — they are plain objects without
|
|
396
|
+
// `path`, `_search`, or `search`, so isRouteDefinition returns false.
|
|
397
|
+
result[key] = createRouteContract(node as RouteTree);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return result as ProcessedTree<T>;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ============================================================================
|
|
405
|
+
// RUNTIME: useTypedSearchParams
|
|
406
|
+
// ============================================================================
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Returns the current URL search params typed to the shape declared on the
|
|
410
|
+
* route, plus a type-safe setter and a clear function.
|
|
411
|
+
*
|
|
412
|
+
* Pass any processed route that was created with `.search<T>()`. TypeScript
|
|
413
|
+
* infers `T` automatically — no generics needed at the call site.
|
|
414
|
+
*
|
|
415
|
+
* **`setSearch` merges** — it does not replace the entire query string.
|
|
416
|
+
* Pass only the keys you want to change; everything else is preserved.
|
|
417
|
+
* Set a key to `undefined` or `null` to remove it from the URL.
|
|
418
|
+
*
|
|
419
|
+
* ⚠️ **Runtime coercion note:** React Router's `useSearchParams` returns all
|
|
420
|
+
* values as strings. If you declare `page: number`, `search.page` will be
|
|
421
|
+
* the string `"1"` at runtime even though TypeScript types it as `number`.
|
|
422
|
+
* Coerce where needed: `Number(search.page)`. This is a deliberate trade-off
|
|
423
|
+
* that avoids requiring a runtime schema library.
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* // Inside the /dashboard/users page component
|
|
427
|
+
* const { search, setSearch, clearSearch } =
|
|
428
|
+
* useTypedSearchParams(AppRoutes.dashboard.users.list);
|
|
429
|
+
*
|
|
430
|
+
* // search.page is typed as `number | undefined`
|
|
431
|
+
* // but is a string at runtime — coerce explicitly:
|
|
432
|
+
* const page = Number(search.page ?? 1);
|
|
433
|
+
*
|
|
434
|
+
* setSearch({ page: 2 }) // keeps sort, q; updates page
|
|
435
|
+
* setSearch({ page: 1, sort: 'asc' }) // updates page and sort; keeps q
|
|
436
|
+
* setSearch({ sort: undefined }) // removes sort from the URL
|
|
437
|
+
* clearSearch() // wipes all search params
|
|
438
|
+
*/
|
|
439
|
+
export function useTypedSearchParams<P extends string, S>(
|
|
440
|
+
// Only used for TypeScript to infer S from ProcessedRoute<P, S>.
|
|
441
|
+
// The runtime value is not read — the hook uses useSearchParams directly.
|
|
442
|
+
_route: ProcessedRoute<P, S>,
|
|
443
|
+
): {
|
|
444
|
+
/** Current search params, typed as a partial of the declared search shape. */
|
|
445
|
+
readonly search: Readonly<Partial<S>>;
|
|
446
|
+
/**
|
|
447
|
+
* Merges the given partial update into the current search params and
|
|
448
|
+
* pushes a new URL entry. Set a key to `undefined` to remove it.
|
|
449
|
+
*/
|
|
450
|
+
setSearch: (update: Partial<S>) => void;
|
|
451
|
+
/** Removes all search parameters from the URL. */
|
|
452
|
+
clearSearch: () => void;
|
|
453
|
+
} {
|
|
454
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
455
|
+
|
|
456
|
+
// Cast URLSearchParams entries to the declared type.
|
|
457
|
+
// Values are strings at runtime — documented in the JSDoc above.
|
|
458
|
+
const search = Object.fromEntries(searchParams.entries()) as Partial<S>;
|
|
459
|
+
|
|
460
|
+
function setSearch(update: Partial<S>): void {
|
|
461
|
+
setSearchParams((prev) => {
|
|
462
|
+
const next: Record<string, string> = Object.fromEntries(prev.entries());
|
|
463
|
+
|
|
464
|
+
for (const [k, v] of Object.entries(
|
|
465
|
+
update as Record<string, unknown>,
|
|
466
|
+
)) {
|
|
467
|
+
if (v === undefined || v === null) {
|
|
468
|
+
delete next[k];
|
|
469
|
+
} else {
|
|
470
|
+
next[k] = String(v);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return next;
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function clearSearch(): void {
|
|
479
|
+
setSearchParams({});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { search, setSearch, clearSearch } as const;
|
|
483
|
+
}
|