@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.
- package/.rush/temp/chunked-rush-logs/spa-route.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/spa-route.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/spa-route.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +5 -0
- package/index.css +5 -0
- package/index.ts +29 -0
- package/package.json +48 -0
- package/rush-logs/spa-route.apply-exports.cache.log +1 -0
- package/rush-logs/spa-route.apply-exports.log +1 -0
- package/rush-logs/spa-route.build_docs.cache.log +1 -0
- package/rush-logs/spa-route.build_docs.log +1 -0
- package/rush-logs/spa-route.build_package-metas.cache.log +1 -0
- package/rush-logs/spa-route.build_package-metas.log +1 -0
- package/spa-a.ts +124 -0
- package/spa-manager.ts +605 -0
- package/spa-route.ts +322 -0
- package/src/spa-route.css +24 -0
- package/src/utils.ts +31 -0
- package/support/custom-elements.json +953 -0
- package/support/dist-docs/spa-a.md +40 -0
- package/support/dist-docs/spa-manager.md +66 -0
- package/support/dist-docs/spa-route.md +408 -0
- package/support/docs/README.md +314 -0
- package/support/package-meta.json +789 -0
- package/support/tests/spa-navigation.test.ts +1076 -0
- package/support/tests/spa-route.test.ts +766 -0
- package/support/tests/spa-title.test.ts +225 -0
- 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
|
+
};
|