@cfasim-ui/shared 0.3.6 → 0.3.7
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/package.json +4 -1
- package/src/index.ts +8 -0
- package/src/useUrlParams.test.ts +361 -0
- package/src/useUrlParams.ts +296 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfasim-ui/shared",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared utilities for cfasim-ui",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -17,5 +17,8 @@
|
|
|
17
17
|
],
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"vue": "^3.5.0"
|
|
20
23
|
}
|
|
21
24
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,3 +14,11 @@ export type {
|
|
|
14
14
|
ModelOutputsWire,
|
|
15
15
|
} from "./ModelOutput.js";
|
|
16
16
|
export { modelOutputToCSV } from "./csv.js";
|
|
17
|
+
export {
|
|
18
|
+
useUrlParams,
|
|
19
|
+
serialize,
|
|
20
|
+
deserialize,
|
|
21
|
+
paramsToQuery,
|
|
22
|
+
queryToParams,
|
|
23
|
+
} from "./useUrlParams.js";
|
|
24
|
+
export type { UrlParamsOptions } from "./useUrlParams.js";
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { defineComponent, h, ref, reactive, nextTick } from "vue";
|
|
3
|
+
import { mount } from "@vue/test-utils";
|
|
4
|
+
import {
|
|
5
|
+
serialize,
|
|
6
|
+
deserialize,
|
|
7
|
+
paramsToQuery,
|
|
8
|
+
queryToParams,
|
|
9
|
+
useUrlParams,
|
|
10
|
+
type UrlParamsRouter,
|
|
11
|
+
type UrlParamsRoute,
|
|
12
|
+
} from "./useUrlParams.js";
|
|
13
|
+
|
|
14
|
+
describe("serialize", () => {
|
|
15
|
+
it("stringifies numbers and booleans", () => {
|
|
16
|
+
expect(serialize(42)).toBe("42");
|
|
17
|
+
expect(serialize(true)).toBe("true");
|
|
18
|
+
expect(serialize(false)).toBe("false");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("joins arrays with commas", () => {
|
|
22
|
+
expect(serialize([1, 2, 3])).toBe("1,2,3");
|
|
23
|
+
expect(serialize(["a", "b"])).toBe("a,b");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("deserialize", () => {
|
|
28
|
+
it("coerces based on default type", () => {
|
|
29
|
+
expect(deserialize("true", false)).toBe(true);
|
|
30
|
+
expect(deserialize("false", true)).toBe(false);
|
|
31
|
+
expect(deserialize("3.14", 0)).toBe(3.14);
|
|
32
|
+
expect(deserialize("hello", "x")).toBe("hello");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("falls back to default on NaN", () => {
|
|
36
|
+
expect(deserialize("not-a-number", 7)).toBe(7);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("parses comma-separated arrays of numbers", () => {
|
|
40
|
+
expect(deserialize("1,2,3", [0])).toEqual([1, 2, 3]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps non-numeric array entries as strings", () => {
|
|
44
|
+
expect(deserialize("a,b,c", [""])).toEqual(["a", "b", "c"]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("paramsToQuery", () => {
|
|
49
|
+
it("omits values that match defaults", () => {
|
|
50
|
+
const defaults = { a: 1, b: 2, c: 3 };
|
|
51
|
+
expect(paramsToQuery({ a: 1, b: 2, c: 3 }, defaults)).toEqual({});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("includes only changed values", () => {
|
|
55
|
+
const defaults = { a: 1, b: 2, c: 3 };
|
|
56
|
+
expect(paramsToQuery({ a: 1, b: 5, c: 3 }, defaults)).toEqual({ b: "5" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("serializes arrays", () => {
|
|
60
|
+
const defaults = { k: [1, 2] };
|
|
61
|
+
expect(paramsToQuery({ k: [3, 4] }, defaults)).toEqual({ k: "3,4" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles booleans", () => {
|
|
65
|
+
const defaults = { on: false };
|
|
66
|
+
expect(paramsToQuery({ on: true }, defaults)).toEqual({ on: "true" });
|
|
67
|
+
expect(paramsToQuery({ on: false }, defaults)).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("queryToParams", () => {
|
|
72
|
+
it("returns empty when query has no overlap", () => {
|
|
73
|
+
const defaults = { a: 1 };
|
|
74
|
+
expect(queryToParams({ unrelated: "x" }, defaults)).toEqual({});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("ignores unknown keys", () => {
|
|
78
|
+
const defaults = { a: 1 };
|
|
79
|
+
expect(queryToParams({ a: "2", b: "3" }, defaults)).toEqual({ a: 2 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("ignores non-string query values", () => {
|
|
83
|
+
const defaults = { a: 1 };
|
|
84
|
+
expect(queryToParams({ a: ["2", "3"] }, defaults)).toEqual({});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("coerces types from defaults", () => {
|
|
88
|
+
const defaults = { n: 0, b: false, s: "", arr: [0] };
|
|
89
|
+
expect(
|
|
90
|
+
queryToParams({ n: "5", b: "true", s: "hi", arr: "1,2" }, defaults),
|
|
91
|
+
).toEqual({ n: 5, b: true, s: "hi", arr: [1, 2] });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function makeRouterStub(initialQuery: Record<string, unknown> = {}) {
|
|
96
|
+
const route = reactive<UrlParamsRoute>({ query: { ...initialQuery } });
|
|
97
|
+
const replace = vi.fn(({ query }: { query: Record<string, string> }) => {
|
|
98
|
+
route.query = { ...query };
|
|
99
|
+
});
|
|
100
|
+
const router: UrlParamsRouter = { replace };
|
|
101
|
+
return { router, route, replace };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function mountWith<R>(factory: () => R) {
|
|
105
|
+
let api!: R;
|
|
106
|
+
const wrapper = mount(
|
|
107
|
+
defineComponent({
|
|
108
|
+
setup() {
|
|
109
|
+
api = factory();
|
|
110
|
+
return () => h("div");
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
return { wrapper, api: api as R };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe("useUrlParams (composable)", () => {
|
|
118
|
+
it("hydrates a reactive params object from initial query", async () => {
|
|
119
|
+
const { router, route } = makeRouterStub({ a: "5" });
|
|
120
|
+
const params = reactive({ a: 1, b: 2 });
|
|
121
|
+
mountWith(() =>
|
|
122
|
+
useUrlParams(
|
|
123
|
+
params,
|
|
124
|
+
{ a: 1, b: 2 },
|
|
125
|
+
{
|
|
126
|
+
router,
|
|
127
|
+
route,
|
|
128
|
+
debounceMs: 0,
|
|
129
|
+
},
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
await nextTick();
|
|
133
|
+
expect(params.a).toBe(5);
|
|
134
|
+
expect(params.b).toBe(2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("hydrates a ref params object from initial query", async () => {
|
|
138
|
+
const { router, route } = makeRouterStub({ a: "9" });
|
|
139
|
+
const params = ref({ a: 1, b: 2 });
|
|
140
|
+
mountWith(() =>
|
|
141
|
+
useUrlParams(
|
|
142
|
+
params,
|
|
143
|
+
{ a: 1, b: 2 },
|
|
144
|
+
{
|
|
145
|
+
router,
|
|
146
|
+
route,
|
|
147
|
+
debounceMs: 0,
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
await nextTick();
|
|
152
|
+
expect(params.value).toEqual({ a: 9, b: 2 });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("writes changed params back to the URL", async () => {
|
|
156
|
+
const { router, route, replace } = makeRouterStub();
|
|
157
|
+
const params = reactive({ a: 1, b: 2 });
|
|
158
|
+
mountWith(() =>
|
|
159
|
+
useUrlParams(
|
|
160
|
+
params,
|
|
161
|
+
{ a: 1, b: 2 },
|
|
162
|
+
{
|
|
163
|
+
router,
|
|
164
|
+
route,
|
|
165
|
+
debounceMs: 0,
|
|
166
|
+
},
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
await nextTick();
|
|
170
|
+
params.a = 7;
|
|
171
|
+
await nextTick();
|
|
172
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
173
|
+
expect(replace).toHaveBeenCalled();
|
|
174
|
+
expect(route.query).toEqual({ a: "7" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("include / ignore", () => {
|
|
178
|
+
it("include limits the keys that sync to the URL", async () => {
|
|
179
|
+
const { router, route } = makeRouterStub();
|
|
180
|
+
const params = reactive({ a: 1, b: 2, c: 3 });
|
|
181
|
+
mountWith(() =>
|
|
182
|
+
useUrlParams(
|
|
183
|
+
params,
|
|
184
|
+
{ a: 1, b: 2, c: 3 },
|
|
185
|
+
{ router, route, debounceMs: 0, include: ["a"] },
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
await nextTick();
|
|
189
|
+
params.a = 5;
|
|
190
|
+
params.b = 99;
|
|
191
|
+
await nextTick();
|
|
192
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
193
|
+
expect(route.query).toEqual({ a: "5" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("ignore skips listed keys but syncs the rest", async () => {
|
|
197
|
+
const { router, route } = makeRouterStub();
|
|
198
|
+
const params = reactive({ a: 1, b: 2, c: 3 });
|
|
199
|
+
mountWith(() =>
|
|
200
|
+
useUrlParams(
|
|
201
|
+
params,
|
|
202
|
+
{ a: 1, b: 2, c: 3 },
|
|
203
|
+
{ router, route, debounceMs: 0, ignore: ["c"] },
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
await nextTick();
|
|
207
|
+
params.a = 5;
|
|
208
|
+
params.c = 99;
|
|
209
|
+
await nextTick();
|
|
210
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
211
|
+
expect(route.query).toEqual({ a: "5" });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("include takes precedence over ignore", async () => {
|
|
215
|
+
const { router, route } = makeRouterStub();
|
|
216
|
+
const params = reactive({ a: 1, b: 2, c: 3 });
|
|
217
|
+
mountWith(() =>
|
|
218
|
+
useUrlParams(
|
|
219
|
+
params,
|
|
220
|
+
{ a: 1, b: 2, c: 3 },
|
|
221
|
+
{
|
|
222
|
+
router,
|
|
223
|
+
route,
|
|
224
|
+
debounceMs: 0,
|
|
225
|
+
include: ["a"],
|
|
226
|
+
ignore: ["a"],
|
|
227
|
+
},
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
await nextTick();
|
|
231
|
+
params.a = 5;
|
|
232
|
+
params.b = 99;
|
|
233
|
+
await nextTick();
|
|
234
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
235
|
+
expect(route.query).toEqual({ a: "5" });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("does not hydrate keys outside the include set", async () => {
|
|
239
|
+
const { router, route } = makeRouterStub({ a: "5", b: "99" });
|
|
240
|
+
const params = reactive({ a: 1, b: 2 });
|
|
241
|
+
mountWith(() =>
|
|
242
|
+
useUrlParams(
|
|
243
|
+
params,
|
|
244
|
+
{ a: 1, b: 2 },
|
|
245
|
+
{ router, route, debounceMs: 0, include: ["a"] },
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
await nextTick();
|
|
249
|
+
expect(params.a).toBe(5);
|
|
250
|
+
expect(params.b).toBe(2);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("preserves ignored keys on a reactive params object across hydrate", async () => {
|
|
254
|
+
const { router, route } = makeRouterStub({ a: "5" });
|
|
255
|
+
const params = reactive({ a: 1, ephemeral: "keep-me" });
|
|
256
|
+
mountWith(() =>
|
|
257
|
+
useUrlParams(
|
|
258
|
+
params,
|
|
259
|
+
{ a: 1, ephemeral: "default" },
|
|
260
|
+
{ router, route, debounceMs: 0, ignore: ["ephemeral"] },
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
await nextTick();
|
|
264
|
+
expect(params.a).toBe(5);
|
|
265
|
+
expect(params.ephemeral).toBe("keep-me");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("reset", () => {
|
|
270
|
+
it("resets params to defaults and clears URL by default", async () => {
|
|
271
|
+
const { router, route, replace } = makeRouterStub({ a: "5" });
|
|
272
|
+
const params = reactive({ a: 1, b: 2 });
|
|
273
|
+
const { api } = mountWith(() =>
|
|
274
|
+
useUrlParams(
|
|
275
|
+
params,
|
|
276
|
+
{ a: 1, b: 2 },
|
|
277
|
+
{
|
|
278
|
+
router,
|
|
279
|
+
route,
|
|
280
|
+
debounceMs: 0,
|
|
281
|
+
},
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
await nextTick();
|
|
285
|
+
expect(params.a).toBe(5);
|
|
286
|
+
api.reset();
|
|
287
|
+
await nextTick();
|
|
288
|
+
expect(params).toEqual({ a: 1, b: 2 });
|
|
289
|
+
expect(replace).toHaveBeenLastCalledWith({ query: {} });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("does not touch the URL when clearUrl: false", async () => {
|
|
293
|
+
const { router, route, replace } = makeRouterStub({ a: "5" });
|
|
294
|
+
const params = reactive({ a: 1, b: 2 });
|
|
295
|
+
const { api } = mountWith(() =>
|
|
296
|
+
useUrlParams(
|
|
297
|
+
params,
|
|
298
|
+
{ a: 1, b: 2 },
|
|
299
|
+
{
|
|
300
|
+
router,
|
|
301
|
+
route,
|
|
302
|
+
debounceMs: 0,
|
|
303
|
+
},
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
await nextTick();
|
|
307
|
+
replace.mockClear();
|
|
308
|
+
api.reset({ clearUrl: false });
|
|
309
|
+
await nextTick();
|
|
310
|
+
expect(params).toEqual({ a: 1, b: 2 });
|
|
311
|
+
expect(replace).not.toHaveBeenCalled();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("defaults variants", () => {
|
|
316
|
+
it("accepts a Ref as defaults", async () => {
|
|
317
|
+
const { router, route } = makeRouterStub({ a: "7" });
|
|
318
|
+
const params = reactive({ a: 0 });
|
|
319
|
+
const defaults = ref({ a: 0 });
|
|
320
|
+
mountWith(() =>
|
|
321
|
+
useUrlParams(params, defaults, { router, route, debounceMs: 0 }),
|
|
322
|
+
);
|
|
323
|
+
await nextTick();
|
|
324
|
+
expect(params.a).toBe(7);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("accepts a getter and hydrates lazily once defaults are ready", async () => {
|
|
328
|
+
const { router, route } = makeRouterStub({ a: "7" });
|
|
329
|
+
const params = reactive({ a: 0 });
|
|
330
|
+
let ready: { a: number } | undefined = undefined;
|
|
331
|
+
const { api } = mountWith(() =>
|
|
332
|
+
useUrlParams(params, () => ready, {
|
|
333
|
+
router,
|
|
334
|
+
route,
|
|
335
|
+
debounceMs: 0,
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
await nextTick();
|
|
339
|
+
// getter returned undefined on mount — no hydration yet
|
|
340
|
+
expect(params.a).toBe(0);
|
|
341
|
+
ready = { a: 0 };
|
|
342
|
+
expect(api.hydrate()).toBe(true);
|
|
343
|
+
expect(params.a).toBe(7);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("hydrate() returns false while defaults are unavailable", async () => {
|
|
347
|
+
const { router, route } = makeRouterStub({ a: "7" });
|
|
348
|
+
const params = reactive({ a: 0 });
|
|
349
|
+
const { api } = mountWith(() =>
|
|
350
|
+
useUrlParams(params, () => undefined as { a: number } | undefined, {
|
|
351
|
+
router,
|
|
352
|
+
route,
|
|
353
|
+
debounceMs: 0,
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
await nextTick();
|
|
357
|
+
expect(api.hydrate()).toBe(false);
|
|
358
|
+
expect(params.a).toBe(0);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { isRef, onMounted, onUnmounted, toRaw, watch, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal shape of vue-router's `Router` that `useUrlParams` needs.
|
|
5
|
+
* Typed structurally so `@cfasim-ui/shared` does not depend on vue-router.
|
|
6
|
+
*/
|
|
7
|
+
export interface UrlParamsRouter {
|
|
8
|
+
replace(to: { query: Record<string, string> }): unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal shape of vue-router's `RouteLocationNormalized` that
|
|
13
|
+
* `useUrlParams` reads to hydrate initial state. `query` is typed as
|
|
14
|
+
* `Record<string, unknown>` because vue-router yields `string | string[] |
|
|
15
|
+
* null`; `deserialize` silently ignores non-string entries.
|
|
16
|
+
*/
|
|
17
|
+
export interface UrlParamsRoute {
|
|
18
|
+
query: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Defaults can be:
|
|
23
|
+
* - a plain object (sync, always available),
|
|
24
|
+
* - a Ref<T> (reactive; useful when defaults are computed),
|
|
25
|
+
* - a getter `() => T | undefined` (useful for async loads; return
|
|
26
|
+
* `undefined` until defaults are ready, then call `hydrate()` from
|
|
27
|
+
* the consumer).
|
|
28
|
+
*/
|
|
29
|
+
export type DefaultsInput<T> = T | Ref<T> | (() => T | undefined);
|
|
30
|
+
|
|
31
|
+
export type UrlParamsOptions<T> = {
|
|
32
|
+
debounceMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Optional vue-router integration. Pass `useRouter()` and `useRoute()` from
|
|
35
|
+
* the calling component. When provided, URL reads/writes go through the
|
|
36
|
+
* router so `route.query` stays reactive. When omitted, the composable
|
|
37
|
+
* uses the browser History API directly.
|
|
38
|
+
*/
|
|
39
|
+
router?: UrlParamsRouter;
|
|
40
|
+
route?: UrlParamsRoute;
|
|
41
|
+
/**
|
|
42
|
+
* Only sync these keys. Useful when `defaults` contains labels, flags, or
|
|
43
|
+
* other fields the user never edits.
|
|
44
|
+
*/
|
|
45
|
+
include?: (keyof T)[];
|
|
46
|
+
/**
|
|
47
|
+
* Sync all keys except these. Ignored if `include` is provided.
|
|
48
|
+
*/
|
|
49
|
+
ignore?: (keyof T)[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ResetOptions = {
|
|
53
|
+
/** Whether to clear the URL query in addition to resetting params. Default: true. */
|
|
54
|
+
clearUrl?: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function serialize(value: unknown): string {
|
|
58
|
+
if (Array.isArray(value)) return value.join(",");
|
|
59
|
+
return String(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function deserialize(raw: string, defaultValue: unknown): unknown {
|
|
63
|
+
if (typeof defaultValue === "boolean") return raw === "true";
|
|
64
|
+
if (typeof defaultValue === "number") {
|
|
65
|
+
const n = Number(raw);
|
|
66
|
+
return Number.isNaN(n) ? defaultValue : n;
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(defaultValue)) {
|
|
69
|
+
return raw.split(",").map((s) => {
|
|
70
|
+
const n = Number(s);
|
|
71
|
+
return Number.isNaN(n) ? s : n;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function paramsToQuery<T extends object>(
|
|
78
|
+
params: T,
|
|
79
|
+
defaults: T,
|
|
80
|
+
): Record<string, string> {
|
|
81
|
+
const query: Record<string, string> = {};
|
|
82
|
+
for (const key of Object.keys(defaults) as (keyof T & string)[]) {
|
|
83
|
+
const serialized = serialize(params[key]);
|
|
84
|
+
const defaultSerialized = serialize(defaults[key]);
|
|
85
|
+
if (serialized !== defaultSerialized) {
|
|
86
|
+
query[key] = serialized;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return query;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function queryToParams<T extends object>(
|
|
93
|
+
query: Record<string, unknown>,
|
|
94
|
+
defaults: T,
|
|
95
|
+
): Partial<T> {
|
|
96
|
+
const result: Record<string, unknown> = {};
|
|
97
|
+
const defaultsRecord = defaults as Record<string, unknown>;
|
|
98
|
+
for (const [key, raw] of Object.entries(query)) {
|
|
99
|
+
if (!(key in defaultsRecord)) continue;
|
|
100
|
+
if (typeof raw !== "string") continue;
|
|
101
|
+
result[key] = deserialize(raw, defaultsRecord[key]);
|
|
102
|
+
}
|
|
103
|
+
return result as Partial<T>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readLocationQuery(): Record<string, string> {
|
|
107
|
+
const out: Record<string, string> = {};
|
|
108
|
+
const search = new URLSearchParams(window.location.search);
|
|
109
|
+
for (const [k, v] of search) out[k] = v;
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeLocationQuery(query: Record<string, string>) {
|
|
114
|
+
const params = new URLSearchParams();
|
|
115
|
+
for (const [k, v] of Object.entries(query)) params.set(k, v);
|
|
116
|
+
const qs = params.toString();
|
|
117
|
+
const url =
|
|
118
|
+
window.location.pathname + (qs ? `?${qs}` : "") + window.location.hash;
|
|
119
|
+
// Preserve whatever state object the current history entry holds (vue-router
|
|
120
|
+
// stores its own bookkeeping there) so we don't clobber it.
|
|
121
|
+
window.history.replaceState(window.history.state, "", url);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toGetter<T>(input: DefaultsInput<T>): () => T | undefined {
|
|
125
|
+
if (typeof input === "function") return input as () => T | undefined;
|
|
126
|
+
if (isRef(input)) return () => (input as Ref<T>).value;
|
|
127
|
+
return () => input as T;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function filterKeys<T extends object>(
|
|
131
|
+
obj: T,
|
|
132
|
+
include?: (keyof T)[],
|
|
133
|
+
ignore?: (keyof T)[],
|
|
134
|
+
): T {
|
|
135
|
+
if (!include && !ignore) return obj;
|
|
136
|
+
const src = obj as Record<string, unknown>;
|
|
137
|
+
const out: Record<string, unknown> = {};
|
|
138
|
+
for (const k of Object.keys(src)) {
|
|
139
|
+
const key = k as keyof T;
|
|
140
|
+
if (include) {
|
|
141
|
+
if (include.includes(key)) out[k] = src[k];
|
|
142
|
+
} else if (ignore) {
|
|
143
|
+
if (!ignore.includes(key)) out[k] = src[k];
|
|
144
|
+
} else {
|
|
145
|
+
out[k] = src[k];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out as T;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Syncs a reactive params object with the URL query string. Only values
|
|
153
|
+
* that differ from defaults appear in the URL. Accepts either a `ref()`
|
|
154
|
+
* or a `reactive()` object.
|
|
155
|
+
*
|
|
156
|
+
* For async defaults (e.g. loaded from WASM/network), pass a getter or ref
|
|
157
|
+
* that returns `undefined` until ready, and call `hydrate()` once defaults
|
|
158
|
+
* are available. The composable also attempts hydration on mount; the
|
|
159
|
+
* first successful attempt "locks in" writes going forward.
|
|
160
|
+
*
|
|
161
|
+
* Browser back/forward and external `route.query` changes are picked up
|
|
162
|
+
* automatically (via `popstate` or a route watcher).
|
|
163
|
+
*
|
|
164
|
+
* By default, uses the browser History API directly. Consumers using
|
|
165
|
+
* vue-router can pass `{ router: useRouter(), route: useRoute() }` so that
|
|
166
|
+
* `route.query` stays reactive.
|
|
167
|
+
*/
|
|
168
|
+
export function useUrlParams<T extends object>(
|
|
169
|
+
params: T | Ref<T>,
|
|
170
|
+
defaults: DefaultsInput<T>,
|
|
171
|
+
options: UrlParamsOptions<T> = {},
|
|
172
|
+
) {
|
|
173
|
+
const debounceMs = options.debounceMs ?? 300;
|
|
174
|
+
const { router, route, include, ignore } = options;
|
|
175
|
+
const getDefaults = toGetter<T>(defaults);
|
|
176
|
+
const scopedDefaults = (): T | undefined => {
|
|
177
|
+
const d = getDefaults();
|
|
178
|
+
return d === undefined ? undefined : filterKeys(d, include, ignore);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function readQuery(): Record<string, unknown> {
|
|
182
|
+
if (route) return route.query;
|
|
183
|
+
return readLocationQuery();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function writeQuery(query: Record<string, string>) {
|
|
187
|
+
if (router) {
|
|
188
|
+
router.replace({ query });
|
|
189
|
+
} else {
|
|
190
|
+
writeLocationQuery(query);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function read(): T {
|
|
195
|
+
return isRef(params) ? params.value : params;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function apply(overrides: Partial<T>, d: T) {
|
|
199
|
+
// Layer order (later wins): current params -> defaults -> URL overrides.
|
|
200
|
+
// Starting from current preserves keys outside the sync scope (i.e.
|
|
201
|
+
// those filtered out by `include`/`ignore`), which matters when `params`
|
|
202
|
+
// is a ref and the whole object gets replaced below. `toRaw` unwraps
|
|
203
|
+
// reactive proxies so `structuredClone` doesn't choke.
|
|
204
|
+
const current = toRaw(read());
|
|
205
|
+
const merged = {
|
|
206
|
+
...structuredClone(current),
|
|
207
|
+
...structuredClone(toRaw(d)),
|
|
208
|
+
...overrides,
|
|
209
|
+
} as T;
|
|
210
|
+
if (isRef(params)) {
|
|
211
|
+
params.value = merged;
|
|
212
|
+
} else {
|
|
213
|
+
Object.assign(params, merged);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let hydrated = false;
|
|
218
|
+
|
|
219
|
+
function hydrate(): boolean {
|
|
220
|
+
const d = scopedDefaults();
|
|
221
|
+
if (d === undefined) return false;
|
|
222
|
+
const overrides = queryToParams(readQuery(), d);
|
|
223
|
+
if (Object.keys(overrides).length > 0) apply(overrides, d);
|
|
224
|
+
hydrated = true;
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function syncFromUrl() {
|
|
229
|
+
if (!hydrated) {
|
|
230
|
+
hydrate();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const d = scopedDefaults();
|
|
234
|
+
if (d === undefined) return;
|
|
235
|
+
const overrides = queryToParams(readQuery(), d);
|
|
236
|
+
apply(overrides, d);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
onMounted(() => {
|
|
240
|
+
hydrate();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
244
|
+
|
|
245
|
+
watch(
|
|
246
|
+
() => read(),
|
|
247
|
+
() => {
|
|
248
|
+
if (!hydrated) return;
|
|
249
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
250
|
+
debounceTimer = setTimeout(() => {
|
|
251
|
+
const d = scopedDefaults();
|
|
252
|
+
if (d === undefined) return;
|
|
253
|
+
writeQuery(paramsToQuery(read(), d));
|
|
254
|
+
}, debounceMs);
|
|
255
|
+
},
|
|
256
|
+
{ deep: true },
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// React to external query changes (back/forward, programmatic nav).
|
|
260
|
+
let stopRouteWatch: (() => void) | null = null;
|
|
261
|
+
function onPopState() {
|
|
262
|
+
syncFromUrl();
|
|
263
|
+
}
|
|
264
|
+
onMounted(() => {
|
|
265
|
+
if (route) {
|
|
266
|
+
stopRouteWatch = watch(
|
|
267
|
+
() => route.query,
|
|
268
|
+
() => syncFromUrl(),
|
|
269
|
+
{ deep: true },
|
|
270
|
+
);
|
|
271
|
+
} else {
|
|
272
|
+
window.addEventListener("popstate", onPopState);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
onUnmounted(() => {
|
|
276
|
+
if (stopRouteWatch) stopRouteWatch();
|
|
277
|
+
else window.removeEventListener("popstate", onPopState);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
function reset(opts: ResetOptions = {}) {
|
|
281
|
+
const { clearUrl = true } = opts;
|
|
282
|
+
const d = scopedDefaults();
|
|
283
|
+
if (d === undefined) return;
|
|
284
|
+
apply({}, d);
|
|
285
|
+
if (clearUrl) {
|
|
286
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
287
|
+
writeQuery({});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
onUnmounted(() => {
|
|
292
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return { reset, hydrate };
|
|
296
|
+
}
|