@keenmate/svelte-spa-router 1.0.1

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/Router.svelte ADDED
@@ -0,0 +1,644 @@
1
+ <script context="module">
2
+ import {derived, get, readable, writable} from "svelte/store"
3
+ import {tick} from "svelte"
4
+ import {SvelteSPARouterNavigationEvent} from "./constants.js"
5
+ import {joinPaths} from "./helpers/url-helpers.js"
6
+
7
+ export const HashRoutingEnabled = writable(true)
8
+ export const BasePath = writable("/")
9
+
10
+ /**
11
+ * Returns the current location from the hash.
12
+ *
13
+ * @returns {Location} Location object
14
+ * @private
15
+ */
16
+ function getLocation() {
17
+ const hashRoutingEnabled = get(HashRoutingEnabled)
18
+ const basePath = get(BasePath)
19
+ let location
20
+
21
+ if (hashRoutingEnabled) {
22
+ const hashPosition = window.location.href.indexOf("#/")
23
+ location = (hashPosition > -1) ?
24
+ window.location.href.substr(hashPosition + 1) :
25
+ "/"
26
+ } else {
27
+ const startsWithPrefix = window.location.pathname.startsWith(basePath)
28
+ if (!hashRoutingEnabled && !startsWithPrefix) {
29
+ throw new Error(`Hash routing disabled and location: "${window.location.href}" does not start with expected base path: "${get(BasePath)}"`)
30
+ }
31
+
32
+ location = "/" + window.location.pathname.substring(basePath.length)
33
+ }
34
+
35
+ // Check if there's a querystring
36
+ const qsPosition = location.indexOf("?")
37
+ let querystring = ""
38
+ if (qsPosition > -1) {
39
+ querystring = location.substr(qsPosition + 1)
40
+ location = location.substr(0, qsPosition)
41
+ }
42
+
43
+ return {location, querystring}
44
+ }
45
+
46
+ /**
47
+ * Readable store that returns the current full location (incl. querystring)
48
+ */
49
+ export const loc = readable(
50
+ null,
51
+ // eslint-disable-next-line prefer-arrow-callback
52
+ function start(set) {
53
+ set(getLocation())
54
+
55
+ const eventName = get(HashRoutingEnabled) ?
56
+ "hashchange" :
57
+ SvelteSPARouterNavigationEvent
58
+ console.log("Setting loc")
59
+ const update = () => {
60
+ console.log("Updating location", getLocation())
61
+ set(getLocation())
62
+ }
63
+ window.addEventListener(eventName, update, false)
64
+
65
+ return function stop() {
66
+ window.removeEventListener(eventName, update, false)
67
+ }
68
+ }
69
+ )
70
+
71
+ /**
72
+ * Readable store that returns the current location
73
+ */
74
+ export const location = derived(
75
+ loc,
76
+ (_loc) => _loc.location
77
+ )
78
+
79
+ /**
80
+ * Readable store that returns the current querystring
81
+ */
82
+ export const querystring = derived(
83
+ loc,
84
+ (_loc) => _loc.querystring
85
+ )
86
+
87
+ /**
88
+ * Store that returns the currently-matched params.
89
+ * Despite this being writable, consumers should not change the value of the store.
90
+ * It is exported as a readable store only (in the typings file)
91
+ */
92
+ export const params = writable(undefined)
93
+
94
+ /**
95
+ * Navigates to a new page programmatically.
96
+ *
97
+ * @param {string} location - Path to navigate to (must start with `/` or '#/')
98
+ * @return {Promise<void>} Promise that resolves after the page navigation has completed
99
+ */
100
+ export async function push(location) {
101
+ return jediForcePush(location)
102
+ }
103
+
104
+ /**
105
+ * Navigates back in history (equivalent to pressing the browser's back button).
106
+ *
107
+ * @return {Promise<void>} Promise that resolves after the page navigation has completed
108
+ */
109
+ export async function pop() {
110
+ // Execute this code when the current call stack is complete
111
+ await tick()
112
+
113
+ window.history.back()
114
+ }
115
+
116
+ /**
117
+ * Replaces the current page but without modifying the history stack.
118
+ *
119
+ * @param {string} location - Path to navigate to (must start with `/` or '#/')
120
+ * @return {Promise<void>} Promise that resolves after the page navigation has completed
121
+ */
122
+ export async function replace(location) {
123
+ return jediForcePush(location, true)
124
+ }
125
+
126
+ async function jediForcePush(location, shouldReplace = false) {
127
+ const hashRoutingEnabled = get(HashRoutingEnabled)
128
+ const basePath = get(BasePath)
129
+
130
+ if (hashRoutingEnabled) {
131
+ if (!location || location.length < 1 || !/^(\/|#\/)/.test(location)) {
132
+ throw Error("Invalid parameter location")
133
+ }
134
+ } else {
135
+ if (!location || location.length < 1 || !/^\//.test(location)) {
136
+ throw Error("Invalid parameter location")
137
+ }
138
+ }
139
+
140
+ // Execute this code when the current call stack is complete
141
+ await tick()
142
+
143
+ const newHistoryState = {
144
+ ...window.history.state,
145
+ __svelte_spa_router_scrollX: window.scrollX,
146
+ __svelte_spa_router_scrollY: window.scrollY
147
+ }
148
+
149
+ const doNavigate = shouldReplace ?
150
+ window.history.replaceState.bind(window.history) :
151
+ window.history.pushState.bind(window.history)
152
+
153
+ if (hashRoutingEnabled) {
154
+ // Note: this will include scroll state in history even when restoreScrollState is false
155
+ doNavigate(newHistoryState, undefined)
156
+
157
+ if (!shouldReplace) {
158
+ window.location.hash = (location.charAt(0) === "#" ? "" : "#") + location
159
+ } else {
160
+ window.dispatchEvent(new Event("hashchange"))
161
+ }
162
+ } else {
163
+ if (!location.startsWith(basePath)) {
164
+ location = joinPaths(basePath, location)
165
+ }
166
+
167
+ console.log(
168
+ "Before navigate",
169
+ window.history,
170
+ window.history.pushState,
171
+ doNavigate,
172
+ newHistoryState,
173
+ undefined,
174
+ location
175
+ )
176
+ // window.history.pushState(newHistoryState, undefined, location)
177
+ doNavigate(newHistoryState, undefined, location)
178
+
179
+ window.dispatchEvent(new Event(SvelteSPARouterNavigationEvent))
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Dictionary with options for the link action.
185
+ * @typedef {Object} LinkActionOpts
186
+ * @property {string} href - A string to use in place of the link's href attribute. Using this allows for updating link's targets reactively.
187
+ * @property {boolean} disabled - If true, link is disabled
188
+ * @property {boolean} shouldReplace - If true, link will replace instead of push new history entry
189
+ */
190
+
191
+ /**
192
+ * Svelte Action that enables a link element (`<a>`) to use our history management.
193
+ *
194
+ * For example:
195
+ *
196
+ * ````html
197
+ * <a href="/books" use:link>View books</a>
198
+ * ````
199
+ *
200
+ * @param {HTMLElement} node - The target node (automatically set by Svelte). Must be an anchor tag (`<a>`) with a href attribute starting in `/`
201
+ * @param {string|LinkActionOpts|null} opts - Options object. For legacy reasons, we support a string too which will be the value for opts.href
202
+ */
203
+ export function link(node, opts = null) {
204
+ opts = linkOpts(opts)
205
+
206
+ // Only apply to <a> tags
207
+ if (!node || !node.tagName || node.tagName.toLowerCase() != "a") {
208
+ throw Error("Action \"link\" can only be used with <a> tags")
209
+ }
210
+
211
+ updateLink(node, opts)
212
+
213
+ if (!get(HashRoutingEnabled)) {
214
+ node.addEventListener("click", ev => {
215
+ ev.stopImmediatePropagation()
216
+ ev.preventDefault()
217
+
218
+ const shouldReplace = typeof opts !== "string" && opts.shouldReplace
219
+
220
+ jediForcePush(node.getAttribute("href"), shouldReplace)
221
+ window.dispatchEvent(new Event(SvelteSPARouterNavigationEvent))
222
+ }, {capture: true})
223
+ }
224
+
225
+ return {
226
+ update(updated) {
227
+ updated = linkOpts(updated)
228
+ updateLink(node, updated)
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Tries to restore the scroll state from the given history state.
235
+ *
236
+ * @param {{__svelte_spa_router_scrollX: number, __svelte_spa_router_scrollY: number}} [state] - The history state to restore from.
237
+ */
238
+ export function restoreScroll(state) {
239
+ // If this exists, then this is a back navigation: restore the scroll position
240
+ if (state) {
241
+ window.scrollTo(state.__svelte_spa_router_scrollX, state.__svelte_spa_router_scrollY)
242
+ } else {
243
+ // Otherwise this is a forward navigation: scroll to top
244
+ window.scrollTo(0, 0)
245
+ }
246
+ }
247
+
248
+ // Internal function used by the link function
249
+ function updateLink(node, opts) {
250
+ const basePath = get(BasePath)
251
+
252
+ let href = opts.href || node.getAttribute("href")
253
+
254
+ if (get(HashRoutingEnabled)) {
255
+ if (href && href.charAt(0) == "/") {
256
+ // Add # to the href attribute
257
+ href = "#" + href
258
+ } else if (!href || href.length < 2 || href.slice(0, 2) != "#/") {
259
+ throw Error("Invalid value for \"href\" attribute: " + href)
260
+ }
261
+ } else {
262
+ if (href && !href.startsWith(basePath)) {
263
+ href = joinPaths(basePath, href)
264
+ }
265
+ }
266
+
267
+ node.setAttribute("href", href)
268
+ node.addEventListener("click", (event) => {
269
+ // Prevent default anchor onclick behaviour
270
+ event.preventDefault()
271
+ if (!opts.disabled) {
272
+ scrollstateHistoryHandler(event.currentTarget.getAttribute("href"))
273
+ }
274
+ })
275
+ }
276
+
277
+ // Internal function that ensures the argument of the link action is always an object
278
+ function linkOpts(val) {
279
+ if (val && typeof val == "string") {
280
+ return {
281
+ href: val
282
+ }
283
+ } else {
284
+ return val || {}
285
+ }
286
+ }
287
+
288
+ /**
289
+ * The handler attached to an anchor tag responsible for updating the
290
+ * current history state with the current scroll state
291
+ *
292
+ * @param {string} href - Destination
293
+ */
294
+ function scrollstateHistoryHandler(href) {
295
+ // Setting the url (3rd arg) to href will break clicking for reasons, so don't try to do that
296
+ history.replaceState({
297
+ ...history.state,
298
+ __svelte_spa_router_scrollX: window.scrollX,
299
+ __svelte_spa_router_scrollY: window.scrollY
300
+ }, undefined)
301
+ // This will force an update as desired, but this time our scroll state will be attached
302
+ window.location.hash = href
303
+ }
304
+ </script>
305
+
306
+ {#if componentParams}
307
+ <svelte:component
308
+ this={component}
309
+ params={componentParams}
310
+ on:routeEvent
311
+ {...props}
312
+ />
313
+ {:else}
314
+ <svelte:component
315
+ this={component}
316
+ on:routeEvent
317
+ {...props}
318
+ />
319
+ {/if}
320
+
321
+ <script>
322
+ import {onDestroy, createEventDispatcher, afterUpdate} from "svelte"
323
+ import {parse} from "regexparam"
324
+
325
+ /**
326
+ * Dictionary of all routes, in the format `'/path': component`.
327
+ *
328
+ * For example:
329
+ * ````js
330
+ * import HomeRoute from './routes/HomeRoute.svelte'
331
+ * import BooksRoute from './routes/BooksRoute.svelte'
332
+ * import NotFoundRoute from './routes/NotFoundRoute.svelte'
333
+ * routes = {
334
+ * '/': HomeRoute,
335
+ * '/books': BooksRoute,
336
+ * '*': NotFoundRoute
337
+ * }
338
+ * ````
339
+ */
340
+ export let routes = {}
341
+
342
+ /**
343
+ * Optional prefix for the routes in this router. This is useful for example in the case of nested routers.
344
+ */
345
+ export let prefix = ""
346
+
347
+ /**
348
+ * If set to true, the router will restore scroll positions on back navigation
349
+ * and scroll to top on forward navigation.
350
+ */
351
+ export let restoreScrollState = false
352
+
353
+ /**
354
+ * Container for a route: path, component
355
+ */
356
+ class RouteItem {
357
+ /**
358
+ * Initializes the object and creates a regular expression from the path, using regexparam.
359
+ *
360
+ * @param {string} path - Path to the route (must start with '/' or '*')
361
+ * @param {SvelteComponent|WrappedComponent} component - Svelte component for the route, optionally wrapped
362
+ */
363
+ constructor(path, component) {
364
+ if (!component || (typeof component != "function" && (typeof component != "object" || component._sveltesparouter !== true))) {
365
+ throw Error("Invalid component object")
366
+ }
367
+
368
+ // Path must be a regular or expression, or a string starting with '/' or '*'
369
+ if (!path ||
370
+ (typeof path == "string" && (path.length < 1 || (path.charAt(0) != "/" && path.charAt(0) != "*"))) ||
371
+ (typeof path == "object" && !(path instanceof RegExp))
372
+ ) {
373
+ throw Error("Invalid value for \"path\" argument - strings must start with / or *")
374
+ }
375
+
376
+ const {pattern, keys} = parse(path)
377
+
378
+ this.path = path
379
+
380
+ // Check if the component is wrapped and we have conditions
381
+ if (typeof component == "object" && component._sveltesparouter === true) {
382
+ this.component = component.component
383
+ this.conditions = component.conditions || []
384
+ this.userData = component.userData
385
+ this.props = component.props || {}
386
+ } else {
387
+ // Convert the component to a function that returns a Promise, to normalize it
388
+ this.component = () => Promise.resolve(component)
389
+ this.conditions = []
390
+ this.props = {}
391
+ }
392
+
393
+ this._pattern = pattern
394
+ this._keys = keys
395
+ }
396
+
397
+ /**
398
+ * Checks if `path` matches the current route.
399
+ * If there's a match, will return the list of parameters from the URL (if any).
400
+ * In case of no match, the method will return `null`.
401
+ *
402
+ * @param {string} path - Path to test
403
+ * @returns {null|Object.<string, string>} List of paramters from the URL if there's a match, or `null` otherwise.
404
+ */
405
+ match(path) {
406
+ // If there's a prefix, check if it matches the start of the path.
407
+ // If not, bail early, else remove it before we run the matching.
408
+ if (prefix) {
409
+ if (typeof prefix == "string") {
410
+ if (path.startsWith(prefix)) {
411
+ path = path.substr(prefix.length) || "/"
412
+ } else {
413
+ return null
414
+ }
415
+ } else if (prefix instanceof RegExp) {
416
+ const match = path.match(prefix)
417
+ if (match && match[0]) {
418
+ path = path.substr(match[0].length) || "/"
419
+ } else {
420
+ return null
421
+ }
422
+ }
423
+ }
424
+
425
+ // Check if the pattern matches
426
+ const matches = this._pattern.exec(path)
427
+ if (matches === null) {
428
+ return null
429
+ }
430
+
431
+ // If the input was a regular expression, this._keys would be false, so return matches as is
432
+ if (this._keys === false) {
433
+ return matches
434
+ }
435
+
436
+ const out = {}
437
+ let i = 0
438
+ while (i < this._keys.length) {
439
+ // In the match parameters, URL-decode all values
440
+ try {
441
+ out[this._keys[i]] = decodeURIComponent(matches[i + 1] || "") || null
442
+ } catch (e) {
443
+ out[this._keys[i]] = null
444
+ }
445
+ i++
446
+ }
447
+ return out
448
+ }
449
+
450
+ /**
451
+ * Dictionary with route details passed to the pre-conditions functions, as well as the `routeLoading`, `routeLoaded` and `conditionsFailed` events
452
+ * @typedef {Object} RouteDetail
453
+ * @property {string|RegExp} route - Route matched as defined in the route definition (could be a string or a reguar expression object)
454
+ * @property {string} location - Location path
455
+ * @property {string} querystring - Querystring from the hash
456
+ * @property {object} [userData] - Custom data passed by the user
457
+ * @property {SvelteComponent} [component] - Svelte component (only in `routeLoaded` events)
458
+ * @property {string} [name] - Name of the Svelte component (only in `routeLoaded` events)
459
+ */
460
+
461
+ /**
462
+ * Executes all conditions (if any) to control whether the route can be shown. Conditions are executed in the order they are defined, and if a condition fails, the following ones aren't executed.
463
+ *
464
+ * @param {RouteDetail} detail - Route detail
465
+ * @returns {boolean} Returns true if all the conditions succeeded
466
+ */
467
+ async checkConditions(detail) {
468
+ for (let i = 0; i < this.conditions.length; i++) {
469
+ if (!(await this.conditions[i](detail))) {
470
+ return false
471
+ }
472
+ }
473
+
474
+ return true
475
+ }
476
+ }
477
+
478
+ // Set up all routes
479
+ const routesList = []
480
+ if (routes instanceof Map) {
481
+ // If it's a map, iterate on it right away
482
+ routes.forEach((route, path) => {
483
+ routesList.push(new RouteItem(path, route))
484
+ })
485
+ } else {
486
+ // We have an object, so iterate on its own properties
487
+ Object.keys(routes).forEach((path) => {
488
+ routesList.push(new RouteItem(path, routes[path]))
489
+ })
490
+ }
491
+
492
+ // Props for the component to render
493
+ let component = null
494
+ let componentParams = null
495
+ let props = {}
496
+
497
+ // Event dispatcher from Svelte
498
+ const dispatch = createEventDispatcher()
499
+
500
+ // Just like dispatch, but executes on the next iteration of the event loop
501
+ async function dispatchNextTick(name, detail) {
502
+ // Execute this code when the current call stack is complete
503
+ await tick()
504
+ dispatch(name, detail)
505
+ }
506
+
507
+ // If this is set, then that means we have popped into this var the state of our last scroll position
508
+ let previousScrollState = null
509
+
510
+ // Update history.scrollRestoration depending on restoreScrollState
511
+ $: history.scrollRestoration = restoreScrollState ? "manual" : "auto"
512
+ let popStateChanged = null
513
+ if (restoreScrollState) {
514
+ popStateChanged = (event) => {
515
+ // If this event was from our history.replaceState, event.state will contain
516
+ // our scroll history. Otherwise, event.state will be null (like on forward
517
+ // navigation)
518
+ if (event.state && (event.state.__svelte_spa_router_scrollY || event.state.__svelte_spa_router_scrollX)) {
519
+ previousScrollState = event.state
520
+ } else {
521
+ previousScrollState = null
522
+ }
523
+ }
524
+ // This is removed in the destroy() invocation below
525
+ window.addEventListener("popstate", popStateChanged)
526
+
527
+ afterUpdate(() => {
528
+ restoreScroll(previousScrollState)
529
+ })
530
+ }
531
+
532
+ // Always have the latest value of loc
533
+ let lastLoc = null
534
+
535
+ // Current object of the component loaded
536
+ let componentObj = null
537
+
538
+ // Handle hash change events
539
+ // Listen to changes in the $loc store and update the page
540
+ // Do not use the $: syntax because it gets triggered by too many things
541
+ const unsubscribeLoc = loc.subscribe(async (newLoc) => {
542
+ lastLoc = newLoc
543
+
544
+ // Find a route matching the location
545
+ let i = 0
546
+ while (i < routesList.length) {
547
+ const match = routesList[i].match(newLoc.location)
548
+ if (!match) {
549
+ i++
550
+ continue
551
+ }
552
+
553
+ const detail = {
554
+ route: routesList[i].path,
555
+ location: newLoc.location,
556
+ querystring: newLoc.querystring,
557
+ userData: routesList[i].userData,
558
+ params: (match && typeof match == "object" && Object.keys(match).length) ? match : null
559
+ }
560
+
561
+ // Check if the route can be loaded - if all conditions succeed
562
+ if (!(await routesList[i].checkConditions(detail))) {
563
+ // Don't display anything
564
+ component = null
565
+ componentObj = null
566
+ // Trigger an event to notify the user, then exit
567
+ dispatchNextTick("conditionsFailed", detail)
568
+ return
569
+ }
570
+
571
+ // Trigger an event to alert that we're loading the route
572
+ // We need to clone the object on every event invocation so we don't risk the object to be modified in the next tick
573
+ dispatchNextTick("routeLoading", Object.assign({}, detail))
574
+
575
+ // If there's a component to show while we're loading the route, display it
576
+ const obj = routesList[i].component
577
+ // Do not replace the component if we're loading the same one as before, to avoid the route being unmounted and re-mounted
578
+ if (componentObj != obj) {
579
+ if (obj.loading) {
580
+ component = obj.loading
581
+ componentObj = obj
582
+ componentParams = obj.loadingParams
583
+ props = {}
584
+
585
+ // Trigger the routeLoaded event for the loading component
586
+ // Create a copy of detail so we don't modify the object for the dynamic route (and the dynamic route doesn't modify our object too)
587
+ dispatchNextTick("routeLoaded", Object.assign({}, detail, {
588
+ component: component,
589
+ name: component.name,
590
+ params: componentParams
591
+ }))
592
+ } else {
593
+ component = null
594
+ componentObj = null
595
+ }
596
+
597
+ // Invoke the Promise
598
+ const loaded = await obj()
599
+
600
+ // Now that we're here, after the promise resolved, check if we still want this component, as the user might have navigated to another page in the meanwhile
601
+ if (newLoc != lastLoc) {
602
+ // Don't update the component, just exit
603
+ return
604
+ }
605
+
606
+ // If there is a "default" property, which is used by async routes, then pick that
607
+ component = (loaded && loaded.default) || loaded
608
+ componentObj = obj
609
+ }
610
+
611
+ // Set componentParams only if we have a match, to avoid a warning similar to `<Component> was created with unknown prop 'params'`
612
+ // Of course, this assumes that developers always add a "params" prop when they are expecting parameters
613
+ if (match && typeof match == "object" && Object.keys(match).length) {
614
+ componentParams = match
615
+ } else {
616
+ componentParams = null
617
+ }
618
+
619
+ // Set static props, if any
620
+ props = routesList[i].props
621
+
622
+ // Dispatch the routeLoaded event then exit
623
+ // We need to clone the object on every event invocation so we don't risk the object to be modified in the next tick
624
+ dispatchNextTick("routeLoaded", Object.assign({}, detail, {
625
+ component: component,
626
+ name: component.name,
627
+ params: componentParams
628
+ })).then(() => {
629
+ params.set(componentParams)
630
+ })
631
+ return
632
+ }
633
+
634
+ // If we're still here, there was no match, so show the empty component
635
+ component = null
636
+ componentObj = null
637
+ params.set(undefined)
638
+ })
639
+
640
+ onDestroy(() => {
641
+ unsubscribeLoc()
642
+ popStateChanged && window.removeEventListener("popstate", popStateChanged)
643
+ })
644
+ </script>
package/active.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /** Options for the `active` action */
2
+ interface ActiveOptions {
3
+ /** Path to match; if empty, will default to the link's target */
4
+ path?: string | RegExp
5
+
6
+ /** Name of the CSS class to add when the route is active; default is "active" */
7
+ className?: string
8
+
9
+ /** Name of the CSS class to add when the route is inactive; nothing added by default */
10
+ inactiveClassName?: string
11
+ }
12
+
13
+ /**
14
+ * Svelte Action for automatically adding the "active" class to elements (links, or any other DOM element) when the current location matches a certain path.
15
+ *
16
+ * @param node - The target node (automatically set by Svelte)
17
+ * @param opts - Can be an object of type `ActiveOptions`, or a string (or regular expressions) representing `ActiveOptions.path`.
18
+ * @returns Destroy function
19
+ */
20
+ export default function active(
21
+ node: HTMLElement,
22
+ opt?: ActiveOptions | string | RegExp
23
+ ): { destroy: () => void }