@excom/spa-route 0.1.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 (38) hide show
  1. package/.rush/temp/chunked-rush-logs/spa-route.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/spa-route.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/spa-route.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +5 -0
  15. package/index.css +5 -0
  16. package/index.ts +29 -0
  17. package/package.json +48 -0
  18. package/rush-logs/spa-route.apply-exports.cache.log +1 -0
  19. package/rush-logs/spa-route.apply-exports.log +1 -0
  20. package/rush-logs/spa-route.build_docs.cache.log +1 -0
  21. package/rush-logs/spa-route.build_docs.log +1 -0
  22. package/rush-logs/spa-route.build_package-metas.cache.log +1 -0
  23. package/rush-logs/spa-route.build_package-metas.log +1 -0
  24. package/spa-a.ts +124 -0
  25. package/spa-manager.ts +605 -0
  26. package/spa-route.ts +322 -0
  27. package/src/spa-route.css +24 -0
  28. package/src/utils.ts +31 -0
  29. package/support/custom-elements.json +953 -0
  30. package/support/dist-docs/spa-a.md +40 -0
  31. package/support/dist-docs/spa-manager.md +66 -0
  32. package/support/dist-docs/spa-route.md +408 -0
  33. package/support/docs/README.md +314 -0
  34. package/support/package-meta.json +789 -0
  35. package/support/tests/spa-navigation.test.ts +1076 -0
  36. package/support/tests/spa-route.test.ts +766 -0
  37. package/support/tests/spa-title.test.ts +225 -0
  38. package/tsconfig.json +5 -0
@@ -0,0 +1,1076 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ vi,
8
+ wait,
9
+ waitForEvent,
10
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
11
+ import { urlMatchesHref } from "../../src/utils";
12
+ import "../../index";
13
+ import { KitLogger } from "@excom/kit-logger";
14
+ import { kitRouter } from "@excom/kit-router";
15
+
16
+ if (!document.startViewTransition) {
17
+ /*
18
+ * Shim: like a browser, `finished` settles only after the update
19
+ * callback (exercises spa-manager's render-timeout / allSettled).
20
+ * @ts-ignore shim
21
+ */
22
+ document.startViewTransition = ({ update }) => {
23
+ const done = Promise.resolve(update()).then(() => {});
24
+ return {
25
+ ready: done,
26
+ updateCallbackDone: done,
27
+ finished: done,
28
+ skipTransition: () => {},
29
+ cancel: () => {},
30
+ };
31
+ };
32
+ }
33
+
34
+ type RouterState = {
35
+ id: string;
36
+ url: string;
37
+ title?: string;
38
+ ttypes?: string[];
39
+ isInit?: boolean;
40
+ };
41
+ type RouterInternals = {
42
+ states: RouterState[];
43
+ currentStateId: string | null;
44
+ currentTempData: { move: null | string; event?: unknown };
45
+ routes: unknown[];
46
+ };
47
+ const router = kitRouter as unknown as RouterInternals & typeof kitRouter;
48
+
49
+ /** Put the singleton router back to a cold-load state. */
50
+ const resetRouter = () => {
51
+ history.replaceState(null, "", "/");
52
+ router.states = [{ id: "init", url: "/", isInit: true }];
53
+ router.currentStateId = null;
54
+ router.currentTempData = { move: null };
55
+ router.MAX_STATES = router.DEFAULT_MAX_STATES;
56
+ };
57
+
58
+ /** Emulate the browser landing on a known history entry (back / forward). */
59
+ const popstate = (state: RouterState, hasUAVisualTransition = false) => {
60
+ history.replaceState({ id: state.id }, "", state.url);
61
+ const e = new PopStateEvent("popstate");
62
+ Object.defineProperty(e, "hasUAVisualTransition", {
63
+ value: hasUAVisualTransition,
64
+ });
65
+ window.dispatchEvent(e);
66
+ };
67
+
68
+ const click = (el: Element) => el.dispatchEvent(new Event("click"));
69
+
70
+ /**
71
+ * Navigate and wait for the manager to settle. A cold first paint
72
+ * renders synchronously, but `spa-route-provision` still batches once
73
+ * `has-rendered` flips, so `spa-manager-rendered` follows every nav.
74
+ */
75
+ const navigate = (manager: Element, trigger: () => void) =>
76
+ waitForEvent(manager, "spa-manager-rendered", trigger);
77
+
78
+ const captureEvent = <T = unknown>(target: EventTarget, type: string) => {
79
+ const events: CustomEvent<T>[] = [];
80
+ target.addEventListener(type, (e) => events.push(e as CustomEvent<T>));
81
+ return events;
82
+ };
83
+
84
+ const hasRenderedContent = (route: Element) =>
85
+ Array.from(route.children).some((c) => c.tagName !== "TEMPLATE");
86
+
87
+ const q = <T extends Element>(selector: string) =>
88
+ document.querySelector(selector) as T;
89
+ const qa = <T extends Element>(selector: string) =>
90
+ Array.from(document.querySelectorAll(selector)) as T[];
91
+
92
+ const touch = (
93
+ type: "touchstart" | "touchmove" | "touchend" | "touchcancel",
94
+ touches: Array<{ identifier: number; clientX: number; clientY: number }>
95
+ ) => {
96
+ const e = new Event(type, { cancelable: true, bubbles: true });
97
+ Object.defineProperty(e, "touches", { value: touches });
98
+ document.dispatchEvent(e);
99
+ return e;
100
+ };
101
+
102
+ describe("spa-a actions", () => {
103
+ beforeEach(resetRouter);
104
+ afterEach(() => {
105
+ document.body.innerHTML = "";
106
+ router.routes = [];
107
+ vi.restoreAllMocks();
108
+ resetRouter();
109
+ });
110
+
111
+ it("walks history with route-action back / forward when possible", async () => {
112
+ document.body.innerHTML = `
113
+ <spa-manager>
114
+ <spa-route route-href="/a"><template>A</template></spa-route>
115
+ <spa-route route-href="/b"><template>B</template></spa-route>
116
+ </spa-manager>
117
+ <spa-a route-href="/a"></spa-a>
118
+ <spa-a route-href="/b"></spa-a>
119
+ <spa-a route-action="back"></spa-a>
120
+ <spa-a route-action="forward"></spa-a>
121
+ `;
122
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
123
+ const [routeA, routeB] = qa<HTMLSpaRouteElement>("spa-route");
124
+ const [linkA, linkB, back, forward] = qa<HTMLSpaAElement>("spa-a");
125
+ const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
126
+ const errorSpy = vi
127
+ .spyOn(KitLogger, "error")
128
+ .mockImplementation(() => {});
129
+ const backEvents = captureEvent(manager, "spa-manager-back");
130
+ const forwardEvents = captureEvent(manager, "spa-manager-forward");
131
+
132
+ await navigate(manager, () => click(linkA));
133
+ await navigate(manager, () => click(linkB));
134
+ expect(linkA.wasActive).toBe(true);
135
+ expect(linkB.wasActive).toBe(false);
136
+
137
+ // cannot go forward from the newest entry: logs, does nothing
138
+ click(forward);
139
+ expect(goSpy).not.toHaveBeenCalled();
140
+ expect(errorSpy).toHaveBeenLastCalledWith("spa-a", "Cannot go forward");
141
+
142
+ click(back);
143
+ expect(goSpy).toHaveBeenLastCalledWith(-1);
144
+
145
+ // the browser lands on /a
146
+ await navigate(manager, () => popstate(router.states[1]));
147
+ expect(backEvents).toHaveLength(1);
148
+ expect(manager.lastMove).toBe("back");
149
+ expect(manager.activeUrl).toBe("/a");
150
+ expect(routeA.isActive).toBe(true);
151
+ expect(routeB.isActive).toBe(false);
152
+ expect(routeB.wasActive).toBe(true);
153
+ expect(linkB.wasActive).toBe(true);
154
+ expect(linkA.isActive).toBe(true);
155
+
156
+ click(forward);
157
+ expect(goSpy).toHaveBeenLastCalledWith(1);
158
+ await navigate(manager, () => popstate(router.states[2]));
159
+ expect(forwardEvents).toHaveLength(1);
160
+ expect(manager.lastMove).toBe("forward");
161
+ expect(routeB.isActive).toBe(true);
162
+ expect(routeA.wasActive).toBe(true);
163
+
164
+ // back to the initial entry (no route matches), then back is refused
165
+ await navigate(manager, () => popstate(router.states[0]));
166
+ expect(routeB.isActive).toBe(false);
167
+ expect(hasRenderedContent(routeB)).toBe(false);
168
+ goSpy.mockClear();
169
+ click(back);
170
+ expect(goSpy).not.toHaveBeenCalled();
171
+ expect(errorSpy).toHaveBeenLastCalledWith("spa-a", "Cannot go back");
172
+ });
173
+
174
+ it("falls back to push when back / forward is impossible but route-href is set", async () => {
175
+ document.body.innerHTML = `
176
+ <spa-manager>
177
+ <spa-route route-href="/a"><template>A</template></spa-route>
178
+ <spa-route route-href="/b"><template>B</template></spa-route>
179
+ </spa-manager>
180
+ <spa-a route-action="back" route-href="/a"></spa-a>
181
+ <spa-a route-action="forward" route-href="/b"></spa-a>
182
+ `;
183
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
184
+ const [, routeB] = qa<HTMLSpaRouteElement>("spa-route");
185
+ const [backLink, forwardLink] = qa<HTMLSpaAElement>("spa-a");
186
+ const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
187
+
188
+ await navigate(manager, () => click(backLink));
189
+ expect(goSpy).not.toHaveBeenCalled();
190
+ expect(manager.activeUrl).toBe("/a");
191
+ expect(backLink.isActive).toBe(true);
192
+
193
+ await navigate(manager, () => click(forwardLink));
194
+ expect(goSpy).not.toHaveBeenCalled();
195
+ expect(manager.lastMove).toBe("push");
196
+ expect(routeB.isActive).toBe(true);
197
+ });
198
+
199
+ it("logs when there is nothing to navigate to", () => {
200
+ document.body.innerHTML = `<spa-a></spa-a>`;
201
+ const errorSpy = vi
202
+ .spyOn(KitLogger, "error")
203
+ .mockImplementation(() => {});
204
+ const before = router.states.length;
205
+ click(q("spa-a"));
206
+ expect(errorSpy).toHaveBeenCalledWith("spa-a", "No route-href provided");
207
+ expect(router.states.length).toBe(before);
208
+ });
209
+
210
+ it("replaces the current entry with route-action=replace", async () => {
211
+ document.body.innerHTML = `
212
+ <spa-manager>
213
+ <spa-route route-href="/a"><template>A</template></spa-route>
214
+ <spa-route route-href="/r"><template>R</template></spa-route>
215
+ </spa-manager>
216
+ <spa-a route-href="/a"></spa-a>
217
+ <spa-a route-href="/r" route-action="replace"></spa-a>
218
+ `;
219
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
220
+ const [routeA, routeR] = qa<HTMLSpaRouteElement>("spa-route");
221
+ const [linkA, linkR] = qa<HTMLSpaAElement>("spa-a");
222
+ const replaceEvents = captureEvent(manager, "spa-manager-replace");
223
+
224
+ await navigate(manager, () => click(linkA));
225
+ expect(router.states.map((s) => s.url)).toEqual(["/", "/a"]);
226
+
227
+ await navigate(manager, () => click(linkR));
228
+ expect(replaceEvents).toHaveLength(1);
229
+ expect(router.states.map((s) => s.url)).toEqual(["/", "/r"]);
230
+ expect(manager.lastMove).toBe("replace");
231
+ expect(manager.activeUrl).toBe("/r");
232
+ expect(routeA.isActive).toBe(false);
233
+ expect(routeR.isActive).toBe(true);
234
+ // the replaced entry is gone: the previous entry is now `/`
235
+ expect(routeA.wasActive).toBe(false);
236
+ expect(linkA.wasActive).toBe(false);
237
+ expect(routeR.provision?.previous).toEqual(
238
+ expect.objectContaining({ url: "/" })
239
+ );
240
+ expect(kitRouter.canGoBack()).toBe(true);
241
+ });
242
+
243
+ it("replace as the very first move leaves no previous entry", async () => {
244
+ document.body.innerHTML = `
245
+ <spa-manager>
246
+ <spa-route route-href="/"><template>Home</template></spa-route>
247
+ <spa-route route-href="/x"><template>X</template></spa-route>
248
+ </spa-manager>
249
+ <spa-a route-href="/x" route-action="replace"></spa-a>
250
+ `;
251
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
252
+ const [home, x] = qa<HTMLSpaRouteElement>("spa-route");
253
+ expect(home.isActive).toBe(true);
254
+ await waitForEvent(manager, "spa-manager-rendered");
255
+ expect(home.provision?.move).toBeNull();
256
+ expect(home.provision?.previous).toBeNull();
257
+
258
+ await navigate(manager, () => click(q("spa-a")));
259
+ expect(router.states.map((s) => s.url)).toEqual(["/x"]);
260
+ expect(home.isActive).toBe(false);
261
+ // no previous entry survives a first-move replace
262
+ expect(home.wasActive).toBe(false);
263
+ expect(x.isActive).toBe(true);
264
+ expect(x.provision?.previous).toBeNull();
265
+ });
266
+
267
+ it("passes document-title and transition-types to the router / view transition", async () => {
268
+ document.body.innerHTML = `
269
+ <spa-manager>
270
+ <spa-route route-href="/a"><template>A</template></spa-route>
271
+ <spa-route route-href="/t"><template>T</template></spa-route>
272
+ </spa-manager>
273
+ <spa-a route-href="/a"></spa-a>
274
+ <spa-a route-href="/t" document-title="Title T" transition-types="fade slide"></spa-a>
275
+ `;
276
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
277
+ const [linkA, linkT] = qa<HTMLSpaAElement>("spa-a");
278
+ const transitionSpy = vi.spyOn(document, "startViewTransition");
279
+ const transitionEvents = captureEvent<{ transition: ViewTransition }>(
280
+ manager,
281
+ "spa-manager-transition"
282
+ );
283
+
284
+ await navigate(manager, () => click(linkA));
285
+ transitionSpy.mockClear();
286
+ transitionEvents.length = 0;
287
+
288
+ await navigate(manager, () => click(linkT));
289
+ expect(transitionSpy).toHaveBeenCalledTimes(1);
290
+ expect(transitionEvents).toHaveLength(1);
291
+ expect(transitionEvents[0].detail.transition).toBe(
292
+ transitionSpy.mock.results[0].value
293
+ );
294
+ expect(transitionSpy.mock.calls[0][0]).toEqual(
295
+ expect.objectContaining({ types: ["route-push", "fade", "slide"] })
296
+ );
297
+ expect(manager.provision?.active).toEqual(
298
+ expect.objectContaining({
299
+ url: "/t",
300
+ title: "Title T",
301
+ ttypes: ["fade", "slide"],
302
+ })
303
+ );
304
+
305
+ // going back uses the outgoing entry's types
306
+ await navigate(manager, () => popstate(router.states[1]));
307
+ expect(transitionSpy.mock.calls[1][0]).toEqual(
308
+ expect.objectContaining({ types: ["route-back", "fade", "slide"] })
309
+ );
310
+ });
311
+
312
+ it("honours match-hash for is-active and resets is-active when route-href is removed", async () => {
313
+ document.body.innerHTML = `
314
+ <spa-manager>
315
+ <spa-route route-href="/h"><template>H</template></spa-route>
316
+ </spa-manager>
317
+ <spa-a route-href="/h#x" match-hash></spa-a>
318
+ <spa-a route-href="/h#x"></spa-a>
319
+ <spa-a route-href="/h#y"></spa-a>
320
+ `;
321
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
322
+ const [strict, loose, other] = qa<HTMLSpaAElement>("spa-a");
323
+
324
+ await navigate(manager, () => click(other));
325
+ expect(location.hash).toBe("#y");
326
+ expect(strict.isActive).toBe(false);
327
+ expect(loose.isActive).toBe(true);
328
+ expect(other.isActive).toBe(true);
329
+
330
+ click(strict);
331
+ await wait(5);
332
+ expect(location.hash).toBe("#x");
333
+ expect(strict.isActive).toBe(true);
334
+ expect(loose.isActive).toBe(true);
335
+ expect(loose.wasActive).toBe(true);
336
+ expect(other.isActive).toBe(true);
337
+
338
+ loose.routeHref = null;
339
+ await wait(0);
340
+ expect(loose.isActive).toBe(false);
341
+ expect(loose.routeInstance).toBeNull();
342
+ });
343
+
344
+ it("navigating to the current URL keeps the rendered screen", async () => {
345
+ document.body.innerHTML = `
346
+ <spa-manager>
347
+ <spa-route route-href="/a"><template><p>A</p></template></spa-route>
348
+ </spa-manager>
349
+ <spa-a route-href="/a"></spa-a>
350
+ `;
351
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
352
+ const route = q<HTMLSpaRouteElement>("spa-route");
353
+ const link = q<HTMLSpaAElement>("spa-a");
354
+ const renderSpy = vi.fn();
355
+ route.addEventListener("spa-route-did-render", renderSpy);
356
+
357
+ await navigate(manager, () => click(link));
358
+ const p = route.querySelector("p");
359
+ await waitForEvent(manager, "spa-manager-push", () => click(link));
360
+ await wait(5);
361
+ expect(router.states.map((s) => s.url)).toEqual(["/", "/a", "/a"]);
362
+ expect(route.isActive).toBe(true);
363
+ expect(link.isActive).toBe(true);
364
+ expect(route.querySelector("p")).toBe(p);
365
+ expect(renderSpy).toHaveBeenCalledTimes(1);
366
+ });
367
+ });
368
+
369
+ describe("spa-route", () => {
370
+ beforeEach(resetRouter);
371
+ afterEach(() => {
372
+ document.body.innerHTML = "";
373
+ router.routes = [];
374
+ vi.restoreAllMocks();
375
+ resetRouter();
376
+ });
377
+
378
+ it("exposes the route payload as provision and announces it", async () => {
379
+ document.body.innerHTML = `
380
+ <spa-manager>
381
+ <spa-route route-href="/items/:id" scroll-reset-y="push" scroll-reset-behavior="smooth">
382
+ <template>Item</template>
383
+ </spa-route>
384
+ </spa-manager>
385
+ <spa-a route-href="/items/123"></spa-a>
386
+ `;
387
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
388
+ const route = q<HTMLSpaRouteElement>("spa-route");
389
+ const provisionEvents = captureEvent(manager, "neutron-provision");
390
+ const scrollToSpy = vi
391
+ .spyOn(window, "scrollTo")
392
+ .mockImplementation(() => {});
393
+
394
+ expect(route.provision ?? null).toBeNull();
395
+ await navigate(manager, () => click(q("spa-a")));
396
+ await wait(5);
397
+
398
+ expect(route.provision).toEqual(
399
+ expect.objectContaining({
400
+ routeHref: "/items/:id",
401
+ matchNested: false,
402
+ scrollResetY: ["push"],
403
+ scrollResetX: ["push", "replace"],
404
+ scrollResetBehavior: "smooth",
405
+ noTransition: false,
406
+ params: { id: "123" },
407
+ move: "push",
408
+ next: null,
409
+ previous: expect.objectContaining({ url: "/" }),
410
+ active: expect.objectContaining({ url: "/items/123" }),
411
+ })
412
+ );
413
+ expect(route.provision!.match![0]).toBe("/items/123");
414
+ // both the route and the manager announce their provision (bubbling)
415
+ expect(provisionEvents.filter((e) => e.target === route)).toHaveLength(1);
416
+ expect(provisionEvents.filter((e) => e.target === manager)).toHaveLength(1);
417
+ expect(manager.provision).toEqual(
418
+ expect.objectContaining({
419
+ move: "push",
420
+ params: {},
421
+ active: expect.objectContaining({ url: "/items/123" }),
422
+ })
423
+ );
424
+ expect(scrollToSpy).toHaveBeenCalledWith({
425
+ top: 0,
426
+ left: 0,
427
+ behavior: "smooth",
428
+ });
429
+ });
430
+
431
+ it("ignores routeChanged when no route is registered", () => {
432
+ document.body.innerHTML = `<spa-route><template>x</template></spa-route>`;
433
+ const route = q<HTMLSpaRouteElement>("spa-route");
434
+ expect(route.routeInstance ?? null).toBeNull();
435
+ expect(
436
+ route.routeChanged({ match: ["/"], params: {} } as never)
437
+ ).toBeUndefined();
438
+ expect(route.isActive).toBe(false);
439
+ });
440
+
441
+ it("works without a manager and lets spa-route-provision be deferred", async () => {
442
+ document.body.innerHTML = `
443
+ <spa-route route-href="/solo/:id"><template><p>solo</p></template></spa-route>
444
+ `;
445
+ const route = q<HTMLSpaRouteElement>("spa-route");
446
+ let thunk: (() => void) | null = null;
447
+ const defer = (e: Event) => {
448
+ e.preventDefault();
449
+ thunk = (e as CustomEvent).detail;
450
+ };
451
+ route.addEventListener("spa-route-provision", defer);
452
+
453
+ await waitForEvent(route, "spa-route-did-render", () =>
454
+ kitRouter.pushState({ url: "/solo/1" })
455
+ );
456
+ expect(route.isActive).toBe(true);
457
+ expect(route.querySelector("p")?.textContent).toBe("solo");
458
+ expect(route.provision ?? null).toBeNull();
459
+ expect(route.readyPromiseObject).not.toBeNull();
460
+
461
+ thunk!();
462
+ await wait(0);
463
+ expect(route.provision?.params).toEqual({ id: "1" });
464
+ expect(route.readyPromiseObject).toBeNull();
465
+
466
+ route.removeEventListener("spa-route-provision", defer);
467
+ kitRouter.pushState({ url: "/solo/2" });
468
+ await wait(0);
469
+ expect(route.provision?.params).toEqual({ id: "2" });
470
+
471
+ kitRouter.pushState({ url: "/elsewhere" });
472
+ await wait(0);
473
+ expect(route.isActive).toBe(false);
474
+ expect(route.provision ?? null).toBeNull();
475
+ expect(hasRenderedContent(route)).toBe(false);
476
+ });
477
+
478
+ it("keeps a nested layout mounted while child routes swap", async () => {
479
+ document.body.innerHTML = `
480
+ <spa-manager>
481
+ <spa-route route-href="/users" match-nested>
482
+ <template>
483
+ <section id="layout">
484
+ <spa-manager>
485
+ <spa-route route-href="/users/:id"><template><p id="detail">detail</p></template></spa-route>
486
+ <spa-route route-href="/users/:id/edit"><template><p id="edit">edit</p></template></spa-route>
487
+ </spa-manager>
488
+ </section>
489
+ </template>
490
+ </spa-route>
491
+ </spa-manager>
492
+ <spa-a route-href="/users/42"></spa-a>
493
+ <spa-a route-href="/users/42/edit"></spa-a>
494
+ `;
495
+ const outerManager = q<HTMLSpaManagerElement>("spa-manager");
496
+ const layout = q<HTMLSpaRouteElement>("spa-route");
497
+ const [toDetail, toEdit] = qa<HTMLSpaAElement>("spa-a");
498
+
499
+ await navigate(outerManager, () => click(toDetail));
500
+ await wait(5);
501
+ const innerManager = q<HTMLSpaManagerElement>("#layout spa-manager");
502
+ const [detail, edit] = Array.from(
503
+ innerManager.querySelectorAll("spa-route")
504
+ ) as HTMLSpaRouteElement[];
505
+ expect(layout.isActive).toBe(true);
506
+ expect(layout.provision?.params).toEqual({});
507
+ expect(detail.isActive).toBe(true);
508
+ expect(detail.provision?.params).toEqual({ id: "42" });
509
+ expect(edit.isActive).toBe(false);
510
+ expect(q("#detail")).not.toBeNull();
511
+ expect(q("#edit")).toBeNull();
512
+
513
+ const layoutRenders = captureEvent(layout, "spa-route-did-render");
514
+ await navigate(innerManager, () => click(toEdit));
515
+ expect(layout.isActive).toBe(true);
516
+ // did-render bubbles up from the inner routes; the layout itself stays put
517
+ expect(layoutRenders.filter((e) => e.target === layout)).toHaveLength(0);
518
+ expect(layoutRenders.filter((e) => e.target === edit)).toHaveLength(1);
519
+ expect(q("#layout")).not.toBeNull();
520
+ expect(edit.isActive).toBe(true);
521
+ expect(edit.provision?.params).toEqual({ id: "42" });
522
+ expect(detail.isActive).toBe(false);
523
+ expect(q("#edit")).not.toBeNull();
524
+ expect(q("#detail")).toBeNull();
525
+ });
526
+
527
+ describe("ready-on", () => {
528
+ it("resolves ready when the ready-on event fires", async () => {
529
+ document.body.innerHTML = `
530
+ <spa-manager>
531
+ <spa-route route-href="/rd" ready-on="content-ready"><template>RD</template></spa-route>
532
+ </spa-manager>
533
+ <spa-a route-href="/rd"></spa-a>
534
+ `;
535
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
536
+ const route = q<HTMLSpaRouteElement>("spa-route");
537
+ const scrollToSpy = vi
538
+ .spyOn(window, "scrollTo")
539
+ .mockImplementation(() => {});
540
+
541
+ await waitForEvent(route, "spa-route-did-render", () =>
542
+ click(q("spa-a"))
543
+ );
544
+ await wait(5);
545
+ expect(route.delayingReady).toBe(true);
546
+ expect(route.readyPromiseObject).not.toBeNull();
547
+ expect(scrollToSpy).not.toHaveBeenCalled();
548
+ // the batched provision waits on ready (first paint: no View Transition)
549
+ expect(manager.executingTransition).toBe(true);
550
+
551
+ await navigate(manager, () =>
552
+ route.dispatchEvent(new Event("content-ready"))
553
+ );
554
+ await wait(5);
555
+ expect(route.delayingReady).toBe(false);
556
+ expect(route.readyPromiseObject).toBeNull();
557
+ expect(scrollToSpy).toHaveBeenCalledTimes(1);
558
+ expect(manager.isTransitioning).toBe(false);
559
+ });
560
+
561
+ it("rejects ready when torn down mid-flight", async () => {
562
+ document.body.innerHTML = `
563
+ <spa-manager>
564
+ <spa-route route-href="/rd" ready-on="content-ready"><template>RD</template></spa-route>
565
+ <spa-route route-href="/a"><template>A</template></spa-route>
566
+ </spa-manager>
567
+ <spa-a route-href="/rd"></spa-a>
568
+ <spa-a route-href="/a"></spa-a>
569
+ `;
570
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
571
+ const [routeRd, routeA] = qa<HTMLSpaRouteElement>("spa-route");
572
+ const [linkRd, linkA] = qa<HTMLSpaAElement>("spa-a");
573
+ const scrollToSpy = vi
574
+ .spyOn(window, "scrollTo")
575
+ .mockImplementation(() => {});
576
+ await waitForEvent(routeRd, "spa-route-did-render", () => click(linkRd));
577
+ await wait(5);
578
+ const settled = vi.fn();
579
+ routeRd.readyPromiseObject!.promise.then(
580
+ () => settled("resolved"),
581
+ () => settled("rejected")
582
+ );
583
+
584
+ await navigate(manager, () => click(linkA));
585
+ await wait(5);
586
+ expect(settled).toHaveBeenCalledWith("rejected");
587
+ expect(routeRd.isActive).toBe(false);
588
+ expect(routeRd.delayingReady).toBe(false);
589
+ expect(routeRd.readyPromiseObject).toBeNull();
590
+ expect(routeA.isActive).toBe(true);
591
+ // scroll only runs for a successful ready (route A)
592
+ expect(scrollToSpy).toHaveBeenCalledTimes(1);
593
+ });
594
+ });
595
+ });
596
+
597
+ describe("spa-manager", () => {
598
+ beforeEach(resetRouter);
599
+ afterEach(() => {
600
+ document.body.innerHTML = "";
601
+ router.routes = [];
602
+ // one test below fakes timers; make sure a failure there cannot leak them
603
+ vi.useRealTimers();
604
+ vi.restoreAllMocks();
605
+ delete (window as unknown as { ontouchstart?: unknown }).ontouchstart;
606
+ delete (navigator as unknown as { maxTouchPoints?: unknown })
607
+ .maxTouchPoints;
608
+ resetRouter();
609
+ });
610
+
611
+ it("transitions the first render with transition-first-render", async () => {
612
+ const transitionSpy = vi.spyOn(document, "startViewTransition");
613
+ document.body.innerHTML = `
614
+ <spa-manager transition-first-render>
615
+ <spa-route route-href="/"><template>Home</template></spa-route>
616
+ </spa-manager>
617
+ `;
618
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
619
+ await waitForEvent(manager, "spa-manager-rendered");
620
+ expect(transitionSpy).toHaveBeenCalledTimes(1);
621
+ // cold load has no move, so no `route-*` type
622
+ expect(transitionSpy.mock.calls[0][0]).toEqual(
623
+ expect.objectContaining({ types: [] })
624
+ );
625
+ expect(manager.hasRendered).toBe(true);
626
+ expect(manager.lastMove ?? null).toBeNull();
627
+ expect(q("spa-route").textContent).toContain("Home");
628
+ });
629
+
630
+ it("skips view transitions with no-transition on the manager or the route", async () => {
631
+ document.body.innerHTML = `
632
+ <spa-manager no-transition>
633
+ <spa-route route-href="/a"><template>A</template></spa-route>
634
+ <spa-route route-href="/b"><template>B</template></spa-route>
635
+ </spa-manager>
636
+ <spa-a route-href="/a"></spa-a>
637
+ <spa-a route-href="/b"></spa-a>
638
+ `;
639
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
640
+ const [routeA, routeB] = qa<HTMLSpaRouteElement>("spa-route");
641
+ const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
642
+ const transitionSpy = vi.spyOn(document, "startViewTransition");
643
+
644
+ await waitForEvent(routeA, "spa-route-did-render", () => click(linkA));
645
+ await waitForEvent(routeB, "spa-route-did-render", () => click(linkB));
646
+ await wait(5);
647
+ expect(transitionSpy).not.toHaveBeenCalled();
648
+ expect(manager.hasRendered).toBe(true);
649
+
650
+ manager.noTransition = false;
651
+ routeA.noTransition = true;
652
+ await wait(0);
653
+ await navigate(manager, () => click(linkA));
654
+ expect(routeA.isActive).toBe(true);
655
+ expect(routeA.textContent).toContain("A");
656
+ // route B (no opt-out) is still batched into a transition
657
+ expect(transitionSpy).toHaveBeenCalledTimes(1);
658
+ });
659
+
660
+ it("settles a batched render whose route was deactivated before it ran", async () => {
661
+ document.body.innerHTML = `
662
+ <spa-manager transition-delay="20" render-timeout="5000">
663
+ <spa-route route-href="/a"><template>A</template></spa-route>
664
+ <spa-route route-href="/b"><template>B</template></spa-route>
665
+ </spa-manager>
666
+ <spa-a route-href="/a"></spa-a>
667
+ <spa-a route-href="/b"></spa-a>
668
+ `;
669
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
670
+ const [routeA, routeB] = qa<HTMLSpaRouteElement>("spa-route");
671
+ const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
672
+
673
+ // Warm up: `has-rendered` flips, so the next nav is a real batch.
674
+ await navigate(manager, () => click(linkA));
675
+ expect(manager.hasRendered).toBe(true);
676
+
677
+ /* Queue A -> B, then flip back to A synchronously. Route B's render
678
+ callback is in the batch, but B is inactive by the time it runs.
679
+ `waitForEvent` rejects after 1s, well before `render-timeout`. */
680
+ const settled = vi.fn();
681
+ await navigate(manager, () => {
682
+ click(linkB);
683
+ expect(routeB.readyPromiseObject).not.toBeNull();
684
+ routeB.readyPromiseObject!.promise.then(
685
+ () => settled("resolved"),
686
+ () => settled("rejected")
687
+ );
688
+ click(linkA);
689
+ });
690
+ await wait(0);
691
+
692
+ // The batch awaited this promise; an abort must reject, never hang
693
+ expect(settled).toHaveBeenCalledWith("rejected");
694
+ expect(routeA.isActive).toBe(true);
695
+ expect(routeB.isActive).toBe(false);
696
+ // The stale render settled rather than leaking a pending promise
697
+ expect(routeB.readyPromiseObject).toBeNull();
698
+ expect(hasRenderedContent(routeB)).toBe(false);
699
+ expect(hasRenderedContent(routeA)).toBe(true);
700
+ expect(manager.isTransitioning).toBe(false);
701
+ });
702
+
703
+ it("does not transition when the browser already animated the navigation", async () => {
704
+ document.body.innerHTML = `
705
+ <spa-manager>
706
+ <spa-route route-href="/a"><template>A</template></spa-route>
707
+ <spa-route route-href="/b"><template>B</template></spa-route>
708
+ </spa-manager>
709
+ <spa-a route-href="/a"></spa-a>
710
+ <spa-a route-href="/b"></spa-a>
711
+ `;
712
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
713
+ const [routeA] = qa<HTMLSpaRouteElement>("spa-route");
714
+ const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
715
+ await navigate(manager, () => click(linkA));
716
+ await navigate(manager, () => click(linkB));
717
+
718
+ const transitionSpy = vi.spyOn(document, "startViewTransition");
719
+ await navigate(manager, () => popstate(router.states[1], true));
720
+ expect(manager.provision?.event).toEqual({ hasUAVisualTransition: true });
721
+ expect(transitionSpy).not.toHaveBeenCalled();
722
+ expect(manager.isTransitioning).toBe(false);
723
+ expect(routeA.isActive).toBe(true);
724
+ expect(routeA.textContent).toContain("A");
725
+ });
726
+
727
+ it("lets spa-manager-will-transition be canceled and drained later", async () => {
728
+ document.body.innerHTML = `
729
+ <spa-manager>
730
+ <spa-route route-href="/a"><template>A</template></spa-route>
731
+ <spa-route route-href="/b"><template>B</template></spa-route>
732
+ </spa-manager>
733
+ <spa-a route-href="/a"></spa-a>
734
+ <spa-a route-href="/b"></spa-a>
735
+ `;
736
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
737
+ const [routeA, routeB] = qa<HTMLSpaRouteElement>("spa-route");
738
+ const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
739
+ await navigate(manager, () => click(linkA));
740
+
741
+ manager.addEventListener(
742
+ "spa-manager-will-transition",
743
+ (e) => e.preventDefault(),
744
+ { once: true }
745
+ );
746
+ const transitionSpy = vi.spyOn(document, "startViewTransition");
747
+ click(linkB);
748
+ await wait(5);
749
+ expect(routeB.isActive).toBe(true);
750
+ expect(manager.hasCallbacks()).toBe(true);
751
+ expect(hasRenderedContent(routeB)).toBe(false);
752
+ expect(hasRenderedContent(routeA)).toBe(true);
753
+ expect(transitionSpy).not.toHaveBeenCalled();
754
+
755
+ await navigate(manager, () => manager.updateRoutes(false));
756
+ expect(transitionSpy).not.toHaveBeenCalled();
757
+ expect(hasRenderedContent(routeB)).toBe(true);
758
+ expect(hasRenderedContent(routeA)).toBe(false);
759
+ expect(manager.hasCallbacks()).toBe(false);
760
+ });
761
+
762
+ it("tolerates a partially initialised callback queue", async () => {
763
+ document.body.innerHTML = `
764
+ <spa-manager>
765
+ <spa-route route-href="/a"><template>A</template></spa-route>
766
+ <spa-route route-href="/b"><template>B</template></spa-route>
767
+ </spa-manager>
768
+ <spa-a route-href="/a"></spa-a>
769
+ <spa-a route-href="/b"></spa-a>
770
+ `;
771
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
772
+ const [, routeB] = qa<HTMLSpaRouteElement>("spa-route");
773
+ const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
774
+ await navigate(manager, () => click(linkA));
775
+
776
+ manager._routeCallbacks = {} as never;
777
+ await navigate(manager, () => click(linkB));
778
+ expect(routeB.textContent).toContain("B");
779
+
780
+ manager._routeCallbacks = {} as never;
781
+ await navigate(manager, () => manager._updateRoutes(false));
782
+ expect(manager._routeCallbacks).toEqual({
783
+ provision: [],
784
+ render: [],
785
+ unrender: [],
786
+ });
787
+ });
788
+
789
+ it("delays the transition with transition-delay and reschedules on repeat", async () => {
790
+ document.body.innerHTML = `
791
+ <spa-manager transition-delay="10">
792
+ <spa-route route-href="/a"><template>A</template></spa-route>
793
+ <spa-route route-href="/b"><template>B</template></spa-route>
794
+ </spa-manager>
795
+ <spa-a route-href="/a"></spa-a>
796
+ <spa-a route-href="/b"></spa-a>
797
+ `;
798
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
799
+ const [, routeB] = qa<HTMLSpaRouteElement>("spa-route");
800
+ const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
801
+ await navigate(manager, () => click(linkA));
802
+
803
+ // Fake timers from here: the assertions are that the 10ms delay is still
804
+ // pending, which a real `wait(0)` can outlive on a loaded machine.
805
+ vi.useFakeTimers();
806
+ const transitionSpy = vi.spyOn(document, "startViewTransition");
807
+ const clearSpy = vi.spyOn(globalThis, "clearTimeout");
808
+ const rendered = waitForEvent(manager, "spa-manager-rendered");
809
+ click(linkB);
810
+ await vi.advanceTimersByTimeAsync(0);
811
+ const firstId = manager.transitionDelayId;
812
+ expect(firstId).not.toBeNull();
813
+ expect(transitionSpy).not.toHaveBeenCalled();
814
+ // a second request while the delay is pending restarts the timer
815
+ manager.updateRoutes(true);
816
+ await vi.advanceTimersByTimeAsync(0);
817
+ expect(clearSpy).toHaveBeenCalledWith(firstId);
818
+ expect(manager.transitionDelayId).not.toBe(firstId);
819
+ await vi.advanceTimersByTimeAsync(20);
820
+ await rendered;
821
+ expect(transitionSpy).toHaveBeenCalledTimes(1);
822
+ expect(manager.transitionDelayId).toBeNull();
823
+ expect(routeB.textContent).toContain("B");
824
+ });
825
+
826
+ it("forces the transition to settle after render-timeout", async () => {
827
+ document.body.innerHTML = `
828
+ <spa-manager render-timeout="20">
829
+ <spa-route route-href="/a"><template>A</template></spa-route>
830
+ <spa-route route-href="/slow" ready-on="content-ready"><template>Slow</template></spa-route>
831
+ </spa-manager>
832
+ <spa-a route-href="/a"></spa-a>
833
+ <spa-a route-href="/slow"></spa-a>
834
+ `;
835
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
836
+ const [, routeSlow] = qa<HTMLSpaRouteElement>("spa-route");
837
+ const [linkA, linkSlow] = qa<HTMLSpaAElement>("spa-a");
838
+ await navigate(manager, () => click(linkA));
839
+
840
+ const started = Date.now();
841
+ await navigate(manager, () => click(linkSlow));
842
+ expect(Date.now() - started).toBeGreaterThanOrEqual(15);
843
+ expect(routeSlow.delayingReady).toBe(true);
844
+ expect(routeSlow.textContent).toContain("Slow");
845
+ expect(manager.isTransitioning).toBe(false);
846
+ routeSlow.dispatchEvent(new Event("content-ready"));
847
+ await wait(0);
848
+ expect(routeSlow.delayingReady).toBe(false);
849
+ });
850
+
851
+ it("re-emits child errors as spa-manager-error without a transition", async () => {
852
+ document.body.innerHTML = `
853
+ <spa-manager>
854
+ <spa-route route-href="/a"><template>A</template></spa-route>
855
+ <spa-route route-href="/broken" template-ref="#does-not-exist"></spa-route>
856
+ </spa-manager>
857
+ <spa-a route-href="/a"></spa-a>
858
+ <spa-a route-href="/broken"></spa-a>
859
+ `;
860
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
861
+ const [, routeBroken] = qa<HTMLSpaRouteElement>("spa-route");
862
+ const [linkA, linkBroken] = qa<HTMLSpaAElement>("spa-a");
863
+ vi.spyOn(KitLogger, "error").mockImplementation(() => {});
864
+ const bodyErrorSpy = vi.fn();
865
+ document.body.addEventListener("spa-route-error", bodyErrorSpy);
866
+ const managerErrors = captureEvent(manager, "spa-manager-error");
867
+ await navigate(manager, () => click(linkA));
868
+
869
+ const transitionSpy = vi.spyOn(document, "startViewTransition");
870
+ await navigate(manager, () => click(linkBroken));
871
+ await wait(5);
872
+ expect(managerErrors).toHaveLength(1);
873
+ expect(managerErrors[0].detail).toBeDefined();
874
+ expect(routeBroken.isError).toBe(true);
875
+ expect(routeBroken.isActive).toBe(true);
876
+ // the child error is consumed by the manager
877
+ expect(bodyErrorSpy).not.toHaveBeenCalled();
878
+ expect(manager.isTransitioning).toBe(false);
879
+ expect(transitionSpy).toHaveBeenCalledTimes(1);
880
+ });
881
+
882
+ it("caps router history with max-states", () => {
883
+ document.body.innerHTML = `<spa-manager max-states="2"></spa-manager>`;
884
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
885
+ expect(kitRouter.MAX_STATES).toBe(2);
886
+ kitRouter.pushState({ url: "/1" });
887
+ kitRouter.pushState({ url: "/2" });
888
+ kitRouter.pushState({ url: "/3" });
889
+ expect(router.states.map((s) => s.url)).toEqual(["/2", "/3"]);
890
+ manager.maxStates = null;
891
+ expect(kitRouter.MAX_STATES).toBe(kitRouter.DEFAULT_MAX_STATES);
892
+ });
893
+
894
+ it("unregisters from the router on disconnect", async () => {
895
+ document.body.innerHTML = `
896
+ <spa-manager>
897
+ <spa-route route-href="/a"><template>A</template></spa-route>
898
+ </spa-manager>
899
+ `;
900
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
901
+ const route = q<HTMLSpaRouteElement>("spa-route");
902
+ const managerRoute = manager.routeInstance!;
903
+ const childRoute = route.routeInstance!;
904
+ expect(managerRoute.key).toBeInstanceOf(RegExp);
905
+ const offSpy = vi.spyOn(kitRouter, "off");
906
+
907
+ manager.remove();
908
+ await wait(0);
909
+ expect(offSpy).toHaveBeenCalledWith(managerRoute);
910
+ expect(offSpy).toHaveBeenCalledWith(childRoute);
911
+ expect(manager.routeInstance).toBeNull();
912
+ expect(route.routeInstance).toBeNull();
913
+ });
914
+
915
+ describe("touch edge-swipe", () => {
916
+ const listenerCount = (type: string) =>
917
+ globalThis.getEventListeners(document)[type]?.length ?? 0;
918
+
919
+ it("ignores overscroll-behavior-x on non-touch devices", () => {
920
+ const before = listenerCount("touchstart");
921
+ document.body.innerHTML = `<spa-manager overscroll-behavior-x="navigate"></spa-manager>`;
922
+ expect(listenerCount("touchstart")).toBe(before);
923
+ touch("touchstart", [{ identifier: 1, clientX: 5, clientY: 50 }]);
924
+ expect(q<HTMLSpaManagerElement>("spa-manager").swipeTracker ?? null).toBe(
925
+ null
926
+ );
927
+ });
928
+
929
+ it("navigates back / forward from the edges", () => {
930
+ (window as unknown as { ontouchstart: unknown }).ontouchstart = null;
931
+ const before = listenerCount("touchstart");
932
+ document.body.innerHTML = `<spa-manager overscroll-behavior-x="navigate"></spa-manager>`;
933
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
934
+ const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
935
+ expect(manager.overscrollXThreshold).toBe(40);
936
+ expect(listenerCount("touchstart")).toBe(before + 1);
937
+ expect(listenerCount("touchmove")).toBeGreaterThan(0);
938
+ const width = window.innerWidth;
939
+
940
+ // left edge → right: back
941
+ touch("touchstart", [{ identifier: 1, clientX: 10, clientY: 50 }]);
942
+ expect(manager.swipeTracker).toEqual({
943
+ identifier: 1,
944
+ startX: 10,
945
+ startY: 50,
946
+ });
947
+ const move = touch("touchmove", [
948
+ { identifier: 1, clientX: 80, clientY: 55 },
949
+ ]);
950
+ expect(move.defaultPrevented).toBe(true);
951
+ expect(goSpy).toHaveBeenLastCalledWith(-1);
952
+ expect(manager.swipeTracker).toBeNull();
953
+ touch("touchend", []);
954
+ expect(manager.swipeTracker).toBeNull();
955
+
956
+ // right edge → left: forward
957
+ touch("touchstart", [{ identifier: 2, clientX: width - 5, clientY: 50 }]);
958
+ expect(manager.swipeTracker?.startX).toBe(width - 5);
959
+ touch("touchmove", [{ identifier: 2, clientX: width - 90, clientY: 50 }]);
960
+ expect(goSpy).toHaveBeenLastCalledWith(1);
961
+ expect(manager.swipeTracker).toBeNull();
962
+ touch("touchcancel", []);
963
+
964
+ // clamps out-of-viewport start coordinates
965
+ touch("touchstart", [{ identifier: 3, clientX: -20, clientY: -5 }]);
966
+ expect(manager.swipeTracker).toEqual({
967
+ identifier: 3,
968
+ startX: 0,
969
+ startY: 0,
970
+ });
971
+ touch("touchend", []);
972
+
973
+ // switching the mode off removes the document listeners
974
+ manager.overscrollBehaviorX = null;
975
+ expect(listenerCount("touchstart")).toBe(before);
976
+ touch("touchstart", [{ identifier: 4, clientX: 10, clientY: 50 }]);
977
+ expect(manager.swipeTracker).toBeNull();
978
+ });
979
+
980
+ it("ignores multi-touch, mid-screen starts and unknown touches", () => {
981
+ Object.defineProperty(navigator, "maxTouchPoints", {
982
+ value: 1,
983
+ configurable: true,
984
+ });
985
+ document.body.innerHTML = `<spa-manager overscroll-behavior-x="navigate" overscroll-x-threshold="30"></spa-manager>`;
986
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
987
+ const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
988
+ expect(manager.overscrollXThreshold).toBe(30);
989
+
990
+ // move without a tracker is ignored
991
+ const stray = touch("touchmove", [
992
+ { identifier: 1, clientX: 80, clientY: 50 },
993
+ ]);
994
+ expect(stray.defaultPrevented).toBe(false);
995
+
996
+ touch("touchstart", [
997
+ { identifier: 1, clientX: 5, clientY: 50 },
998
+ { identifier: 2, clientX: 50, clientY: 50 },
999
+ ]);
1000
+ expect(manager.swipeTracker ?? null).toBeNull();
1001
+
1002
+ touch("touchstart", [{ identifier: 1, clientX: 200, clientY: 50 }]);
1003
+ expect(manager.swipeTracker ?? null).toBeNull();
1004
+
1005
+ touch("touchstart", [{ identifier: 1, clientX: 5, clientY: 50 }]);
1006
+ expect(manager.swipeTracker?.identifier).toBe(1);
1007
+ // a different finger moving does not count
1008
+ const other = touch("touchmove", [
1009
+ { identifier: 9, clientX: 100, clientY: 50 },
1010
+ ]);
1011
+ expect(other.defaultPrevented).toBe(false);
1012
+ expect(manager.swipeTracker?.identifier).toBe(1);
1013
+ expect(goSpy).not.toHaveBeenCalled();
1014
+ });
1015
+
1016
+ it("only navigates past the threshold and mostly horizontally", () => {
1017
+ (window as unknown as { ontouchstart: unknown }).ontouchstart = null;
1018
+ document.body.innerHTML = `<spa-manager overscroll-behavior-x="navigate"></spa-manager>`;
1019
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
1020
+ const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
1021
+ const width = window.innerWidth;
1022
+
1023
+ // tiny horizontal delta: nothing prevented
1024
+ touch("touchstart", [{ identifier: 1, clientX: 10, clientY: 50 }]);
1025
+ let move = touch("touchmove", [
1026
+ { identifier: 1, clientX: 11, clientY: 50 },
1027
+ ]);
1028
+ expect(move.defaultPrevented).toBe(false);
1029
+ // mostly vertical: nothing prevented
1030
+ move = touch("touchmove", [{ identifier: 1, clientX: 15, clientY: 200 }]);
1031
+ expect(move.defaultPrevented).toBe(false);
1032
+ // horizontal but still inside the edge threshold: prevented, no nav
1033
+ move = touch("touchmove", [{ identifier: 1, clientX: 30, clientY: 50 }]);
1034
+ expect(move.defaultPrevented).toBe(true);
1035
+ expect(goSpy).not.toHaveBeenCalled();
1036
+ // dragging left from the left edge never navigates
1037
+ move = touch("touchmove", [{ identifier: 1, clientX: 0, clientY: 50 }]);
1038
+ expect(move.defaultPrevented).toBe(true);
1039
+ expect(goSpy).not.toHaveBeenCalled();
1040
+ touch("touchend", []);
1041
+
1042
+ // right edge: dragging further right, or left but not past threshold
1043
+ touch("touchstart", [{ identifier: 2, clientX: width - 5, clientY: 50 }]);
1044
+ touch("touchmove", [{ identifier: 2, clientX: width, clientY: 50 }]);
1045
+ touch("touchmove", [{ identifier: 2, clientX: width - 20, clientY: 50 }]);
1046
+ expect(goSpy).not.toHaveBeenCalled();
1047
+ expect(manager.swipeTracker?.identifier).toBe(2);
1048
+ touch("touchend", []);
1049
+ });
1050
+
1051
+ it("blocks overscroll without navigating when set to none", () => {
1052
+ (window as unknown as { ontouchstart: unknown }).ontouchstart = null;
1053
+ document.body.innerHTML = `<spa-manager overscroll-behavior-x="none"></spa-manager>`;
1054
+ const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
1055
+ touch("touchstart", [{ identifier: 1, clientX: 10, clientY: 50 }]);
1056
+ const move = touch("touchmove", [
1057
+ { identifier: 1, clientX: 90, clientY: 50 },
1058
+ ]);
1059
+ expect(move.defaultPrevented).toBe(true);
1060
+ expect(goSpy).not.toHaveBeenCalled();
1061
+ expect(q<HTMLSpaManagerElement>("spa-manager").swipeTracker).toEqual(
1062
+ expect.objectContaining({ identifier: 1 })
1063
+ );
1064
+ });
1065
+ });
1066
+ });
1067
+
1068
+ describe("utils search params", () => {
1069
+ it("compares query strings key by key", () => {
1070
+ expect(urlMatchesHref("/a?foo=1", "/a?foo=2")).toBe(false);
1071
+ expect(urlMatchesHref("/a?foo=1", "/a?foo=1&bar=2")).toBe(false);
1072
+ expect(urlMatchesHref("/a?foo=1&bar=2", "/a?foo=1&bar=3")).toBe(false);
1073
+ expect(urlMatchesHref("/a?foo=1&bar=2", "/a?bar=2&foo=1")).toBe(true);
1074
+ expect(urlMatchesHref("/a?foo=1", "/a")).toBe(false);
1075
+ });
1076
+ });