@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
package/spa-route.ts ADDED
@@ -0,0 +1,322 @@
1
+ import { KitRouteData } from "@excom/kit-router";
2
+ import { deepCompare } from "@excom/kit-utils";
3
+ import {
4
+ ConstructorType,
5
+ Neutron,
6
+ TEvent,
7
+ TokenList,
8
+ } from "@excom/neutron";
9
+ import { RenderableElement } from "@excom/renderable-element";
10
+ import { RoutableElement } from "@excom/routable-element";
11
+
12
+ export type SpaRouteProvisionThunk = () => void;
13
+
14
+ export type SpaRouteProvisionEvent = TEvent & {
15
+ type: "spa-route-provision";
16
+ detail: SpaRouteProvisionThunk;
17
+ };
18
+
19
+ export type SpaRouteProvision = {
20
+ routeHref: string;
21
+ matchNested: boolean;
22
+ scrollResetY: string[];
23
+ scrollResetX: string[];
24
+ scrollResetBehavior: ScrollBehavior;
25
+ noTransition: boolean;
26
+ active: KitRouteData["active"];
27
+ event: KitRouteData["event"];
28
+ match: KitRouteData["match"];
29
+ move: KitRouteData["move"];
30
+ next: KitRouteData["next"];
31
+ previous: KitRouteData["previous"];
32
+ params: KitRouteData["params"];
33
+ };
34
+
35
+ /**
36
+ * One screen of a single-page app. Matches `route-href` / `route-regex`, then renders its `<template>` while active and unrenders on the way out. Place inside `<spa-manager>` for view transitions and coordinated scroll.
37
+ *
38
+ * @summary Declarative SPA screen — URL match → render.
39
+ *
40
+ * @example
41
+ * <spa-manager>
42
+ * <spa-route route-href="/">
43
+ * <template><home-page></home-page></template>
44
+ * </spa-route>
45
+ * <spa-route route-href="/users/:id">
46
+ * <template><user-page></user-page></template>
47
+ * </spa-route>
48
+ * </spa-manager>
49
+ *
50
+ * @example
51
+ * <!-- 404 fallback: only activates if no preceding sibling matched -->
52
+ * <spa-manager>
53
+ * <spa-route route-href="/known"><template>Known</template></spa-route>
54
+ * <spa-route route-regex=".*" is-fallback>
55
+ * <template>Not found</template>
56
+ * </spa-route>
57
+ * </spa-manager>
58
+ *
59
+ * @fires spa-route-provision - Cancelable. Same route stayed active but
60
+ * params changed (`same-route="reuse"`). `event.detail` is a thunk that
61
+ * updates route data and resolves the ready promise. `<spa-manager>`
62
+ * batches this into the View Transition like render/unrender.
63
+ * @type SpaRouteProvisionEvent
64
+ *
65
+ * @default-action spa-route-provision - Invokes `event.detail()` to apply
66
+ * the new provision.
67
+ *
68
+ * @child template - Screen content. Cloned (or reused with `persist-content`) on activation.
69
+ */
70
+ export const SpaRoute = Neutron.compose([
71
+ RenderableElement,
72
+ RoutableElement,
73
+ Neutron({
74
+ tag: "spa-route",
75
+ events: {
76
+ ["spa-route-provision"]: {},
77
+ },
78
+ props: {
79
+ /**
80
+ * @option
81
+ * When this route matches while already active, `reuse` keeps the
82
+ * rendered tree and updates route data; `refresh` tears down and
83
+ * re-renders. Use `refresh` for param-driven screens (e.g.
84
+ * `/users/:id` → `/users/2`); `reuse` when only route data should
85
+ * change (e.g. `/logs/:view`). Pair with `scroll-set-disabled` to
86
+ * leave the viewport untouched.
87
+ * @values reuse | refresh
88
+ * @default reuse
89
+ */
90
+ sameRoute: {
91
+ type: String,
92
+ defaultValue: () => "reuse",
93
+ },
94
+ /**
95
+ * @option
96
+ * Opt this route out of the parent `<spa-manager>` View Transition. Still renders/unrenders — just without the cross-fade.
97
+ */
98
+ noTransition: Boolean,
99
+ /**
100
+ * @option
101
+ * `window.scrollTo` behavior when this route applies a scroll reset /
102
+ * restore.
103
+ * @default instant
104
+ * @values auto | instant | smooth
105
+ */
106
+ scrollResetBehavior: String,
107
+ /**
108
+ * @option
109
+ * Navigation moves that reset scroll X to `0`. Moves omitted here
110
+ * restore the saved X for that history entry instead.
111
+ * @values push | replace | back | forward
112
+ * @default push replace
113
+ */
114
+ scrollResetX: {
115
+ type: TokenList,
116
+ defaultValue: () => ["push", "replace"],
117
+ },
118
+ /**
119
+ * @option
120
+ * Navigation moves that reset scroll Y to `0`. Moves omitted here
121
+ * restore the saved Y for that history entry instead.
122
+ * @values push | replace | back | forward
123
+ * @default push replace
124
+ */
125
+ scrollResetY: {
126
+ type: TokenList,
127
+ defaultValue: () => ["push", "replace"],
128
+ },
129
+ /**
130
+ * @option
131
+ * Disable all scroll reset / restore for this route.
132
+ */
133
+ scrollSetDisabled: Boolean,
134
+ /**
135
+ * @option
136
+ * Only activate when this route matches *and* no earlier sibling `<spa-route>` is already active. Pair with a permissive `route-regex` (e.g. `.*`) for 404 catch-alls.
137
+ */
138
+ isFallback: Boolean,
139
+ /**
140
+ * @option
141
+ * `document.title` while this route is active. The outermost
142
+ * `<spa-manager>` applies the last active route carrying one — so a
143
+ * nested route beats its ancestor — and restores the page's own
144
+ * `<title>` once no active route has one. Cold loads and back /
145
+ * forward retitle too: it keys off activation, not clicks.
146
+ */
147
+ documentTitle: String,
148
+ /**
149
+ * @state
150
+ * Set briefly while navigating away. Style outgoing screens / card-expansion exits with `spa-route[was-active]`.
151
+ */
152
+ wasActive: Boolean,
153
+ /**
154
+ * @provision
155
+ * Active route payload for this activation (`null` when inactive).
156
+ * Not reflected as an attribute.
157
+ * @type SpaRouteProvision
158
+ */
159
+ provision: Object as unknown as ConstructorType<KitRouteData>,
160
+ // private
161
+ routeParams: Object as unknown as ConstructorType<KitRouteData["params"]>,
162
+ },
163
+ }),
164
+ ])
165
+ .defineMethods({
166
+ setScroll: (element) => {
167
+ // Read props inside the timeout: ready may resolve before the
168
+ // matching `provision` effect lands.
169
+ setTimeout(() => {
170
+ const {
171
+ provision,
172
+ scrollResetX,
173
+ scrollResetY,
174
+ scrollResetBehavior,
175
+ scrollSetDisabled,
176
+ } = element;
177
+ if (!provision || scrollSetDisabled) return;
178
+ const move = provision?.move as string;
179
+ window.scrollTo({
180
+ top:
181
+ !move || scrollResetY?.includes(move)
182
+ ? 0
183
+ : provision.active.scrollY || 0,
184
+ left:
185
+ !move || scrollResetX?.includes(move)
186
+ ? 0
187
+ : provision.active.scrollX || 0,
188
+ behavior: (scrollResetBehavior as ScrollBehavior) || "instant",
189
+ });
190
+ }, 0);
191
+ },
192
+ isResponsibleForReady: ({ readyOn }, caller: string | Event) => {
193
+ if (caller === "startTeardown") {
194
+ return { returns: true };
195
+ } else if (readyOn && caller instanceof Event) {
196
+ return { returns: true };
197
+ // Same idea as RenderableElement.isResponsibleForReady, except `doProvision` flips ready, not the render
198
+ } else if (!readyOn && caller === "doProvision") {
199
+ return { returns: true };
200
+ }
201
+ return { returns: false };
202
+ },
203
+ doProvision: (_, newProvision: SpaRouteProvision) => [
204
+ { provision: newProvision },
205
+ // Reuse doesn't re-render; resolve ready now unless `ready-on` is
206
+ // waiting on an external paint signal
207
+ { tryCompleteReady: ["doProvision"] },
208
+ ],
209
+ routeChanged: (element, routeData: KitRouteData) => {
210
+ const {
211
+ sameRoute,
212
+ isActive,
213
+ provision,
214
+ // @ts-ignore
215
+ doProvision,
216
+ isFallback,
217
+ routeHref,
218
+ routeInstance,
219
+ matchNested,
220
+ scrollResetY,
221
+ scrollResetX,
222
+ scrollResetBehavior,
223
+ noTransition,
224
+ } = element;
225
+ if (!routeInstance) return;
226
+ let willActivate = !!routeData.match;
227
+ if (
228
+ // Fallback matched, but another route is already active: stay off.
229
+ // Not itself: an active fallback moving between two unmatched URLs
230
+ isFallback &&
231
+ willActivate &&
232
+ Array.from(
233
+ element
234
+ .closest("spa-manager")
235
+ ?.querySelectorAll("spa-route[is-active]") ?? []
236
+ ).some((route) => route !== element)
237
+ ) {
238
+ willActivate = false;
239
+ }
240
+ const provisionChanged =
241
+ !deepCompare(provision?.params || {}, routeData?.params || {}) ||
242
+ // param-less routes (a `route-regex` catch-all) still change per URL
243
+ provision?.match?.[0] !== routeData?.match?.[0];
244
+ if (isActive === willActivate && !provisionChanged) {
245
+ // Skip only when both activation and params are unchanged.
246
+ return false;
247
+ }
248
+ const willStayActive = willActivate && isActive;
249
+ const wasActivated =
250
+ !willActivate || willStayActive
251
+ ? // After a reload, history survives but router states don't: check `next.url`
252
+ routeData.move === "back" && routeData.next?.url
253
+ ? routeInstance.match(routeData.next.url)?.match?.[0]
254
+ : // Same reload gap: check `previous.url`
255
+ ["forward", "push", "replace"].includes(routeData.move!) &&
256
+ routeData.previous?.url
257
+ ? routeInstance.match(routeData.previous.url)?.match?.[0]
258
+ : false
259
+ : false;
260
+ const newProvision = willActivate
261
+ ? ({
262
+ // Element options
263
+ routeHref,
264
+ matchNested,
265
+ scrollResetY,
266
+ scrollResetX,
267
+ scrollResetBehavior,
268
+ noTransition,
269
+ // Router data
270
+ active: routeData.active,
271
+ event: routeData.event,
272
+ match: routeData.match,
273
+ move: routeData.move,
274
+ next: routeData.next,
275
+ previous: routeData.previous,
276
+ params: routeData.params,
277
+ } as SpaRouteProvision)
278
+ : null;
279
+ const shouldRefresh = sameRoute === "refresh" && willStayActive;
280
+ const willReuse = willStayActive && !shouldRefresh;
281
+ return [
282
+ shouldRefresh && {
283
+ isActive: false,
284
+ startTeardown: [],
285
+ },
286
+ {
287
+ isActive: willActivate,
288
+ wasActive: wasActivated,
289
+ },
290
+ willReuse && {
291
+ // Same route doesn't set `isActive`, so call `startReady` here
292
+ startReady: [],
293
+ },
294
+ {
295
+ firePromiseEvent: [
296
+ "spa-route-provision",
297
+ () => doProvision(newProvision),
298
+ ],
299
+ },
300
+ ];
301
+ },
302
+ })
303
+ /* Retitle the live page when a Quark rule (or JS) rewrites the title of the
304
+ route that is already on screen. An inactive route waits for activation,
305
+ which the manager picks up at the end of its route update. `element` over
306
+ a destructure: `closest` needs its receiver. */
307
+ .onPropChanged("documentTitle", (element) => {
308
+ if (!element.isActive) return;
309
+ // Same family: the manager owns `document.title`
310
+ (
311
+ element.closest("spa-manager") as unknown as {
312
+ syncDocumentTitle: () => void;
313
+ } | null
314
+ )?.syncDocumentTitle();
315
+ })
316
+ .onPropSet("readyPromiseObject", ({ readyPromiseObject, setScroll }) =>
317
+ // Reject = aborted mid-flight; scroll only on successful ready
318
+ readyPromiseObject.promise.then(setScroll, () => {})
319
+ )
320
+ .onEventDefault("spa-route-provision", (_, { detail }) => {
321
+ detail();
322
+ });
@@ -0,0 +1,24 @@
1
+ @import "@excom/listenable-element/src/index.css";
2
+ @import "@excom/renderable-element/src/index.css";
3
+
4
+ /**
5
+ * Styles for `<spa-route>` and `<spa-a>`.
6
+ * @element spa-route
7
+ */
8
+
9
+ /* Aliases */
10
+ @custom-selector :--spa-route spa-route, .tag-spa-route;
11
+ @custom-selector :--spa-a spa-a, .tag-spa-a;
12
+
13
+ @define-mixin module-spa-route {
14
+ :--spa-route {
15
+ display: block;
16
+ &:not([is-active][was-active]) {
17
+ /* Don't hide the route that's being re-activated */
18
+ @mixin renderable-element;
19
+ }
20
+ }
21
+ :--spa-a {
22
+ @mixin listenable-element;
23
+ }
24
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,31 @@
1
+ const searchParamsMatch = (
2
+ searchParamsA: URLSearchParams,
3
+ searchParamsB: URLSearchParams
4
+ ) => {
5
+ if (searchParamsA.size !== searchParamsB.size) {
6
+ return false;
7
+ }
8
+ for (const [key, value] of searchParamsA.entries()) {
9
+ if (searchParamsB.get(key) !== value) {
10
+ return false;
11
+ }
12
+ }
13
+ return true;
14
+ };
15
+
16
+ export const urlMatchesHref = (
17
+ url: string,
18
+ href: string,
19
+ { ignoreHash = false }: { ignoreHash?: boolean } = {}
20
+ ) => {
21
+ if (url === href) {
22
+ return true;
23
+ }
24
+ const urlObj = new URL(url, location.origin);
25
+ const hrefObj = new URL(href, location.origin);
26
+ return (
27
+ urlObj.pathname === hrefObj.pathname &&
28
+ searchParamsMatch(urlObj.searchParams, hrefObj.searchParams) &&
29
+ (ignoreHash || urlObj.hash === hrefObj.hash)
30
+ );
31
+ };