@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-manager.ts
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SpaRoute as TSpaRoute,
|
|
3
|
+
SpaRouteProvisionEvent,
|
|
4
|
+
} from "./spa-route";
|
|
5
|
+
import { type KitRouteData, kitRouter } from "@excom/kit-router";
|
|
6
|
+
import { ConstructorType, Neutron, TEvent } from "@excom/neutron";
|
|
7
|
+
import type {
|
|
8
|
+
RenderableErrorEvent,
|
|
9
|
+
RenderableRenderEvent,
|
|
10
|
+
RenderableUnrenderEvent,
|
|
11
|
+
} from "@excom/renderable-element";
|
|
12
|
+
import { RoutableElement } from "@excom/routable-element";
|
|
13
|
+
|
|
14
|
+
type THTMLSpaRouteElement = typeof TSpaRoute.CustomElement;
|
|
15
|
+
|
|
16
|
+
export type SpaManagerTransitionDetail = {
|
|
17
|
+
transition: ViewTransition;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SpaManagerWillTransitionEvent = TEvent & {
|
|
21
|
+
type: "spa-manager-will-transition";
|
|
22
|
+
detail: void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SpaManagerTransitionEvent = TEvent & {
|
|
26
|
+
type: "spa-manager-transition";
|
|
27
|
+
detail: SpaManagerTransitionDetail;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SpaManagerRenderedEvent = TEvent & {
|
|
31
|
+
type: "spa-manager-rendered";
|
|
32
|
+
detail: void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SpaManagerErrorEvent = TEvent & {
|
|
36
|
+
type: "spa-manager-error";
|
|
37
|
+
detail: unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type SpaManagerPushEvent = TEvent & {
|
|
41
|
+
type: "spa-manager-push";
|
|
42
|
+
detail: void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type SpaManagerReplaceEvent = TEvent & {
|
|
46
|
+
type: "spa-manager-replace";
|
|
47
|
+
detail: void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type SpaManagerBackEvent = TEvent & {
|
|
51
|
+
type: "spa-manager-back";
|
|
52
|
+
detail: void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type SpaManagerForwardEvent = TEvent & {
|
|
56
|
+
type: "spa-manager-forward";
|
|
57
|
+
detail: void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type SpaManagerRouteListenEvents =
|
|
61
|
+
| SpaRouteProvisionEvent
|
|
62
|
+
| RenderableRenderEvent
|
|
63
|
+
| RenderableUnrenderEvent
|
|
64
|
+
| RenderableErrorEvent;
|
|
65
|
+
|
|
66
|
+
const clamp = (value, min, max) => {
|
|
67
|
+
return Math.min(Math.max(Math.round(value), min), max);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Coordinates sibling `<spa-route>` children: batches their render / unrender into one View Transition per navigation, re-emits nav lifecycle events, and optionally handles touch edge-swipe back / forward.
|
|
72
|
+
*
|
|
73
|
+
* @summary SPA shell — view transitions, swipe nav, route orchestration.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* <spa-manager overscroll-behavior-x="navigate">
|
|
77
|
+
* <spa-route route-href="/"><template>Home</template></spa-route>
|
|
78
|
+
* <spa-route route-href="/about"><template>About</template></spa-route>
|
|
79
|
+
* </spa-manager>
|
|
80
|
+
*
|
|
81
|
+
* @descendant spa-route - Routes this manager coordinates. Their render lifecycle is intercepted and batched into one view transition per navigation.
|
|
82
|
+
*
|
|
83
|
+
* @fires spa-manager-will-transition - Cancelable. Once per navigation, just before the View Transition starts. `preventDefault()` skips the transition for this navigation.
|
|
84
|
+
* @type SpaManagerWillTransitionEvent
|
|
85
|
+
* @fires spa-manager-transition - After `document.startViewTransition()` is called.
|
|
86
|
+
* @type SpaManagerTransitionEvent
|
|
87
|
+
* @fires spa-manager-rendered - After the transition (or synchronous fallback) finishes and routes are settled.
|
|
88
|
+
* @type SpaManagerRenderedEvent
|
|
89
|
+
* @fires spa-manager-error - Re-emitted when a child fires `spa-route-error`. `event.detail` mirrors the source error.
|
|
90
|
+
* @type SpaManagerErrorEvent
|
|
91
|
+
* @fires spa-manager-push - On `pushState` navigations.
|
|
92
|
+
* @type SpaManagerPushEvent
|
|
93
|
+
* @fires spa-manager-replace - On `replaceState` navigations.
|
|
94
|
+
* @type SpaManagerReplaceEvent
|
|
95
|
+
* @fires spa-manager-back - On `back` navigations.
|
|
96
|
+
* @type SpaManagerBackEvent
|
|
97
|
+
* @fires spa-manager-forward - On `forward` navigations.
|
|
98
|
+
* @type SpaManagerForwardEvent
|
|
99
|
+
*
|
|
100
|
+
* @default-action spa-manager-will-transition - Starts the batched View Transition (or synchronous update when transitions are unavailable / opted out).
|
|
101
|
+
*
|
|
102
|
+
* @listens spa-route-render - Queues the child's render callback for the next batched transition.
|
|
103
|
+
* @type RenderableRenderEvent
|
|
104
|
+
* @listens spa-route-unrender - Queues the child's unrender callback (runs before renders so the outgoing route leaves first).
|
|
105
|
+
* @type RenderableUnrenderEvent
|
|
106
|
+
* @listens spa-route-provision - Queues a same-route param update (reuse) into the next batched transition.
|
|
107
|
+
* @type SpaRouteProvisionEvent
|
|
108
|
+
* @listens spa-route-error - Falls back to a non-transitioned update and re-emits as `spa-manager-error`.
|
|
109
|
+
* @type RenderableErrorEvent
|
|
110
|
+
*/
|
|
111
|
+
export const SpaManager = Neutron.compose([
|
|
112
|
+
RoutableElement,
|
|
113
|
+
Neutron({
|
|
114
|
+
tag: "spa-manager",
|
|
115
|
+
props: {
|
|
116
|
+
/**
|
|
117
|
+
* @option
|
|
118
|
+
* Animate the very first activation (cold load) with a View Transition. Off by default so the initial paint is instant.
|
|
119
|
+
*/
|
|
120
|
+
transitionFirstRender: Boolean,
|
|
121
|
+
/**
|
|
122
|
+
* @option
|
|
123
|
+
* Delay (ms) before starting the batched View Transition. Gives late
|
|
124
|
+
* sibling render/unrender events time to queue, or room for last-second
|
|
125
|
+
* DOM work. Unset / `null` starts synchronously.
|
|
126
|
+
*/
|
|
127
|
+
transitionDelay: Number,
|
|
128
|
+
/**
|
|
129
|
+
* @option
|
|
130
|
+
* Disable View Transitions for every child route.
|
|
131
|
+
*/
|
|
132
|
+
noTransition: Boolean,
|
|
133
|
+
/**
|
|
134
|
+
* @option
|
|
135
|
+
* Max wait (ms) for child render promises before forcing the transition to resolve. Raise for slow remote templates.
|
|
136
|
+
* @default 2000
|
|
137
|
+
*/
|
|
138
|
+
renderTimeout: {
|
|
139
|
+
type: Number,
|
|
140
|
+
// Safety timeout for an uncaught failure. High so slow template loads survive.
|
|
141
|
+
defaultValue: () => 2000,
|
|
142
|
+
},
|
|
143
|
+
/**
|
|
144
|
+
* @option
|
|
145
|
+
* Touch edge-swipe on touch devices. `none` blocks horizontal overscroll; `navigate` also calls back / forward past the threshold.
|
|
146
|
+
* @values none | navigate
|
|
147
|
+
*/
|
|
148
|
+
overscrollBehaviorX: String,
|
|
149
|
+
/**
|
|
150
|
+
* @option
|
|
151
|
+
* Edge inset (px) where a touch start counts as an edge swipe.
|
|
152
|
+
* @default 40
|
|
153
|
+
*/
|
|
154
|
+
overscrollXThreshold: {
|
|
155
|
+
type: Number,
|
|
156
|
+
defaultValue: () => 40,
|
|
157
|
+
},
|
|
158
|
+
/**
|
|
159
|
+
* @option
|
|
160
|
+
* Cap retained router history states (scroll positions, transition types, etc.).
|
|
161
|
+
*/
|
|
162
|
+
maxStates: Number,
|
|
163
|
+
/**
|
|
164
|
+
* @state
|
|
165
|
+
* Last navigation direction (`push` / `replace` / `back` / `forward`). Pick CSS transition styles from this.
|
|
166
|
+
* @values push | replace | back | forward
|
|
167
|
+
*/
|
|
168
|
+
lastMove: String,
|
|
169
|
+
/**
|
|
170
|
+
* @state
|
|
171
|
+
* URL of the currently active route.
|
|
172
|
+
*/
|
|
173
|
+
activeUrl: String,
|
|
174
|
+
/**
|
|
175
|
+
* @state
|
|
176
|
+
* A View Transition is in flight.
|
|
177
|
+
*/
|
|
178
|
+
isTransitioning: Boolean,
|
|
179
|
+
/**
|
|
180
|
+
* @state
|
|
181
|
+
* At least one render has committed. Gates `transition-first-render`; useful for hiding loading shells.
|
|
182
|
+
*/
|
|
183
|
+
hasRendered: Boolean,
|
|
184
|
+
/**
|
|
185
|
+
* @provision
|
|
186
|
+
* Current route payload. Not reflected as an attribute.
|
|
187
|
+
* @type KitRouteData
|
|
188
|
+
*/
|
|
189
|
+
provision: Object as unknown as ConstructorType<KitRouteData>,
|
|
190
|
+
router: Object as unknown as ConstructorType<typeof kitRouter>,
|
|
191
|
+
// private
|
|
192
|
+
executingTransition: { type: Boolean, attr: false },
|
|
193
|
+
// the batch right after an unbatched first paint (see pushRouteCallback)
|
|
194
|
+
_firstPaintPending: { type: Boolean, attr: false },
|
|
195
|
+
// the page's own <title>, captured before the first route title lands
|
|
196
|
+
_defaultTitle: { type: String, attr: false },
|
|
197
|
+
renderTimeoutId: {
|
|
198
|
+
type: Number as unknown as ConstructorType<
|
|
199
|
+
ReturnType<typeof setTimeout>
|
|
200
|
+
>,
|
|
201
|
+
attr: false,
|
|
202
|
+
},
|
|
203
|
+
transitionDelayId: {
|
|
204
|
+
type: Number as unknown as ConstructorType<
|
|
205
|
+
ReturnType<typeof setTimeout>
|
|
206
|
+
>,
|
|
207
|
+
attr: false,
|
|
208
|
+
},
|
|
209
|
+
_activeViewTransition: {
|
|
210
|
+
type: Object as unknown as ConstructorType<ViewTransition | null>,
|
|
211
|
+
attr: false,
|
|
212
|
+
},
|
|
213
|
+
_routeCallbacks: Object as unknown as ConstructorType<{
|
|
214
|
+
provision: Array<() => void>;
|
|
215
|
+
render: Array<() => void>;
|
|
216
|
+
unrender: Array<() => void>;
|
|
217
|
+
}>,
|
|
218
|
+
attemptTransitionDebounced: Function,
|
|
219
|
+
swipeTracker: Object as unknown as ConstructorType<{
|
|
220
|
+
identifier: number;
|
|
221
|
+
startX: number;
|
|
222
|
+
startY: number;
|
|
223
|
+
}>,
|
|
224
|
+
routeHref: {
|
|
225
|
+
// Override RoutableElement
|
|
226
|
+
type: RegExp,
|
|
227
|
+
attr: false,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
])
|
|
232
|
+
.defineMethods({
|
|
233
|
+
hasCallbacks: ({ _routeCallbacks }) => ({
|
|
234
|
+
returns: Object.values(_routeCallbacks!).some(
|
|
235
|
+
(callbacks) => callbacks.length > 0
|
|
236
|
+
),
|
|
237
|
+
}),
|
|
238
|
+
pushRouteCallback: (
|
|
239
|
+
element,
|
|
240
|
+
e,
|
|
241
|
+
type: "provision" | "render" | "unrender",
|
|
242
|
+
callback
|
|
243
|
+
) => {
|
|
244
|
+
e.stopPropagation();
|
|
245
|
+
if (shouldTransition(element, e.target as THTMLSpaRouteElement)) {
|
|
246
|
+
const shouldStartTransition =
|
|
247
|
+
// @ts-ignore TODO defineMethods
|
|
248
|
+
!element.isTransitioning && !element.hasCallbacks();
|
|
249
|
+
const _routeCallbacks = {
|
|
250
|
+
provision: [...(element._routeCallbacks?.provision || [])],
|
|
251
|
+
render: [...(element._routeCallbacks?.render || [])],
|
|
252
|
+
unrender: [...(element._routeCallbacks?.unrender || [])],
|
|
253
|
+
};
|
|
254
|
+
_routeCallbacks[type]!.push(callback);
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
/* Rapid back/forward (or any nav) during a transition: skip the
|
|
257
|
+
current View Transition so `finished` resolves and `doCleanup`
|
|
258
|
+
can drain the newly queued callbacks now. */
|
|
259
|
+
if (element.isTransitioning) {
|
|
260
|
+
element._activeViewTransition?.skipTransition?.();
|
|
261
|
+
}
|
|
262
|
+
return [
|
|
263
|
+
{ _routeCallbacks },
|
|
264
|
+
// Start a new transition only when nothing is in flight
|
|
265
|
+
shouldStartTransition && {
|
|
266
|
+
emit: ["spa-manager-will-transition"],
|
|
267
|
+
},
|
|
268
|
+
];
|
|
269
|
+
} else {
|
|
270
|
+
/* First paint runs unbatched; the rest of the same activation
|
|
271
|
+
(`spa-route-provision`) is batched next, and that batch must not
|
|
272
|
+
start a View Transition — a hard load used to cross-fade the page. */
|
|
273
|
+
return { hasRendered: true, _firstPaintPending: !element.hasRendered };
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
// @ts-ignore TODO defineMethods
|
|
277
|
+
doCleanup: ({ _routeCallbacks, hasCallbacks }) => {
|
|
278
|
+
// More callbacks arrived mid-transition: run another transition
|
|
279
|
+
return [
|
|
280
|
+
{ _activeViewTransition: null },
|
|
281
|
+
...(hasCallbacks()
|
|
282
|
+
? [{ updateRoutes: [true] }]
|
|
283
|
+
: [
|
|
284
|
+
{
|
|
285
|
+
executingTransition: false,
|
|
286
|
+
},
|
|
287
|
+
// Routes have settled: the active set is final
|
|
288
|
+
{
|
|
289
|
+
syncDocumentTitle: [],
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
emit: ["spa-manager-rendered"],
|
|
293
|
+
},
|
|
294
|
+
]),
|
|
295
|
+
];
|
|
296
|
+
},
|
|
297
|
+
/**
|
|
298
|
+
* `document.title` = the last active descendant route carrying a
|
|
299
|
+
* `document-title`, else the page's own `<title>`.
|
|
300
|
+
*/
|
|
301
|
+
syncDocumentTitle: (element) => {
|
|
302
|
+
const parentManager = element.parentElement?.closest(
|
|
303
|
+
"spa-manager"
|
|
304
|
+
) as unknown as { syncDocumentTitle: () => void } | null;
|
|
305
|
+
// One document, one title: a nested manager computes nothing, it hands
|
|
306
|
+
// the job to the outermost one, which sees every active route.
|
|
307
|
+
if (parentManager) {
|
|
308
|
+
parentManager.syncDocumentTitle();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Attribute reflection is synchronous, but the prop is the source of truth
|
|
312
|
+
const titled = (
|
|
313
|
+
Array.from(
|
|
314
|
+
element.querySelectorAll("spa-route")
|
|
315
|
+
) as THTMLSpaRouteElement[]
|
|
316
|
+
)
|
|
317
|
+
// Last in document order wins, so a nested route beats its ancestor
|
|
318
|
+
.findLast((route) => route.isActive && route.documentTitle);
|
|
319
|
+
// No route has ever claimed the title: leave the page's own alone
|
|
320
|
+
if (!titled && element._defaultTitle == null) return;
|
|
321
|
+
if (element._defaultTitle == null) {
|
|
322
|
+
element._defaultTitle = document.title;
|
|
323
|
+
}
|
|
324
|
+
const title = titled?.documentTitle ?? element._defaultTitle;
|
|
325
|
+
if (document.title !== title) {
|
|
326
|
+
document.title = title;
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
_updateRoutes: (element, doTransition: boolean) => {
|
|
330
|
+
// @ts-ignore TODO defineMethods
|
|
331
|
+
const { doCleanup, provision, fireTransition } = element;
|
|
332
|
+
// Callbacks that arrived mid-transition are picked up by `doCleanup`
|
|
333
|
+
const callbacks = [
|
|
334
|
+
// Order matters: unrender first
|
|
335
|
+
...(element._routeCallbacks?.unrender || []),
|
|
336
|
+
// then render
|
|
337
|
+
...(element._routeCallbacks?.render || []),
|
|
338
|
+
// `provision` last: otherwise Quark starts rendering before unrender/render wipes the tree.
|
|
339
|
+
...(element._routeCallbacks?.provision || []),
|
|
340
|
+
];
|
|
341
|
+
const runUpdate = () => {
|
|
342
|
+
const promises = callbacks
|
|
343
|
+
.map((cb) => cb())
|
|
344
|
+
.filter((p: any) => p instanceof Promise);
|
|
345
|
+
return new Promise((resolve) => {
|
|
346
|
+
if (!promises.length) {
|
|
347
|
+
resolve(true);
|
|
348
|
+
} else {
|
|
349
|
+
// Aborted renders reject their ready promise; settle either way
|
|
350
|
+
// so a skipped / canceled nav doesn't hang until `renderTimeout`.
|
|
351
|
+
Promise.allSettled(promises).then(() => {
|
|
352
|
+
resolve(true);
|
|
353
|
+
});
|
|
354
|
+
element.renderTimeoutId = setTimeout(() => {
|
|
355
|
+
resolve(true);
|
|
356
|
+
}, element.renderTimeout!);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
};
|
|
360
|
+
const firstPaint = element._firstPaintPending;
|
|
361
|
+
element._firstPaintPending = false;
|
|
362
|
+
// Skip if the browser already has a UA visual transition
|
|
363
|
+
if (
|
|
364
|
+
doTransition &&
|
|
365
|
+
!firstPaint &&
|
|
366
|
+
!provision?.event?.hasUAVisualTransition
|
|
367
|
+
) {
|
|
368
|
+
element.isTransitioning = true;
|
|
369
|
+
const viewTransition = document.startViewTransition({
|
|
370
|
+
update: runUpdate,
|
|
371
|
+
types: [provision?.move ? "route-" + provision.move : undefined]
|
|
372
|
+
.concat(
|
|
373
|
+
provision?.move === "back"
|
|
374
|
+
? provision?.next?.ttypes || []
|
|
375
|
+
: provision?.active?.ttypes || []
|
|
376
|
+
)
|
|
377
|
+
.filter((type): type is string => type !== undefined),
|
|
378
|
+
});
|
|
379
|
+
viewTransition.finished.then(() => {
|
|
380
|
+
doCleanup();
|
|
381
|
+
});
|
|
382
|
+
fireTransition(viewTransition);
|
|
383
|
+
} else {
|
|
384
|
+
runUpdate().then(() => {
|
|
385
|
+
doCleanup();
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
_routeCallbacks: {
|
|
390
|
+
provision: [],
|
|
391
|
+
render: [],
|
|
392
|
+
unrender: [],
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
resetTransitionDelayId: ({ transitionDelayId }, clear: boolean) => {
|
|
397
|
+
if (clear && transitionDelayId) {
|
|
398
|
+
clearTimeout(transitionDelayId);
|
|
399
|
+
}
|
|
400
|
+
return { transitionDelayId: null };
|
|
401
|
+
},
|
|
402
|
+
updateRoutes: (
|
|
403
|
+
// @ts-ignore TODO defineMethods
|
|
404
|
+
{ _updateRoutes, transitionDelay, resetTransitionDelayId },
|
|
405
|
+
doTransition: boolean
|
|
406
|
+
) => {
|
|
407
|
+
resetTransitionDelayId(true);
|
|
408
|
+
const cb = () => {
|
|
409
|
+
resetTransitionDelayId(false);
|
|
410
|
+
_updateRoutes(doTransition);
|
|
411
|
+
};
|
|
412
|
+
if (transitionDelay == null) {
|
|
413
|
+
cb();
|
|
414
|
+
} else {
|
|
415
|
+
return { transitionDelayId: setTimeout(cb, transitionDelay) };
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
fireTransition: (_, viewTransition: ViewTransition) => ({
|
|
419
|
+
_activeViewTransition: viewTransition,
|
|
420
|
+
emit: [
|
|
421
|
+
"spa-manager-transition",
|
|
422
|
+
{ detail: { transition: viewTransition } },
|
|
423
|
+
],
|
|
424
|
+
}),
|
|
425
|
+
routeChanged: ({ activeUrl }, routeData: KitRouteData) => ({
|
|
426
|
+
...(activeUrl && { lastMove: routeData.move }),
|
|
427
|
+
activeUrl: routeData.active.url,
|
|
428
|
+
provision: routeData,
|
|
429
|
+
...(routeData.move && { emit: ["spa-manager-" + routeData.move] }),
|
|
430
|
+
}),
|
|
431
|
+
handleTouchStart: ({ overscrollXThreshold }, e: TouchEvent) => {
|
|
432
|
+
if (e.touches.length === 1) {
|
|
433
|
+
const touch = e.touches[0];
|
|
434
|
+
if (
|
|
435
|
+
touch.clientX < overscrollXThreshold ||
|
|
436
|
+
touch.clientX > window.innerWidth - overscrollXThreshold
|
|
437
|
+
) {
|
|
438
|
+
return {
|
|
439
|
+
swipeTracker: {
|
|
440
|
+
identifier: touch.identifier,
|
|
441
|
+
startX: clamp(touch.clientX, 0, window.innerWidth),
|
|
442
|
+
startY: clamp(touch.clientY, 0, window.innerHeight),
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
handleTouchMove: (
|
|
449
|
+
{ swipeTracker, overscrollBehaviorX, overscrollXThreshold, router },
|
|
450
|
+
e: TouchEvent
|
|
451
|
+
) => {
|
|
452
|
+
if (!swipeTracker) return null;
|
|
453
|
+
const touch = Array.from(e.touches).find(
|
|
454
|
+
({ identifier }) => identifier === swipeTracker.identifier
|
|
455
|
+
);
|
|
456
|
+
if (!touch) return null;
|
|
457
|
+
|
|
458
|
+
const diffX = touch.clientX - swipeTracker.startX;
|
|
459
|
+
const diffY = touch.clientY - swipeTracker.startY;
|
|
460
|
+
const Y_TOLERANCE = 0.4;
|
|
461
|
+
const shouldPrevent =
|
|
462
|
+
Math.abs(diffX) >= 2 &&
|
|
463
|
+
Math.abs(diffX) >= Math.round(diffY * Y_TOLERANCE);
|
|
464
|
+
const shouldNavigate =
|
|
465
|
+
shouldPrevent && overscrollBehaviorX === "navigate";
|
|
466
|
+
const draggedRight =
|
|
467
|
+
// went right
|
|
468
|
+
diffX > 0 &&
|
|
469
|
+
// started before threshold
|
|
470
|
+
swipeTracker.startX < overscrollXThreshold &&
|
|
471
|
+
// ended after threshold
|
|
472
|
+
touch.clientX > overscrollXThreshold;
|
|
473
|
+
const draggedLeft =
|
|
474
|
+
// dragged left
|
|
475
|
+
diffX < 0 &&
|
|
476
|
+
// started before threshold
|
|
477
|
+
swipeTracker.startX > window.innerWidth - overscrollXThreshold &&
|
|
478
|
+
// ended after threshold
|
|
479
|
+
touch.clientX < window.innerWidth - overscrollXThreshold;
|
|
480
|
+
|
|
481
|
+
if (shouldPrevent) {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
}
|
|
484
|
+
if (shouldNavigate && (draggedRight || draggedLeft)) {
|
|
485
|
+
const method = draggedRight
|
|
486
|
+
? "back"
|
|
487
|
+
: draggedLeft
|
|
488
|
+
? "forward"
|
|
489
|
+
: undefined;
|
|
490
|
+
if (method) {
|
|
491
|
+
router![method]();
|
|
492
|
+
return { swipeTracker: null };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
handleTouchEnd: () => {
|
|
497
|
+
return {
|
|
498
|
+
swipeTracker: null,
|
|
499
|
+
};
|
|
500
|
+
},
|
|
501
|
+
})
|
|
502
|
+
.onConstructed(() => ({
|
|
503
|
+
router: kitRouter,
|
|
504
|
+
_routeCallbacks: {
|
|
505
|
+
provision: [],
|
|
506
|
+
render: [],
|
|
507
|
+
unrender: [],
|
|
508
|
+
},
|
|
509
|
+
}))
|
|
510
|
+
.onConnected(
|
|
511
|
+
// Run RoutableElement logic
|
|
512
|
+
({ wasMounted }) => !wasMounted && { routeHref: new RegExp(".*") }
|
|
513
|
+
)
|
|
514
|
+
.onPropChanged(
|
|
515
|
+
"overscrollBehaviorX",
|
|
516
|
+
({
|
|
517
|
+
overscrollBehaviorX,
|
|
518
|
+
handleTouchStart,
|
|
519
|
+
handleTouchMove,
|
|
520
|
+
handleTouchEnd,
|
|
521
|
+
}) => {
|
|
522
|
+
// Skip on non-touch devices
|
|
523
|
+
if (!("ontouchstart" in window || navigator.maxTouchPoints > 0))
|
|
524
|
+
return null;
|
|
525
|
+
|
|
526
|
+
const shouldListen = ["none", "navigate"].includes(
|
|
527
|
+
overscrollBehaviorX as string
|
|
528
|
+
);
|
|
529
|
+
const opts = { target: document, passive: false };
|
|
530
|
+
return {
|
|
531
|
+
toggleListeners: [
|
|
532
|
+
["touchstart", handleTouchStart, shouldListen, opts],
|
|
533
|
+
["touchmove", handleTouchMove, shouldListen, opts],
|
|
534
|
+
["touchend", handleTouchEnd, shouldListen, opts],
|
|
535
|
+
["touchcancel", handleTouchEnd, shouldListen, opts],
|
|
536
|
+
],
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
)
|
|
540
|
+
.onPropChanged("maxStates", ({ maxStates, router }) => {
|
|
541
|
+
router!.MAX_STATES = maxStates ?? router!.DEFAULT_MAX_STATES;
|
|
542
|
+
})
|
|
543
|
+
.onPropSet("executingTransition", () => ({
|
|
544
|
+
updateRoutes: [true],
|
|
545
|
+
}))
|
|
546
|
+
.onPropUnset(
|
|
547
|
+
"executingTransition",
|
|
548
|
+
({ renderTimeoutId, resetTransitionDelayId, _activeViewTransition }) => {
|
|
549
|
+
if (renderTimeoutId) {
|
|
550
|
+
clearTimeout(renderTimeoutId);
|
|
551
|
+
}
|
|
552
|
+
// @ts-ignore TODO fix `onPropUnset` typing for boolean props
|
|
553
|
+
resetTransitionDelayId(true);
|
|
554
|
+
// @ts-ignore TODO fix `onPropUnset` typing for boolean props
|
|
555
|
+
_activeViewTransition?.skipTransition?.();
|
|
556
|
+
return {
|
|
557
|
+
renderTimeoutId: null,
|
|
558
|
+
_activeViewTransition: null,
|
|
559
|
+
_routeCallbacks: {
|
|
560
|
+
provision: [],
|
|
561
|
+
render: [],
|
|
562
|
+
unrender: [],
|
|
563
|
+
},
|
|
564
|
+
isTransitioning: false,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
)
|
|
568
|
+
.onEvent("spa-route-unrender", (_, e) => ({
|
|
569
|
+
pushRouteCallback: [e, "unrender", e.detail],
|
|
570
|
+
}))
|
|
571
|
+
.onEvent("spa-route-render", (_, e) => ({
|
|
572
|
+
pushRouteCallback: [e, "render", e.detail],
|
|
573
|
+
}))
|
|
574
|
+
.onEvent("spa-route-provision", (_, e) => ({
|
|
575
|
+
pushRouteCallback: [e, "provision", e.detail],
|
|
576
|
+
}))
|
|
577
|
+
.onEventDefault("spa-manager-will-transition", () => ({
|
|
578
|
+
hasRendered: true,
|
|
579
|
+
executingTransition: true,
|
|
580
|
+
}))
|
|
581
|
+
.onEvent("spa-route-error", (_, e) => {
|
|
582
|
+
e.stopPropagation();
|
|
583
|
+
return {
|
|
584
|
+
updateRoutes: [false],
|
|
585
|
+
emit: ["spa-manager-error", { detail: e.detail }],
|
|
586
|
+
};
|
|
587
|
+
});
|
|
588
|
+
const prefersReducedMotion =
|
|
589
|
+
// @ts-ignore make ts unhappy, but this supports legacy browsers
|
|
590
|
+
window.matchMedia?.(`(prefers-reduced-motion: reduce)`) === true ||
|
|
591
|
+
window.matchMedia?.(`(prefers-reduced-motion: reduce)`).matches === true;
|
|
592
|
+
|
|
593
|
+
function shouldTransition(
|
|
594
|
+
// typeof SpaManager.CustomElement
|
|
595
|
+
manager: any,
|
|
596
|
+
route: THTMLSpaRouteElement
|
|
597
|
+
): boolean {
|
|
598
|
+
return (
|
|
599
|
+
!!document.startViewTransition &&
|
|
600
|
+
!prefersReducedMotion &&
|
|
601
|
+
!manager.noTransition &&
|
|
602
|
+
!route.noTransition &&
|
|
603
|
+
(manager.transitionFirstRender || manager.hasRendered)
|
|
604
|
+
);
|
|
605
|
+
}
|