@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,225 @@
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 "../../index";
12
+ import { kitRouter } from "@excom/kit-router";
13
+
14
+ if (!document.startViewTransition) {
15
+ /*
16
+ * Shim: like a browser, `finished` settles only after the update callback.
17
+ * @ts-ignore shim
18
+ */
19
+ document.startViewTransition = ({ update }) => {
20
+ const done = Promise.resolve(update()).then(() => {});
21
+ return {
22
+ ready: done,
23
+ updateCallbackDone: done,
24
+ finished: done,
25
+ skipTransition: () => {},
26
+ cancel: () => {},
27
+ };
28
+ };
29
+ }
30
+
31
+ type RouterState = { id: string; url: string; isInit?: boolean };
32
+ type RouterInternals = {
33
+ states: RouterState[];
34
+ currentStateId: string | null;
35
+ currentTempData: { move: null | string; event?: unknown };
36
+ routes: unknown[];
37
+ };
38
+ const router = kitRouter as unknown as RouterInternals & typeof kitRouter;
39
+
40
+ /** The page's own `<title>` — what an untitled route must fall back to. */
41
+ const PAGE_TITLE = "Page own title";
42
+
43
+ /** Put the singleton router back to a cold-load state on `url`. */
44
+ const resetRouter = (url = "/") => {
45
+ history.replaceState(null, "", url);
46
+ router.states = [{ id: "init", url, isInit: true }];
47
+ router.currentStateId = null;
48
+ router.currentTempData = { move: null };
49
+ };
50
+
51
+ /** Emulate the browser landing on a known history entry (back / forward). */
52
+ const popstate = (state: RouterState) => {
53
+ history.replaceState({ id: state.id }, "", state.url);
54
+ window.dispatchEvent(new PopStateEvent("popstate"));
55
+ };
56
+
57
+ const q = <T extends Element>(selector: string) =>
58
+ document.querySelector(selector) as T;
59
+ const qa = <T extends Element>(selector: string) =>
60
+ Array.from(document.querySelectorAll(selector)) as T[];
61
+
62
+ /** Navigate and wait for the manager to settle. */
63
+ const navigate = (manager: Element, trigger: () => void) =>
64
+ waitForEvent(manager, "spa-manager-rendered", trigger);
65
+
66
+ /**
67
+ * Hard-load `url` with `html` as the document: routes match at connect, so
68
+ * nothing can be awaited before mounting — settle on the title instead.
69
+ */
70
+ const coldLoad = async (url: string, html: string, title: string) => {
71
+ resetRouter(url);
72
+ document.body.innerHTML = html;
73
+ await vi.waitFor(() => expect(document.title).toBe(title));
74
+ // let the batch that follows the unbatched first paint drain
75
+ await wait(5);
76
+ };
77
+
78
+ describe("spa-route document-title", () => {
79
+ beforeEach(() => {
80
+ resetRouter();
81
+ document.title = PAGE_TITLE;
82
+ });
83
+ afterEach(() => {
84
+ document.body.innerHTML = "";
85
+ router.routes = [];
86
+ vi.restoreAllMocks();
87
+ resetRouter();
88
+ });
89
+
90
+ const twoRoutes = `
91
+ <spa-manager>
92
+ <spa-route route-href="/company" document-title="Experimental Company">
93
+ <template><p>company</p></template>
94
+ </spa-route>
95
+ <spa-route route-href="/docs"><template><p>docs</p></template></spa-route>
96
+ </spa-manager>
97
+ `;
98
+
99
+ it("titles a cold load, with no link in the way", async () => {
100
+ await coldLoad("/company", twoRoutes, "Experimental Company");
101
+
102
+ expect(q<HTMLSpaRouteElement>("spa-route").isActive).toBe(true);
103
+ expect(document.title).toBe("Experimental Company");
104
+ });
105
+
106
+ it("restores the page's own title when no active route has one", async () => {
107
+ await coldLoad("/company", twoRoutes, "Experimental Company");
108
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
109
+
110
+ await navigate(manager, () => kitRouter.pushState({ url: "/docs" }));
111
+ expect(document.title).toBe(PAGE_TITLE);
112
+
113
+ // and back on the titled route it returns
114
+ await navigate(manager, () => kitRouter.pushState({ url: "/company" }));
115
+ expect(document.title).toBe("Experimental Company");
116
+ });
117
+
118
+ it("swaps between two titled routes, back included", async () => {
119
+ const html = `
120
+ <spa-manager>
121
+ <spa-route route-href="/a" document-title="Alpha"><template>A</template></spa-route>
122
+ <spa-route route-href="/b" document-title="Beta"><template>B</template></spa-route>
123
+ </spa-manager>
124
+ `;
125
+ await coldLoad("/a", html, "Alpha");
126
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
127
+
128
+ await navigate(manager, () => kitRouter.pushState({ url: "/b" }));
129
+ expect(document.title).toBe("Beta");
130
+
131
+ await navigate(manager, () => popstate(router.states[0]!));
132
+ expect(document.title).toBe("Alpha");
133
+ });
134
+
135
+ it("lets a nested route outrank its ancestor, and hands the title back", async () => {
136
+ const html = `
137
+ <spa-manager>
138
+ <!-- a regex, not match-nested: that one only matches nested paths -->
139
+ <spa-route route-regex="^/parent" document-title="Parent">
140
+ <template>
141
+ <spa-manager>
142
+ <spa-route route-href="/parent/child" document-title="Child">
143
+ <template><p id="child">child</p></template>
144
+ </spa-route>
145
+ </spa-manager>
146
+ </template>
147
+ </spa-route>
148
+ </spa-manager>
149
+ `;
150
+ await coldLoad("/parent", html, "Parent");
151
+ const innerManager = qa<HTMLSpaManagerElement>("spa-manager")[1]!;
152
+
153
+ // the inner manager settles this one; it hands the title to the outermost
154
+ await navigate(innerManager, () =>
155
+ kitRouter.pushState({ url: "/parent/child" })
156
+ );
157
+ expect(q("#child")).not.toBeNull();
158
+ expect(document.title).toBe("Child");
159
+
160
+ await navigate(innerManager, () => kitRouter.pushState({ url: "/parent" }));
161
+ expect(q("#child")).toBeNull();
162
+ expect(document.title).toBe("Parent");
163
+ });
164
+
165
+ it("follows document-title rewritten on the route already on screen", async () => {
166
+ await coldLoad("/company", twoRoutes, "Experimental Company");
167
+ const route = q<HTMLSpaRouteElement>("spa-route");
168
+
169
+ route.setAttribute("document-title", "Renamed");
170
+ await vi.waitFor(() => expect(document.title).toBe("Renamed"));
171
+
172
+ // dropping it falls back to the page's own title
173
+ route.removeAttribute("document-title");
174
+ await vi.waitFor(() => expect(document.title).toBe(PAGE_TITLE));
175
+ });
176
+
177
+ it("ignores document-title on an inactive route", async () => {
178
+ await coldLoad("/docs", twoRoutes, PAGE_TITLE);
179
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
180
+ const [company, docs] = qa<HTMLSpaRouteElement>("spa-route");
181
+
182
+ expect(docs!.isActive).toBe(true);
183
+ company!.setAttribute("document-title", "Not on screen");
184
+ await wait(5);
185
+
186
+ expect(document.title).toBe(PAGE_TITLE);
187
+ // nothing claimed the title, so the page's own was never captured
188
+ expect(manager._defaultTitle ?? null).toBeNull();
189
+ });
190
+
191
+ it("titles the is-fallback route", async () => {
192
+ const html = `
193
+ <spa-manager>
194
+ <spa-route route-href="/known" document-title="Known"><template>K</template></spa-route>
195
+ <spa-route route-regex=".*" is-fallback document-title="Not found">
196
+ <template>404</template>
197
+ </spa-route>
198
+ </spa-manager>
199
+ `;
200
+ await coldLoad("/known", html, "Known");
201
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
202
+
203
+ await navigate(manager, () => kitRouter.pushState({ url: "/nowhere" }));
204
+ expect(qa<HTMLSpaRouteElement>("spa-route")[1]!.isActive).toBe(true);
205
+ expect(document.title).toBe("Not found");
206
+ });
207
+
208
+ it("leaves document.title alone when no route is titled", async () => {
209
+ const html = `
210
+ <spa-manager>
211
+ <spa-route route-href="/a"><template>A</template></spa-route>
212
+ <spa-route route-href="/b"><template>B</template></spa-route>
213
+ </spa-manager>
214
+ `;
215
+ await coldLoad("/a", html, PAGE_TITLE);
216
+ const manager = q<HTMLSpaManagerElement>("spa-manager");
217
+ // an app titling the page itself must not be clobbered
218
+ document.title = "Set by the app";
219
+
220
+ await navigate(manager, () => kitRouter.pushState({ url: "/b" }));
221
+
222
+ expect(document.title).toBe("Set by the app");
223
+ expect(manager._defaultTitle ?? null).toBeNull();
224
+ });
225
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "@excom/heft-rig/profiles/default/config/tsconfig.json",
3
+ "include": ["./*.ts"],
4
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
5
+ }