;
50 | declare function done(transition: any): void;
51 | export {};
52 | //# sourceMappingURL=wc.d.ts.map
--------------------------------------------------------------------------------
/lib/function-dsl.js:
--------------------------------------------------------------------------------
1 | import invariant from './invariant.js'
2 |
3 | /**
4 | * @typedef {import("./router.js").Route} Route
5 | */
6 |
7 | /**
8 | * @callback registerRoute
9 | * @param {string} name
10 | * @param {Object} options
11 | * @param {routeCallback} [childrenCallback]
12 | */
13 |
14 | /**
15 | * @callback routeCallback
16 | * @param {registerRoute} route
17 | */
18 |
19 | /**
20 | * @export
21 | * @param {routeCallback} callback
22 | * @return {Route[]}
23 | */
24 | export default function functionDsl(callback) {
25 | let ancestors = []
26 | const matches = {}
27 | const names = {}
28 |
29 | callback(function route(name, options, childrenCallback) {
30 | let routes
31 |
32 | invariant(
33 | !names[name],
34 | 'Route names must be unique, but route "%s" is declared multiple times',
35 | name,
36 | )
37 |
38 | names[name] = true
39 |
40 | if (arguments.length === 1) {
41 | options = {}
42 | }
43 |
44 | if (arguments.length === 2 && typeof options === 'function') {
45 | childrenCallback = options
46 | options = {}
47 | }
48 |
49 | if (typeof options.path !== 'string') {
50 | const parts = name.split('.')
51 | options.path = parts[parts.length - 1]
52 | }
53 |
54 | // go to the next level
55 | if (childrenCallback) {
56 | ancestors = ancestors.concat(name)
57 | childrenCallback()
58 | routes = pop()
59 | ancestors.splice(-1)
60 | }
61 |
62 | // add the node to the tree
63 | push({
64 | name,
65 | path: options.path,
66 | routes: routes || [],
67 | options,
68 | })
69 | })
70 |
71 | function pop() {
72 | return matches[currentLevel()] || []
73 | }
74 |
75 | function push(route) {
76 | const level = currentLevel()
77 | matches[level] = matches[level] || []
78 | matches[level].push(route)
79 | }
80 |
81 | function currentLevel() {
82 | return ancestors.join('.')
83 | }
84 |
85 | return pop()
86 | }
87 |
--------------------------------------------------------------------------------
/lib/links.js:
--------------------------------------------------------------------------------
1 | import { bindEvent, unbindEvent } from './events.js'
2 |
3 | /**
4 | * Handle link delegation on `el` or the document,
5 | * and invoke `fn(e)` when clickable.
6 | *
7 | * @param {Element} el
8 | * @param {(e: Event, el: HTMLElement) => void} fn
9 | * @return {Function} dispose
10 | * @api public
11 | */
12 |
13 | export function intercept(el, fn) {
14 | const cb = delegate(el, 'click', function (e, el) {
15 | if (clickable(e, el)) fn(e, el)
16 | })
17 |
18 | return function dispose() {
19 | unbindEvent(el, 'click', cb)
20 | }
21 | }
22 |
23 | /**
24 | * Delegate event `type` to links
25 | * and invoke `fn(e)`. A callback function
26 | * is returned which may be passed to `.unbind()`.
27 | *
28 | * @param {HTMLElement} el
29 | * @param {String} selector
30 | * @param {String} type
31 | * @param {(e: Event, el: HTMLElement) => void} fn
32 | * @return {Function}
33 | * @api public
34 | */
35 |
36 | function delegate(el, type, fn) {
37 | return bindEvent(el, type, function (e) {
38 | const el = e.target.closest('a')
39 | if (el) {
40 | fn(e, el)
41 | }
42 | })
43 | }
44 |
45 | /**
46 | * Check if `e` is clickable.
47 | */
48 |
49 | /**
50 | * @param {Event} e
51 | * @param {HTMLElement} el
52 | * @return {Boolean | undefined}
53 | */
54 | function clickable(e, el) {
55 | if (which(e) !== 1) return
56 | if (e.metaKey || e.ctrlKey || e.shiftKey) return
57 | if (e.defaultPrevented) return
58 |
59 | // check target
60 | if (el.target) return
61 |
62 | // check for data-bypass attribute
63 | if (el.getAttribute('data-bypass') !== null) return
64 |
65 | // inspect the href
66 | const href = el.getAttribute('href')
67 | if (!href || href.length === 0) return
68 |
69 | // don't handle hash links, external/absolute links, email links and javascript links
70 | if (/^(#|https{0,1}:\/\/|mailto|javascript:)/i.test(href)) return
71 |
72 | return true
73 | }
74 |
75 | /**
76 | * Event button.
77 | */
78 |
79 | function which(e) {
80 | e = e || window.event
81 | return e.which === null ? e.button : e.which
82 | }
83 |
--------------------------------------------------------------------------------
/types/locations/browser.d.ts:
--------------------------------------------------------------------------------
1 | export default BrowserLocation;
2 | declare class BrowserLocation {
3 | constructor(options?: {});
4 | path: any;
5 | options: {
6 | pushState: boolean;
7 | root: string;
8 | };
9 | locationBar: LocationBar;
10 | /**
11 | * Get the current URL
12 | */
13 | getURL(): any;
14 | /**
15 | * Set the current URL without triggering any events
16 | * back to the router. Add a new entry in browser's history.
17 | */
18 | setURL(path: any, options?: {}): void;
19 | /**
20 | * Set the current URL without triggering any events
21 | * back to the router. Replace the latest entry in broser's history.
22 | */
23 | replaceURL(path: any, options?: {}): void;
24 | /**
25 | * Setup a URL change handler
26 | * @param {Function} callback
27 | */
28 | onChange(callback: Function): void;
29 | changeCallback: Function;
30 | /**
31 | * Given a path, generate a URL appending root
32 | * if pushState is used and # if hash state is used
33 | */
34 | formatURL(path: any): string;
35 | /**
36 | * When we use pushState with a custom root option,
37 | * we need to take care of removingRoot at certain points.
38 | * Specifically
39 | * - browserLocation.update() can be called with the full URL by router
40 | * - LocationBar expects all .update() calls to be called without root
41 | * - this method is public so that we could dispatch URLs without root in router
42 | */
43 | removeRoot(url: any): any;
44 | /**
45 | * Stop listening to URL changes and link clicks
46 | */
47 | destroy(): void;
48 | /**
49 | initially, the changeCallback won't be defined yet, but that's good
50 | because we dont' want to kick off routing right away, the router
51 | does that later by manually calling this handleURL method with the
52 | url it reads of the location. But it's important this is called
53 | first by Backbone, because we wanna set a correct this.path value
54 |
55 | @private
56 | */
57 | private handleURL;
58 | }
59 | import LocationBar from './location-bar.js';
60 | //# sourceMappingURL=browser.d.ts.map
--------------------------------------------------------------------------------
/docs/route-transition.md:
--------------------------------------------------------------------------------
1 | # Route Transition
2 |
3 | Slick Router defines route transition as the process of changing from a route state, generally represented by an URL, to another one. It provides tools to control with great granularity the transition like cancel, redirect, stop or retry.
4 |
5 | #### transition object
6 |
7 | The transition object is itself a promise. It also contains the following attributes
8 |
9 | * `id`: the transition id
10 | * `routes`: the matched routes
11 | * `path`: the matched path
12 | * `pathname`: the matched path without query params
13 | * `params`: a hash with path params
14 | * `query`: a hash with the query
15 | * `prev`: the previous matched info
16 | * `routes`
17 | * `path`
18 | * `pathname`
19 | * `params`
20 | * `query`
21 |
22 | And the following methods
23 |
24 | * `then`
25 | * `catch`
26 | * `cancel`
27 | * `retry`
28 | * `followRedirects`
29 | * `redirectTo`
30 |
31 | #### route
32 |
33 | During every transition, you can inspect `transition.routes` and `transition.prev.routes` to see where the router is transitioning to. These are arrays that contain a list of route descriptors. Each route descriptor has the following attributes
34 |
35 | * `name` - e.g. `'message'`
36 | * `path` - the path segment, e.g. `'message/:id'`
37 | * `params` - a list of params specifically for this route, e.g `{id: 1}`
38 | * `options` - the options object that was passed to the `route` function in the `map`
39 |
40 |
41 | ## Errors
42 |
43 | Transitions can fail, in which case the transition promise is rejected with the error object. This could happen, for example, if some middleware throws or returns a rejected promise.
44 |
45 | There are also two special errors that can be thrown when a redirect happens or when transition is cancelled completely.
46 |
47 | In case of redirect (someone initiating a router.transitionTo() while another transition was active) and error object will have a `type` attribute set to 'TransitionRedirected' and `nextPath` attribute set to the path of the new transition.
48 |
49 | In case of cancelling (someone calling transition.cancel()) the error object will have a `type` attribute set to 'TransitionCancelled'.
50 |
51 | If you have some error handling middleware - you most likely want to check for these two special errors, because they're normal to the functioning of the router, it's common to perform redirects.
52 |
53 |
--------------------------------------------------------------------------------
/docs/programmatic-navigation-and-link.md:
--------------------------------------------------------------------------------
1 | # Programmatic Navigation and Link Handling
2 |
3 | ### router.transitionTo(name, params, query)
4 |
5 | Transition to a route, e.g.
6 |
7 | ```js
8 | router.transitionTo('about')
9 | router.transitionTo('posts.show', {postId: 1})
10 | router.transitionTo('posts.show', {postId: 2}, {commentId: 2})
11 | ```
12 |
13 | ### router.replaceWith(name, params, query)
14 |
15 | Same as transitionTo, but doesn't add an entry in browser's history, instead replaces the current entry. Useful if you don't want this transition to be accessible via browser's Back button, e.g. if you're redirecting, or if you're navigating upon clicking tabs in the UI, etc.
16 |
17 | ### router.generate(name, params, query)
18 |
19 | Generate a URL for a route, e.g.
20 |
21 | ```js
22 | router.generate('about')
23 | router.generate('posts.show', {postId: 1})
24 | router.generate('posts.show', {postId: 2}, {commentId: 2})
25 | ```
26 |
27 | It generates a URL with # if router is in hashChange mode and with no # if router is in pushState mode.
28 |
29 | ### router.isActive(name, params, query, exact)
30 |
31 | Check if a given route, params and query is active.
32 |
33 | ```js
34 | router.isActive('status')
35 | router.isActive('status', {user: 'me'})
36 | router.isActive('status', {user: 'me'}, {commentId: 2})
37 | router.isActive('status', null, {commentId: 2})
38 | ```
39 |
40 | When optional exact argument is truthy, the route is marked as active only if the path match exactly, e.g.,
41 |
42 | ```js
43 | const routes = [
44 | name: 'app',
45 | children: [
46 | {
47 | name: 'dashboard'
48 | }
49 | ]
50 | ]
51 |
52 | // path = /app
53 | router.isActive('app', null, null) // true
54 | router.isActive('app', null, null, true) // true
55 |
56 | // path = /app/dashboard
57 | router.isActive('app', null, null) // true
58 | router.isActive('app', null, null, true) // false
59 | ```
60 |
61 | ### router.state
62 |
63 | The state of the route is always available on the `router.state` object. It contains `activeTransition`, `routes`, `path`, `pathname`, `params` and `query`.
64 |
65 | ### router.matchers
66 |
67 | Use this to inspect all the routes and their URL patterns that exist in your application. It's an array of:
68 |
69 | ```js
70 | {
71 | name,
72 | path,
73 | routes
74 | }
75 | ```
76 |
77 | listed in the order that they will be matched against the URL.
78 |
--------------------------------------------------------------------------------
/tests/location-bar/backbone_router_test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Backbone.Router test suite
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
29 |
30 |
35 |
36 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "slick-router",
3 | "version": "3.0.2",
4 | "description": "A powerful and flexible client side router",
5 | "main": "./lib/wc-router.js",
6 | "module": "./lib/wc-router.js",
7 | "types": "./types/wc-router.d.ts",
8 | "sideEffects": false,
9 | "scripts": {
10 | "build": "eslint --env browser && node tasks/build.js",
11 | "lint": "eslint --env browser lib",
12 | "format": "prettier --write .",
13 | "start": "web-dev-server --open examples/ --node-resolve",
14 | "test": "web-test-runner \"tests/**/*Test.js\" --node-resolve --puppeteer",
15 | "test:coverage": "web-test-runner \"tests/**/*Test.js\" --node-resolve --coverage",
16 | "types": "tsc --project tsconfig.types.json"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "git://github.com/blikblum/slick-router.git"
21 | },
22 | "author": "Luiz Américo Pereira Câmara",
23 | "license": "MIT",
24 | "bugs": {
25 | "url": "https://github.com/blikblum/slick-router/issues"
26 | },
27 | "dependencies": {
28 | "regexparam": "^3.0.0"
29 | },
30 | "keywords": [
31 | "router",
32 | "web-components",
33 | "browser",
34 | "pushState",
35 | "hierarchical",
36 | "nested"
37 | ],
38 | "exports": {
39 | ".": {
40 | "types": "./types/wc-router.d.ts",
41 | "default": "./lib/wc-router.js"
42 | },
43 | "./core.js": {
44 | "types": "./types/router.d.ts",
45 | "default": "./lib/router.js"
46 | },
47 | "./components/*.js": {
48 | "types": "./types/components/*.d.ts",
49 | "default": "./lib/components/*.js"
50 | },
51 | "./middlewares/*.js": {
52 | "types": "./types/middlewares/*.d.ts",
53 | "default": "./lib/middlewares/*.js"
54 | }
55 | },
56 | "files": [
57 | "lib",
58 | "types",
59 | "README.md",
60 | "CHANGELOG.md",
61 | "LICENSE"
62 | ],
63 | "devDependencies": {
64 | "@open-wc/testing": "^4.0.0",
65 | "@web/dev-server": "^0.4.2",
66 | "@web/test-runner": "^0.18.0",
67 | "@web/test-runner-puppeteer": "^0.15.0",
68 | "chai": "^4.3.4",
69 | "eslint": "^8.56.0",
70 | "eslint-config-prettier": "^9.1.0",
71 | "jquery": "^3.6.0",
72 | "lit-element": "^2.5.1",
73 | "path-to-regexp": "6.2.1",
74 | "prettier": "3.2.5",
75 | "sinon": "^11.1.2",
76 | "typescript": "^5.4.4"
77 | },
78 | "packageManager": "yarn@4.0.2"
79 | }
--------------------------------------------------------------------------------
/examples/vanilla-blog/client/app.js:
--------------------------------------------------------------------------------
1 | const { Router } = require('slick-router')
2 | const getHandler = require('./handler')
3 |
4 | // create the router
5 | const router = window.router = new Router({
6 | log: true
7 | })
8 |
9 | // define the route map
10 | router.map(function (route) {
11 | route('application', { path: '/', abstract: true }, function () {
12 | route('home', { path: '' })
13 | route('about')
14 | route('faq')
15 | route('posts', { abstract: true }, function () {
16 | route('posts.index', { path: '' })
17 | route('posts.popular')
18 | route('posts.search', { path: 'search/:query' })
19 | route('posts.show', { path: ':id' })
20 | })
21 | })
22 | })
23 |
24 | // implement a set of middleware
25 |
26 | // load and attach route handlers
27 | // this can load handlers dynamically (TODO)
28 | router.use(function loadHandlers (transition) {
29 | transition.routes.forEach(function (route, i) {
30 | const handler = getHandler(route.name)
31 | handler.name = route.name
32 | handler.router = router
33 | const parentRoute = transition.routes[i - 1]
34 | if (parentRoute) {
35 | handler.parent = parentRoute.handler
36 | }
37 | route.handler = handler
38 | })
39 | })
40 |
41 | // willTransition hook
42 | router.use(function willTransition (transition) {
43 | transition.prev.routes.forEach(function (route) {
44 | route.handler.willTransition && route.handler.willTransition(transition)
45 | })
46 | })
47 |
48 | // deactive up old routes
49 | // they also get a chance to abort the transition (TODO)
50 | router.use(function deactivateHook (transition) {
51 | transition.prev.routes.forEach(function (route) {
52 | route.handler.deactivate()
53 | })
54 | })
55 |
56 | // model hook
57 | // with the loading hook (TODO)
58 | router.use(function modelHook (transition) {
59 | let prevContext = Promise.resolve()
60 | return Promise.all(transition.routes.map(function (route) {
61 | prevContext = Promise.resolve(route.handler.model(transition.params, prevContext, transition))
62 | return prevContext
63 | }))
64 | })
65 |
66 | // activate hook
67 | // which only reactives routes starting at the match point (TODO)
68 | router.use(function activateHook (transition, contexts) {
69 | transition.routes.forEach(function (route, i) {
70 | route.handler.activate(contexts[i])
71 | })
72 | })
73 |
74 | // start the routing
75 | router.listen()
76 |
--------------------------------------------------------------------------------
/tests/functional/testApp.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable array-callback-return */
2 | import $ from './nanodom'
3 | import { Router } from '../../lib/router'
4 |
5 | export default function TestApp(options) {
6 | options = options || {}
7 |
8 | // create the router
9 | const router = (this.router = new Router(options))
10 |
11 | // provide the route map
12 | router.map(function (route) {
13 | route('application', { path: '/' }, function () {
14 | route('about')
15 | route('faq')
16 | route('posts', function () {
17 | route('posts.popular')
18 | route('posts.filter', { path: 'filter/:filterId' })
19 | route('posts.show', { path: ':id' })
20 | })
21 | })
22 | })
23 |
24 | const handlers = {}
25 |
26 | handlers.application = {
27 | // this is a hook for 'performing'
28 | // actions upon entering this state
29 | activate: function () {
30 | this.$view = $(
31 | '
',
32 | )
33 | this.$view.html('Slick Router Application
')
34 | this.$outlet = this.$view.find('.outlet')
35 | this.$outlet.html('Welcome to this application')
36 | $(document.body).html(this.$view)
37 | },
38 | }
39 |
40 | handlers.about = {
41 | activate: function () {
42 | this.parent.$outlet.html('This is about page')
43 | },
44 | }
45 |
46 | handlers.faq = {
47 | activate: function (params, query) {
48 | this.parent.$outlet.html('FAQ. Sorted By: ' + query.sortBy)
49 | },
50 | }
51 |
52 | handlers.posts = {
53 | activate: function () {},
54 | }
55 |
56 | handlers['posts.filter'] = {
57 | activate: function (params) {
58 | if (params.filterId === 'mine') {
59 | this.parent.parent.$outlet.html('My posts...')
60 | } else {
61 | this.parent.parent.$outlet.html('Filter not found')
62 | }
63 | },
64 | }
65 |
66 | router.use((transition) => {
67 | transition.routes.forEach((route, i) => {
68 | const handler = handlers[route.name]
69 | const parentRoute = transition.routes[i - 1]
70 | handler.parent = parentRoute ? handlers[parentRoute.name] : null
71 | handler.activate(transition.params, transition.query)
72 | })
73 | })
74 | }
75 |
76 | TestApp.prototype.start = function () {
77 | return this.router.listen()
78 | }
79 |
80 | TestApp.prototype.destroy = function () {
81 | document.body.innerHTML = ''
82 | return this.router.destroy()
83 | }
84 |
--------------------------------------------------------------------------------
/tests/functional/nanodom.js:
--------------------------------------------------------------------------------
1 | function Dom() {}
2 | Dom.prototype = new Array() // eslint-disable-line
3 | Dom.prototype.append = function (element) {
4 | element.forEach(
5 | function (e) {
6 | this[0].appendChild(e)
7 | }.bind(this),
8 | )
9 | return this
10 | }
11 | Dom.prototype.remove = function () {
12 | this.forEach(function (e) {
13 | e.parentNode.removeChild(e)
14 | })
15 | return this
16 | }
17 | Dom.prototype.prepend = function (element) {
18 | element.forEach(
19 | function (e) {
20 | this[0].insertBefore(e, this[0].hasChildNodes() ? this[0].childNodes[0] : null)
21 | }.bind(this),
22 | )
23 | return this
24 | }
25 | Dom.prototype.each = function (fn) {
26 | this.forEach(fn)
27 | return this
28 | }
29 |
30 | function stringify(dom) {
31 | return dom
32 | .map(function (el) {
33 | return el.innerHTML
34 | })
35 | .join()
36 | }
37 |
38 | Dom.prototype.html = function (content) {
39 | if (content === undefined) {
40 | return stringify(this)
41 | }
42 | if (content instanceof Dom) {
43 | return this.empty().append(content)
44 | }
45 | this.forEach(function (e) {
46 | e.innerHTML = content
47 | })
48 | return this
49 | }
50 |
51 | Dom.prototype.find = function (selector) {
52 | const result = new Dom()
53 | this.forEach(function (el) {
54 | ;[].slice.call(el.querySelectorAll(selector)).forEach(function (e) {
55 | result.push(e)
56 | })
57 | })
58 | return result
59 | }
60 |
61 | Dom.prototype.empty = function () {
62 | this.forEach(function (el) {
63 | el.innerHTML = ''
64 | })
65 | return this
66 | }
67 |
68 | Dom.prototype.appendTo = function (target) {
69 | nanodom(target).append(this)
70 | return this
71 | }
72 |
73 | Dom.prototype.get = function (index) {
74 | return this[index]
75 | }
76 |
77 | function domify(str) {
78 | const d = document.createElement('div')
79 | d.innerHTML = str
80 | return d.childNodes
81 | }
82 |
83 | const nanodom = function (selector) {
84 | let d
85 | if (selector instanceof Dom) return selector
86 | if (selector instanceof HTMLElement) {
87 | d = new Dom()
88 | d.push(selector)
89 | return d
90 | }
91 | if (typeof selector !== 'string') return
92 | d = new Dom()
93 | const c = selector.indexOf('<') === 0
94 | const s = c ? domify(selector) : document.querySelectorAll(selector)
95 | ;[].slice.call(s).forEach(function (e) {
96 | d.push(e)
97 | })
98 | return d
99 | }
100 |
101 | export default nanodom
102 |
--------------------------------------------------------------------------------
/examples/vanilla-blog/client/screens/app/screens/posts/screens/show/templates/show.html:
--------------------------------------------------------------------------------
1 |
4 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rhoncus condimentum sem a facilisis. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas lacinia fringilla tristique. Ut sollicitudin felis sem, in aliquam lectus eleifend nec. Pellentesque id nisi non arcu venenatis convallis vel id nulla. Nullam ut accumsan felis. Nunc nec tincidunt sapien, et posuere massa.
5 | Cras sollicitudin neque ac erat fermentum, quis pulvinar dolor porta. Donec enim lacus, scelerisque ac iaculis eu, rhoncus sed urna. In hac habitasse platea dictumst. Nullam sodales, leo at congue ultricies, mauris sapien ornare ipsum, id dictum orci nulla at leo. Nullam non tristique mauris. Donec aliquam lobortis tortor in volutpat. In at sem sed felis faucibus vulputate. Duis risus leo, aliquet ut lectus vel, sollicitudin egestas dui. Morbi eget justo tristique, tincidunt turpis interdum, faucibus turpis. Sed venenatis ut augue non accumsan. Aliquam at lorem et dui accumsan sodales. Mauris fermentum, ligula imperdiet pharetra feugiat, erat tortor sollicitudin nulla, et condimentum mauris lectus ullamcorper enim. Integer volutpat mauris nisl, non congue turpis fringilla et. Sed rhoncus mollis libero, ut lacinia quam elementum in.
6 | Pellentesque eu arcu condimentum, vulputate leo nec, tincidunt massa. Vestibulum leo libero, aliquet nec enim quis, consequat faucibus elit. Sed tristique et velit vel iaculis. Sed suscipit commodo tellus nec imperdiet. Aenean tristique at urna eget aliquet. Etiam vestibulum ligula ac nunc viverra, quis scelerisque est facilisis. Praesent fermentum eros urna, nec consequat sapien accumsan sit amet. Maecenas congue elit id lacinia scelerisque. Mauris in nibh justo.
7 | Suspendisse potenti. Vestibulum eu molestie diam. Quisque tristique volutpat felis, eget tempor lorem ullamcorper sed. Vivamus aliquam cursus mollis. Cras accumsan justo nec augue dignissim lacinia. Mauris eu tortor lacus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Quisque at lacus a elit elementum semper. Morbi dictum metus lorem, eu ornare nisi gravida ut. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse velit odio, posuere sed mi quis, elementum elementum urna. Etiam mi mi, sollicitudin at ipsum sit amet, posuere gravida sapien.
8 | Curabitur vel mauris id velit lobortis rutrum. In hac habitasse platea dictumst. Fusce in mattis ante, eget tristique ipsum. Sed lorem augue, laoreet eu viverra eget, porttitor sed massa. Quisque suscipit mauris sem, vitae varius arcu gravida sit amet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Ut ipsum libero, fringilla eu nibh vitae, porta euismod lacus. Curabitur arcu nibh, tincidunt in fringilla quis, rutrum id purus.
--------------------------------------------------------------------------------
/examples/vanilla-blog/client/screens/app/screens/about/templates/about.html:
--------------------------------------------------------------------------------
1 |
4 |
5 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec rhoncus condimentum sem a facilisis. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas lacinia fringilla tristique. Ut sollicitudin felis sem, in aliquam lectus eleifend nec. Pellentesque id nisi non arcu venenatis convallis vel id nulla. Nullam ut accumsan felis. Nunc nec tincidunt sapien, et posuere massa.
6 | Cras sollicitudin neque ac erat fermentum, quis pulvinar dolor porta. Donec enim lacus, scelerisque ac iaculis eu, rhoncus sed urna. In hac habitasse platea dictumst. Nullam sodales, leo at congue ultricies, mauris sapien ornare ipsum, id dictum orci nulla at leo. Nullam non tristique mauris. Donec aliquam lobortis tortor in volutpat. In at sem sed felis faucibus vulputate. Duis risus leo, aliquet ut lectus vel, sollicitudin egestas dui. Morbi eget justo tristique, tincidunt turpis interdum, faucibus turpis. Sed venenatis ut augue non accumsan. Aliquam at lorem et dui accumsan sodales. Mauris fermentum, ligula imperdiet pharetra feugiat, erat tortor sollicitudin nulla, et condimentum mauris lectus ullamcorper enim. Integer volutpat mauris nisl, non congue turpis fringilla et. Sed rhoncus mollis libero, ut lacinia quam elementum in.
7 | Pellentesque eu arcu condimentum, vulputate leo nec, tincidunt massa. Vestibulum leo libero, aliquet nec enim quis, consequat faucibus elit. Sed tristique et velit vel iaculis. Sed suscipit commodo tellus nec imperdiet. Aenean tristique at urna eget aliquet. Etiam vestibulum ligula ac nunc viverra, quis scelerisque est facilisis. Praesent fermentum eros urna, nec consequat sapien accumsan sit amet. Maecenas congue elit id lacinia scelerisque. Mauris in nibh justo.
8 | Suspendisse potenti. Vestibulum eu molestie diam. Quisque tristique volutpat felis, eget tempor lorem ullamcorper sed. Vivamus aliquam cursus mollis. Cras accumsan justo nec augue dignissim lacinia. Mauris eu tortor lacus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Quisque at lacus a elit elementum semper. Morbi dictum metus lorem, eu ornare nisi gravida ut. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse velit odio, posuere sed mi quis, elementum elementum urna. Etiam mi mi, sollicitudin at ipsum sit amet, posuere gravida sapien.
9 | Curabitur vel mauris id velit lobortis rutrum. In hac habitasse platea dictumst. Fusce in mattis ante, eget tristique ipsum. Sed lorem augue, laoreet eu viverra eget, porttitor sed massa. Quisque suscipit mauris sem, vitae varius arcu gravida sit amet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Ut ipsum libero, fringilla eu nibh vitae, porta euismod lacus. Curabitur arcu nibh, tincidunt in fringilla quis, rutrum id purus.
10 |
--------------------------------------------------------------------------------
/lib/path.js:
--------------------------------------------------------------------------------
1 | import invariant from './invariant.js'
2 |
3 | const paramInjectMatcher = /:([a-zA-Z_$][a-zA-Z0-9_$?]*[?+*]?)/g
4 | const specialParamChars = /[+*?]$/g
5 | const queryMatcher = /\?(.+)/
6 |
7 | const _compiledPatterns = {}
8 |
9 | function compilePattern(pattern, compiler) {
10 | if (!(pattern in _compiledPatterns)) {
11 | _compiledPatterns[pattern] = compiler(pattern)
12 | }
13 |
14 | return _compiledPatterns[pattern]
15 | }
16 |
17 | export function clearPatternCompilerCache() {
18 | for (const x in _compiledPatterns) {
19 | delete _compiledPatterns[x]
20 | }
21 | }
22 |
23 | /**
24 | * Returns an array of the names of all parameters in the given pattern.
25 | */
26 | export function extractParamNames(pattern, compiler) {
27 | return compilePattern(pattern, compiler).paramNames
28 | }
29 |
30 | /**
31 | * Extracts the portions of the given URL path that match the given pattern
32 | * and returns an object of param name => value pairs. Returns null if the
33 | * pattern does not match the given path.
34 | */
35 | export function extractParams(pattern, path, compiler) {
36 | const cp = compilePattern(pattern, compiler)
37 | const matcher = cp.matcher
38 | const paramNames = cp.paramNames
39 | const match = path.match(matcher)
40 |
41 | if (!match) {
42 | return null
43 | }
44 |
45 | const params = {}
46 |
47 | paramNames.forEach(function (paramName, index) {
48 | params[paramName] = match[index + 1] && decodeURIComponent(match[index + 1])
49 | })
50 |
51 | return params
52 | }
53 |
54 | /**
55 | * Returns a version of the given route path with params interpolated. Throws
56 | * if there is a dynamic segment of the route path for which there is no param.
57 | */
58 | export function injectParams(pattern, params) {
59 | params = params || {}
60 |
61 | return pattern.replace(paramInjectMatcher, function (match, param) {
62 | const paramName = param.replace(specialParamChars, '')
63 | const lastChar = param.slice(-1)
64 |
65 | // If param is optional don't check for existence
66 | if (lastChar === '?' || lastChar === '*') {
67 | if (params[paramName] == null) {
68 | return ''
69 | }
70 | } else {
71 | invariant(
72 | params[paramName] != null,
73 | "Missing '%s' parameter for path '%s'",
74 | paramName,
75 | pattern,
76 | )
77 | }
78 |
79 | let paramValue = encodeURIComponent(params[paramName])
80 | if (lastChar === '*' || lastChar === '+') {
81 | // restore / for splats
82 | paramValue = paramValue.replace('%2F', '/')
83 | }
84 | return paramValue
85 | })
86 | }
87 |
88 | /**
89 | * Returns an object that is the result of parsing any query string contained
90 | * in the given path, null if the path contains no query string.
91 | */
92 | export function extractQuery(qs, path) {
93 | const match = path.match(queryMatcher)
94 | return match && qs.parse(match[1])
95 | }
96 |
97 | /**
98 | * Returns a version of the given path with the parameters in the given
99 | * query merged into the query string.
100 | */
101 | export function withQuery(qs, path, query) {
102 | const queryString = qs.stringify(query, { indices: false })
103 |
104 | if (queryString) {
105 | return withoutQuery(path) + '?' + queryString
106 | }
107 |
108 | return path
109 | }
110 |
111 | /**
112 | * Returns a version of the given path without the query string.
113 | */
114 | export function withoutQuery(path) {
115 | return path.replace(queryMatcher, '')
116 | }
117 |
--------------------------------------------------------------------------------
/lib/locations/browser.js:
--------------------------------------------------------------------------------
1 | import { extend } from '../utils.js'
2 | import LocationBar from './location-bar.js'
3 |
4 | class BrowserLocation {
5 | constructor(options = {}) {
6 | this.path = options.path || ''
7 |
8 | this.options = extend(
9 | {
10 | pushState: false,
11 | root: '/',
12 | },
13 | options,
14 | )
15 |
16 | // we're using the location-bar module for actual
17 | // URL management
18 | this.locationBar = new LocationBar()
19 | this.locationBar.onChange((path) => {
20 | this.handleURL(`/${path || ''}`)
21 | })
22 |
23 | this.locationBar.start(options)
24 | }
25 |
26 | /**
27 | * Get the current URL
28 | */
29 |
30 | getURL() {
31 | return this.path
32 | }
33 |
34 | /**
35 | * Set the current URL without triggering any events
36 | * back to the router. Add a new entry in browser's history.
37 | */
38 |
39 | setURL(path, options = {}) {
40 | if (this.path !== path) {
41 | this.path = path
42 | this.locationBar.update(path, extend({ trigger: true }, options))
43 | }
44 | }
45 |
46 | /**
47 | * Set the current URL without triggering any events
48 | * back to the router. Replace the latest entry in broser's history.
49 | */
50 |
51 | replaceURL(path, options = {}) {
52 | if (this.path !== path) {
53 | this.path = path
54 | this.locationBar.update(path, extend({ trigger: true, replace: true }, options))
55 | }
56 | }
57 |
58 | /**
59 | * Setup a URL change handler
60 | * @param {Function} callback
61 | */
62 | onChange(callback) {
63 | this.changeCallback = callback
64 | }
65 |
66 | /**
67 | * Given a path, generate a URL appending root
68 | * if pushState is used and # if hash state is used
69 | */
70 | formatURL(path) {
71 | if (this.locationBar.hasPushState()) {
72 | let rootURL = this.options.root
73 | if (path !== '') {
74 | rootURL = rootURL.replace(/\/$/, '')
75 | }
76 | return rootURL + path
77 | } else {
78 | if (path[0] === '/') {
79 | path = path.substr(1)
80 | }
81 | return `#${path}`
82 | }
83 | }
84 |
85 | /**
86 | * When we use pushState with a custom root option,
87 | * we need to take care of removingRoot at certain points.
88 | * Specifically
89 | * - browserLocation.update() can be called with the full URL by router
90 | * - LocationBar expects all .update() calls to be called without root
91 | * - this method is public so that we could dispatch URLs without root in router
92 | */
93 | removeRoot(url) {
94 | if (this.options.pushState && this.options.root && this.options.root !== '/') {
95 | return url.replace(this.options.root, '')
96 | } else {
97 | return url
98 | }
99 | }
100 |
101 | /**
102 | * Stop listening to URL changes and link clicks
103 | */
104 | destroy() {
105 | this.locationBar.stop()
106 | }
107 |
108 | /**
109 | initially, the changeCallback won't be defined yet, but that's good
110 | because we dont' want to kick off routing right away, the router
111 | does that later by manually calling this handleURL method with the
112 | url it reads of the location. But it's important this is called
113 | first by Backbone, because we wanna set a correct this.path value
114 |
115 | @private
116 | */
117 | handleURL(url) {
118 | this.path = url
119 | if (this.changeCallback) {
120 | this.changeCallback(url)
121 | }
122 | }
123 | }
124 |
125 | export default BrowserLocation
126 |
--------------------------------------------------------------------------------
/tests/location-bar/vendor/runner.js:
--------------------------------------------------------------------------------
1 | /*
2 | * QtWebKit-powered headless test runner using PhantomJS
3 | *
4 | * PhantomJS binaries: http://phantomjs.org/download.html
5 | * Requires PhantomJS 1.6+ (1.7+ recommended)
6 | *
7 | * Run with:
8 | * phantomjs runner.js [url-of-your-qunit-testsuite]
9 | *
10 | * e.g.
11 | * phantomjs runner.js http://localhost/qunit/test/index.html
12 | */
13 |
14 | /*jshint latedef:false */
15 | /*global phantom:false, require:false, console:false, window:false, QUnit:false */
16 |
17 | (function() {
18 | 'use strict';
19 |
20 | var args = require('system').args;
21 |
22 | // arg[0]: scriptName, args[1...]: arguments
23 | if (args.length !== 2) {
24 | console.error('Usage:\n phantomjs runner.js [url-of-your-qunit-testsuite]');
25 | phantom.exit(1);
26 | }
27 |
28 | var url = args[1],
29 | page = require('webpage').create();
30 |
31 | // Route `console.log()` calls from within the Page context to the main Phantom context (i.e. current `this`)
32 | page.onConsoleMessage = function(msg) {
33 | console.log(msg);
34 | };
35 |
36 | page.onInitialized = function() {
37 | page.evaluate(addLogging);
38 | };
39 |
40 | page.onCallback = function(message) {
41 | var result,
42 | failed;
43 |
44 | if (message) {
45 | if (message.name === 'QUnit.done') {
46 | result = message.data;
47 | failed = !result || result.failed;
48 |
49 | phantom.exit(failed ? 1 : 0);
50 | }
51 | }
52 | };
53 |
54 | page.open(url, function(status) {
55 | if (status !== 'success') {
56 | console.error('Unable to access network: ' + status);
57 | phantom.exit(1);
58 | } else {
59 | // Cannot do this verification with the 'DOMContentLoaded' handler because it
60 | // will be too late to attach it if a page does not have any script tags.
61 | var qunitMissing = page.evaluate(function() { return (typeof QUnit === 'undefined' || !QUnit); });
62 | if (qunitMissing) {
63 | console.error('The `QUnit` object is not present on this page.');
64 | phantom.exit(1);
65 | }
66 |
67 | // Do nothing... the callback mechanism will handle everything!
68 | }
69 | });
70 |
71 | function addLogging() {
72 | window.document.addEventListener('DOMContentLoaded', function() {
73 | var current_test_assertions = [];
74 |
75 | QUnit.log(function(details) {
76 | var response;
77 |
78 | // Ignore passing assertions
79 | if (details.result) {
80 | return;
81 | }
82 |
83 | response = details.message || '';
84 |
85 | if (typeof details.expected !== 'undefined') {
86 | if (response) {
87 | response += ', ';
88 | }
89 |
90 | response += 'expected: ' + details.expected + ', but was: ' + details.actual;
91 | if (details.source) {
92 | response += "\n" + details.source;
93 | }
94 | }
95 |
96 | current_test_assertions.push('Failed assertion: ' + response);
97 | });
98 |
99 | QUnit.testDone(function(result) {
100 | var i,
101 | len,
102 | name = result.module + ': ' + result.name;
103 |
104 | if (result.failed) {
105 | console.log('Test failed: ' + name);
106 |
107 | for (i = 0, len = current_test_assertions.length; i < len; i++) {
108 | console.log(' ' + current_test_assertions[i]);
109 | }
110 | }
111 |
112 | current_test_assertions.length = 0;
113 | });
114 |
115 | QUnit.done(function(result) {
116 | console.log('Took ' + result.runtime + 'ms to run ' + result.total + ' tests. ' + result.passed + ' passed, ' + result.failed + ' failed.');
117 |
118 | if (typeof window.callPhantom === 'function') {
119 | window.callPhantom({
120 | 'name': 'QUnit.done',
121 | 'data': result
122 | });
123 | }
124 | });
125 | }, false);
126 | }
127 | })();
128 |
--------------------------------------------------------------------------------
/docs/components/animated-outlet.md:
--------------------------------------------------------------------------------
1 | # animated-outlet component
2 |
3 | Enable animation on web component swapping triggered by route transitions.
4 |
5 | ## Usage
6 |
7 | 1) Register a `AnimatedOutlet` web component to the tag to be used as router outlet. wc middleware uses 'router-outlet' tag as default, but any tag can be used.
8 |
9 | ```javascript
10 | import { AnimatedOutlet } from 'slick-router/components/animated-outlet'
11 |
12 | customElements.define('router-outlet', AnimatedOutlet)
13 | ```
14 |
15 | 2) Add an 'animation' attribute to the outlet that will be animated
16 |
17 | ```html
18 |
19 | ```
20 |
21 | 3) Write the animation using CSS transition or animation
22 |
23 | ```css
24 | .outlet-enter-active,
25 | .outlet-leave-active {
26 | transition: opacity 0.5s;
27 | }
28 |
29 | .outlet-enter, .outlet-leave-to {
30 | opacity: 0;
31 | }
32 | ```
33 |
34 | The above example adds a fading effect to the element that is entering and the one which is leaving
35 |
36 | > The API is based on [Vue one](https://vuejs.org/v2/guide/transitions.html#Transition-Classes) and most of the Vue animations can be converted with little changes
37 |
38 | Is possible to configure the CSS classes prefix through animation attribute, allowing to create more than one animation in same app:
39 |
40 | ```html
41 |
42 | ```
43 |
44 | ```css
45 | .bounce-enter {
46 | opacity: 0;
47 | }
48 |
49 | .bounce-enter-active {
50 | animation: bounce-in 0.5s;
51 | }
52 |
53 | .bounce-leave-active {
54 | animation: bounce-in 0.5s reverse;
55 | }
56 |
57 | @keyframes bounce-in {
58 | 0% {
59 | transform: scale(0);
60 | }
61 | 50% {
62 | transform: scale(1.5);
63 | }
64 | 100% {
65 | transform: scale(1);
66 | }
67 | }
68 | ```
69 |
70 | The example above uses classes prefixed with 'bounce-' instead of 'outlet-'
71 |
72 | [Live Demo](https://codesandbox.io/s/slick-router-css-animations-q0fzs)
73 |
74 | ## Customization
75 |
76 | Is possible to customize how animation is done by creating and registering animation hook classes. It must extend from AnimationHook:
77 |
78 | ```js
79 | import { AnimationHook } from 'slick-router/components/animated-outlet.js'
80 |
81 | class MyAnimation extends AnimationHook {
82 | beforeEnter (outlet, el) {
83 | // prepare element before is connected
84 | }
85 |
86 | enter (outlet, el) {
87 | // run enter animation
88 | }
89 |
90 | leave (outlet, el, done) {
91 | // run leave animation and call done on finish
92 | done()
93 | }
94 | }
95 | ```
96 |
97 | The hook class can be registered as default with `setDefaultAnimation` or to predefined animations using `registerAnimation`
98 |
99 | Out of box, is provided the `AnimateCSS` class that allows to use [animate.css](https://github.com/daneden/animate.css)
100 |
101 | ```js
102 | import {
103 | AnimatedOutlet,
104 | AnimateCSS,
105 | setDefaultAnimation,
106 | registerAnimation
107 | } from 'slick-router/components/animated-outlet.js'
108 |
109 | setDefaultAnimation(AnimateCSS, { enter: 'fadeIn', leave: 'fadeOut' })
110 |
111 | registerAnimation('funky', AnimateCSS, { enter: 'rotateInDownRight', leave: 'hinge' })
112 | ```
113 |
114 | ```html
115 |
116 |
117 |
118 |
119 |
120 | ```
121 |
122 | [Live demo](https://codesandbox.io/s/slick-router-animate-css-zpg96)
123 |
124 | It's possible to use JS animation libraries like [GSAP](https://codesandbox.io/s/slick-router-gsap-animations-oqbp5) or even as [standalone component](https://codesandbox.io/s/animated-outlet-page-transitions-7vgcy) (without routing envolved)
125 |
126 |
127 |
--------------------------------------------------------------------------------
/examples/hello-world-jquery/index.js:
--------------------------------------------------------------------------------
1 | import 'jquery'
2 | import { Router } from '../../lib/router.js'
3 |
4 | // create the router
5 | const router = new Router({
6 | log: true
7 | })
8 |
9 | // create some handlers
10 | const application = {
11 | activate: function () {
12 | this.view = $(`
13 |
24 | `)
25 | }
26 | }
27 |
28 | const home = {
29 | activate: function () {
30 | this.view = $(`
31 |
32 |
Tweets
33 |
40 |
47 |
54 |
55 | `)
56 | }
57 | }
58 |
59 | const messages = {
60 | activate: function () {
61 | this.view = $(`
62 |
63 |
Messages
64 |
You have no direct messages
65 |
66 | `)
67 | }
68 | }
69 |
70 | const profile = {
71 | activate: function () {
72 | this.view = $(`
73 |
76 | `)
77 | }
78 | }
79 |
80 | const profileIndex = {
81 | activate: function (params) {
82 | this.view = $(`
83 |
84 |
${params.user} profile
85 |
86 | `)
87 | }
88 | }
89 |
90 | // provide your route map
91 | // in this particular case we configure handlers by attaching
92 | // them to routes via options. This is one of several ways you
93 | // could choose to handle transitions in your app.
94 | // * you can attach handlers to the route options like here
95 | // * you could get the route handlers of some map somewhere by name
96 | // * you can have a dynamic require that pulls in the route from a file by name
97 | router.map((route) => {
98 | route('application', { path: '/', handler: application, abstract: true }, () => {
99 | route('home', { path: '', handler: home })
100 | route('messages', { handler: messages })
101 | route('status', { path: ':user/status/:id' })
102 | route('profile', { path: ':user', handler: profile, abstract: true }, () => {
103 | route('profile.index', { path: '', handler: profileIndex })
104 | route('profile.lists')
105 | route('profile.edit')
106 | })
107 | })
108 | })
109 |
110 | // install middleware that will handle transitions
111 | router.use(function activate (transition) {
112 | transition.routes.forEach((route, i) => {
113 | const handler = route.options.handler
114 | router.log(`Transition #${transition.id} activating '${route.name}'`)
115 | handler.activate(transition.params)
116 | if (handler.view) {
117 | const parent = transition.routes[i - 1]
118 | const $container = parent ? parent.options.handler.view.find('.Container') : $(document.body)
119 | $container.html(handler.view)
120 | }
121 | })
122 | })
123 |
124 | // start listening to browser's location bar changes
125 | router.listen()
126 |
--------------------------------------------------------------------------------
/docs/intro.md:
--------------------------------------------------------------------------------
1 | # Usage guide
2 |
3 | When your application starts, the router is responsible for loading data, rendering views and otherwise setting up application state. It does so by translating every URL change to a transition object and a list of matching routes. You then need to apply a middleware function to translate the transition data into the desired state of your application.
4 |
5 | First create an instance of the router.
6 |
7 | ```js
8 | import { Router } from 'slick-router/core.js';
9 |
10 | const router = new Router({
11 | pushState: true
12 | });
13 | ```
14 |
15 | Then use the `map` method to declare the route map.
16 |
17 | ```js
18 | router.map(function (route) {
19 | route('application', { path: '/', abstract: true, handler: App }, function () {
20 | route('index', { path: '', handler: Index })
21 | route('about', { handler: About })
22 | route('favorites', { path: 'favs', handler: Favorites })
23 | route('message', { path: 'message/:id', handler: Message })
24 | })
25 | });
26 | ```
27 |
28 | Next, install middleware.
29 |
30 | ```js
31 | router.use(function activate (transition) {
32 | transition.routes.forEach(function (route) {
33 | route.options.handler.activate(transition.params, transition.query)
34 | })
35 | })
36 | ```
37 |
38 | Now, when the user enters `/about` page, Slick Router will call the middleware with the transition object and `transition.routes` will be the route descriptors of `application` and `about` routes.
39 |
40 | Note that you can leave off the path if you want to use the route name as the path. For example, these are equivalent
41 |
42 | ```js
43 | router.map(function(route) {
44 | route('about');
45 | });
46 |
47 | // or
48 |
49 | router.map(function(route) {
50 | route('about', {path: 'about'});
51 | });
52 | ```
53 |
54 | To generate links to the different routes use `generate` and pass the name of the route:
55 |
56 | ```js
57 | router.generate('favorites')
58 | // => /favs
59 | router.generate('index');
60 | // => /
61 | router.generate('messages', {id: 24});
62 | ```
63 |
64 | If you disable pushState (`pushState: false`), the generated links will start with `#`.
65 |
66 | ### Route params
67 |
68 | Routes can have dynamic urls by specifying patterns in the `path` option. For example:
69 |
70 | ```js
71 | router.map(function(route) {
72 | route('posts');
73 | route('post', { path: '/post/:postId' });
74 | });
75 |
76 | router.use(function (transition) {
77 | console.log(transition.params)
78 | // => {postId: 5}
79 | });
80 |
81 | router.transitionTo('/post/5')
82 | ```
83 |
84 | See what other types of dynamic routes is supported in the [api docs](api.md#dynamic-paths).
85 |
86 | ### Route Nesting
87 |
88 | Route nesting is one of the core features of slick-router. It's useful to nest routes when you want to configure each route to perform a different role in rendering the page - e.g. the root `application` route can do some initial data loading/initialization, but you can avoid redoing that work on subsequent transitions by checking if the route is already in the middleware. The nested routes can then load data specific for a given page. Nesting routes is also very useful for rendering nested UIs, e.g. if you're building an email application, you might have the following route map
89 |
90 | ```js
91 | router.map(function(route) {
92 | route('gmail', {path: '/', abstract: true}, function () {
93 | route('inbox', {path: ''}, function () {
94 | route('email', {path: 'm/:emailId'}, function () {
95 | route('email.raw')
96 | })
97 | })
98 | })
99 | })
100 | ```
101 |
102 | This router creates the following routes:
103 |
104 |
105 |
106 |
107 |
108 | URL
109 | Route Name
110 | Purpose
111 |
112 |
113 |
114 | N/A
115 | gmail
116 | Can't route to it, it's an abstract route.
117 |
118 |
119 | /
120 | inbox
121 | Load 1 page of emails and render it.
122 |
123 |
124 | /m/:emailId/
125 | email
126 | Load the email contents of email with id `transition.params.emailId` and expand it in the list of emails while keeping the email list rendered.
127 |
128 |
129 | /m/:mailId/raw
130 | email.raw
131 | Render the raw textual version of the email in an expanded pane.
132 |
133 |
134 |
135 |
136 | ## Examples
137 |
138 | I hope you found this brief guide useful, check out some example apps next in the [examples](../examples) dir.
139 |
--------------------------------------------------------------------------------
/docs/middlewares/routerlinks.md:
--------------------------------------------------------------------------------
1 | # routerLinks middleware
2 |
3 | Automatically setup route links and monitor the router transitions. When the corresponding route
4 | is active, the 'active' class will be added to the element
5 |
6 | ## Usage
7 |
8 | Wrap router links elements with `router-links` web component
9 |
10 | ```javascript
11 | import 'slick-router/components/router-links'
12 |
13 | class MyView extends LitElement {
14 | render() {
15 | return html`
16 |
17 | Home
18 | About
19 |
20 | `
21 | }
22 | }
23 | ```
24 |
25 | Manually bind a dom element:
26 |
27 | ```js
28 | import { bindRouterLinks } from 'slick-router/middlewares/router-links'
29 |
30 | const navEl = document.getElementById('main-nav')
31 | const unbind = bindRouterLinks(navEl)
32 |
33 | // call unbind when (if) navEl element is removed from DOM
34 | ```
35 |
36 | ## Options
37 |
38 | Both `router-links` and `bindRouterLinks` can be configured
39 |
40 |
41 | ### `query` and `params`
42 |
43 | Returns default values to `query` or `params`
44 |
45 | It can be defined as a hash or a function that returns a hash.
46 |
47 | The function is called with the onwner element as `this` and route name and link element as arguments
48 |
49 |
50 | ```javascript
51 | const routeParams = {
52 | id: 3
53 | }
54 |
55 | const routeQuery = {
56 | foo: 'bar'
57 | }
58 |
59 |
60 | const unbind = bindRouterLinks(navEl, { params: routeParams, query: routeQuery })
61 |
62 | // or
63 | class MyView extends LitElement {
64 | render() {
65 | return html`
66 |
67 | Home
68 | About
69 |
70 | `
71 | }
72 | }
73 | ```
74 |
75 |
76 | ```javascript
77 | function getRouteParams(route, el) {
78 | if (route === 'home') return { id: this.rootId }
79 | }
80 |
81 | function getRouteQuery(route, el) {
82 | if (route === 'child') return { foo: 'bar' }
83 | if (el.id === 'my-link') return { tag: el.tagName }
84 | }
85 |
86 | const unbind = bindRouterLinks(navEl, { params: getRouteParams, query: getRouteQuery })
87 |
88 | // or
89 | class MyView extends LitElement {
90 | render() {
91 | return html`
92 |
93 | Home
94 | About
95 |
96 | `
97 | }
98 | }
99 | ```
100 |
101 |
102 | ## Markup
103 |
104 | The router links are configured with HTML attributes
105 |
106 | They must be child, direct or not, of an element with 'routerlinks' attribute or the one defined in [selector option](#selector).
107 |
108 | ### route
109 |
110 | Defines the route be transitioned to. Should be the name of a route configured in the router map.
111 | When the element is an anchor (a), its href will be expanded to the route path.
112 |
113 | Adding a route attribute to a non anchor element will setup a click event handler that calls `router.transitionTo`
114 | with the appropriate arguments. The exception is when the element has an anchor child. In this case the anchor href
115 | will be expanded.
116 |
117 | ### param-*
118 |
119 | Defines a param value where the param name is the substring after `param-` prefix
120 |
121 | ### query-*
122 |
123 | Defines a query value where the query name is the substring after `query-` prefix
124 |
125 | ### active-class
126 |
127 | Defines the class to be toggled in the element with route attribute according to the route active state. By default is 'active'.
128 | If is set to an empty string, no class is added.
129 |
130 | ### exact
131 |
132 | When present the active class will be set only the route path matches exactly with the one being transitioned.
133 |
134 | ### replace
135 |
136 | When present it will use `replaceWith` instead of `transitionTo` thus not adding a history entry
137 |
138 | Example:
139 |
140 | ```html
141 |
142 |
143 |
All contacts
144 |
145 |
146 |
First Contact
147 |
148 |
149 |
Home
150 |
151 |
152 |
Home
153 |
154 |
155 |
Contacts
156 |
157 |
158 |
Contacts
159 |
160 |
161 |
164 |
165 | ```
--------------------------------------------------------------------------------
/tests/unit/functionDslTest.js:
--------------------------------------------------------------------------------
1 | import functionDsl from '../../lib/function-dsl'
2 | import 'chai/chai.js'
3 |
4 | const { assert } = window.chai
5 | const { describe, it } = window
6 |
7 | describe('function-dsl', () => {
8 | it('simple route map', () => {
9 | const routes = functionDsl((route) => {
10 | route('application')
11 | })
12 | assert.deepEqual(routes, [
13 | {
14 | name: 'application',
15 | path: 'application',
16 | options: { path: 'application' },
17 | routes: [],
18 | },
19 | ])
20 | })
21 |
22 | it('simple route map with options', () => {
23 | const routes = functionDsl((route) => {
24 | route('application', { path: '/', foo: 'bar' })
25 | })
26 | assert.deepEqual(routes, [
27 | {
28 | name: 'application',
29 | path: '/',
30 | options: { foo: 'bar', path: '/' },
31 | routes: [],
32 | },
33 | ])
34 | })
35 |
36 | it('simple nested route map', () => {
37 | const routes = functionDsl((route) => {
38 | route('application', () => {
39 | route('child')
40 | })
41 | })
42 | assert.deepEqual(routes, [
43 | {
44 | name: 'application',
45 | path: 'application',
46 | options: { path: 'application' },
47 | routes: [
48 | {
49 | name: 'child',
50 | path: 'child',
51 | options: { path: 'child' },
52 | routes: [],
53 | },
54 | ],
55 | },
56 | ])
57 | })
58 |
59 | it('route with dot names and no path', () => {
60 | const routes = functionDsl((route) => {
61 | route('application', () => {
62 | route('application.child')
63 | })
64 | })
65 | assert.deepEqual(routes, [
66 | {
67 | name: 'application',
68 | path: 'application',
69 | options: { path: 'application' },
70 | routes: [
71 | {
72 | name: 'application.child',
73 | path: 'child',
74 | options: { path: 'child' },
75 | routes: [],
76 | },
77 | ],
78 | },
79 | ])
80 | })
81 |
82 | it('complex example', () => {
83 | const routes = functionDsl((route) => {
84 | route('application', { abstract: true }, () => {
85 | route('notifications')
86 | route('messages', () => {
87 | route('unread', () => {
88 | route('priority')
89 | })
90 | route('read')
91 | route('draft', { abstract: true }, () => {
92 | route('recent')
93 | })
94 | })
95 | route('status', { path: ':user/status/:id' })
96 | })
97 | route('anotherTopLevel', () => {
98 | route('withChildren')
99 | })
100 | })
101 | assert.deepEqual(routes, [
102 | {
103 | name: 'application',
104 | path: 'application',
105 | options: { path: 'application', abstract: true },
106 | routes: [
107 | {
108 | name: 'notifications',
109 | path: 'notifications',
110 | options: { path: 'notifications' },
111 | routes: [],
112 | },
113 | {
114 | name: 'messages',
115 | path: 'messages',
116 | options: { path: 'messages' },
117 | routes: [
118 | {
119 | name: 'unread',
120 | path: 'unread',
121 | options: { path: 'unread' },
122 | routes: [
123 | {
124 | name: 'priority',
125 | path: 'priority',
126 | options: { path: 'priority' },
127 | routes: [],
128 | },
129 | ],
130 | },
131 | {
132 | name: 'read',
133 | path: 'read',
134 | options: { path: 'read' },
135 | routes: [],
136 | },
137 | {
138 | name: 'draft',
139 | path: 'draft',
140 | options: { path: 'draft', abstract: true },
141 | routes: [
142 | {
143 | name: 'recent',
144 | path: 'recent',
145 | options: { path: 'recent' },
146 | routes: [],
147 | },
148 | ],
149 | },
150 | ],
151 | },
152 | {
153 | name: 'status',
154 | path: ':user/status/:id',
155 | options: { path: ':user/status/:id' },
156 | routes: [],
157 | },
158 | ],
159 | },
160 | {
161 | name: 'anotherTopLevel',
162 | path: 'anotherTopLevel',
163 | options: { path: 'anotherTopLevel' },
164 | routes: [
165 | {
166 | name: 'withChildren',
167 | path: 'withChildren',
168 | options: { path: 'withChildren' },
169 | routes: [],
170 | },
171 | ],
172 | },
173 | ])
174 | })
175 | })
176 |
--------------------------------------------------------------------------------
/tests/location-bar/location_bar_test.js:
--------------------------------------------------------------------------------
1 | (function() {
2 |
3 | var _ = window.underscore;
4 | var $ = window.nonGlobaljQuery;
5 | var LocationBar = window.LocationBar;
6 | var location;
7 | var locationBar, locationBar1, locationBar2;
8 |
9 | // QUnit.config.filter = "routes (simple)";
10 |
11 | var Location = function(href) {
12 | this.replace(href);
13 | };
14 |
15 | _.extend(Location.prototype, {
16 |
17 | replace: function(href) {
18 | _.extend(this, _.pick($(' ', {href: href})[0],
19 | 'href',
20 | 'hash',
21 | 'host',
22 | 'search',
23 | 'fragment',
24 | 'pathname',
25 | 'protocol'
26 | ));
27 | // In IE, anchor.pathname does not contain a leading slash though
28 | // window.location.pathname does.
29 | if (!/^\//.test(this.pathname)) this.pathname = '/' + this.pathname;
30 | },
31 |
32 | toString: function() {
33 | return this.href;
34 | }
35 |
36 | });
37 |
38 | module("location-bar", {
39 |
40 | setup: function() {
41 | location = new Location('http://example.com');
42 | window.location.hash = "#setup";
43 | locationBar = new LocationBar();
44 | locationBar.interval = 9;
45 | locationBar.start({pushState: false});
46 | },
47 |
48 | teardown: function() {
49 | locationBar.stop();
50 | }
51 |
52 | });
53 |
54 | asyncTest("route", 1, function() {
55 | locationBar.route(/^(.*?)$/, function (path) {
56 | equal(path, 'search/news');
57 | start();
58 | });
59 | window.location.hash = "search/news";
60 | });
61 |
62 | asyncTest("onChange", 1, function () {
63 | locationBar.onChange(function (path) {
64 | equal(path, "some/url?withParam=1&moreParams=2");
65 | start();
66 | });
67 | window.location.hash = "some/url?withParam=1&moreParams=2";
68 | });
69 |
70 | asyncTest("routes via update", 2, function() {
71 | locationBar.onChange(function (path) {
72 | equal(path, "search/manhattan/p20");
73 | start();
74 | });
75 | locationBar.update("search/manhattan/p20", {trigger: true});
76 | equal(window.location.hash, "#search/manhattan/p20");
77 | });
78 |
79 | test("routes via update with {replace: true}", 1, function() {
80 | location.replace('http://example.com#start_here');
81 | locationBar.location = location;
82 | locationBar.checkUrl();
83 | location.replace = function(href) {
84 | strictEqual(href, new Location('http://example.com#end_here').href);
85 | };
86 | locationBar.update('end_here', {replace: true});
87 | });
88 |
89 | asyncTest("routes via update with query params", 2, function() {
90 | locationBar.onChange(function (path) {
91 | equal(path, "search/manhattan/p20?id=1&foo=bar");
92 | start();
93 | });
94 | locationBar.update("search/manhattan/p20?id=1&foo=bar", {trigger: true});
95 | equal(window.location.hash, "#search/manhattan/p20?id=1&foo=bar");
96 | });
97 |
98 | module("multiple instances of location-bar", {
99 |
100 | setup: function() {
101 | location = new Location('http://example.com');
102 | window.location.hash = "#setup";
103 | },
104 |
105 | teardown: function() {
106 | locationBar1.stop();
107 | locationBar2.stop();
108 | }
109 |
110 | });
111 |
112 | asyncTest("can all listen in", 3, function () {
113 | var count = 0;
114 |
115 | locationBar1 = new LocationBar();
116 | locationBar1.route(/^search\/.*$/, function (path) {
117 | if (count < 2) {
118 | equal(path, 'search/news');
119 | proceed();
120 | } else {
121 | equal(path, 'search/food')
122 | start();
123 | }
124 | });
125 | locationBar1.start({pushState: false});
126 |
127 | // navigate first
128 | window.location.hash = "search/news";
129 |
130 | // and then add another location bar, which when
131 | // started should match immediately
132 | locationBar2 = new LocationBar();
133 | locationBar2.route(/^search\/news.*$/, function (path) {
134 | equal(path, 'search/news');
135 | proceed();
136 | });
137 | locationBar2.start();
138 |
139 | // second part of the test where we navigate for the second time
140 | function proceed() {
141 | if (++count < 2) return;
142 | window.location.hash = "search/food";
143 | }
144 | });
145 |
146 | asyncTest("can all update", 3, function () {
147 | var count = 0;
148 | locationBar1 = new LocationBar();
149 | locationBar1.route(/^search\/.*$/, function (path) {
150 | if (count < 2) {
151 | equal(path, 'search/news');
152 | proceed();
153 | } else {
154 | equal(path, 'search/food')
155 | start();
156 | }
157 | });
158 | locationBar1.start({pushState: false});
159 |
160 | locationBar2 = new LocationBar();
161 | locationBar2.route(/^search\/news.*$/, function (path) {
162 | equal(path, 'search/news');
163 | proceed();
164 | });
165 | locationBar2.start();
166 |
167 | locationBar1.update('search/news', {trigger: true});
168 |
169 | function proceed() {
170 | if (++count < 2) return;
171 | locationBar1.update('search/food', {trigger: true});
172 | }
173 | });
174 |
175 | })();
176 |
--------------------------------------------------------------------------------
/tests/location-bar/vendor/qunit.css:
--------------------------------------------------------------------------------
1 | /**
2 | * QUnit v1.12.0 - A JavaScript Unit Testing Framework
3 | *
4 | * http://qunitjs.com
5 | *
6 | * Copyright 2012 jQuery Foundation and other contributors
7 | * Released under the MIT license.
8 | * http://jquery.org/license
9 | */
10 |
11 | /** Font Family and Sizes */
12 |
13 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult {
14 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
15 | }
16 |
17 | #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; }
18 | #qunit-tests { font-size: smaller; }
19 |
20 |
21 | /** Resets */
22 |
23 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter {
24 | margin: 0;
25 | padding: 0;
26 | }
27 |
28 |
29 | /** Header */
30 |
31 | #qunit-header {
32 | padding: 0.5em 0 0.5em 1em;
33 |
34 | color: #8699a4;
35 | background-color: #0d3349;
36 |
37 | font-size: 1.5em;
38 | line-height: 1em;
39 | font-weight: normal;
40 |
41 | border-radius: 5px 5px 0 0;
42 | -moz-border-radius: 5px 5px 0 0;
43 | -webkit-border-top-right-radius: 5px;
44 | -webkit-border-top-left-radius: 5px;
45 | }
46 |
47 | #qunit-header a {
48 | text-decoration: none;
49 | color: #c2ccd1;
50 | }
51 |
52 | #qunit-header a:hover,
53 | #qunit-header a:focus {
54 | color: #fff;
55 | }
56 |
57 | #qunit-testrunner-toolbar label {
58 | display: inline-block;
59 | padding: 0 .5em 0 .1em;
60 | }
61 |
62 | #qunit-banner {
63 | height: 5px;
64 | }
65 |
66 | #qunit-testrunner-toolbar {
67 | padding: 0.5em 0 0.5em 2em;
68 | color: #5E740B;
69 | background-color: #eee;
70 | overflow: hidden;
71 | }
72 |
73 | #qunit-userAgent {
74 | padding: 0.5em 0 0.5em 2.5em;
75 | background-color: #2b81af;
76 | color: #fff;
77 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
78 | }
79 |
80 | #qunit-modulefilter-container {
81 | float: right;
82 | }
83 |
84 | /** Tests: Pass/Fail */
85 |
86 | #qunit-tests {
87 | list-style-position: inside;
88 | }
89 |
90 | #qunit-tests li {
91 | padding: 0.4em 0.5em 0.4em 2.5em;
92 | border-bottom: 1px solid #fff;
93 | list-style-position: inside;
94 | }
95 |
96 | #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running {
97 | display: none;
98 | }
99 |
100 | #qunit-tests li strong {
101 | cursor: pointer;
102 | }
103 |
104 | #qunit-tests li a {
105 | padding: 0.5em;
106 | color: #c2ccd1;
107 | text-decoration: none;
108 | }
109 | #qunit-tests li a:hover,
110 | #qunit-tests li a:focus {
111 | color: #000;
112 | }
113 |
114 | #qunit-tests li .runtime {
115 | float: right;
116 | font-size: smaller;
117 | }
118 |
119 | .qunit-assert-list {
120 | margin-top: 0.5em;
121 | padding: 0.5em;
122 |
123 | background-color: #fff;
124 |
125 | border-radius: 5px;
126 | -moz-border-radius: 5px;
127 | -webkit-border-radius: 5px;
128 | }
129 |
130 | .qunit-collapsed {
131 | display: none;
132 | }
133 |
134 | #qunit-tests table {
135 | border-collapse: collapse;
136 | margin-top: .2em;
137 | }
138 |
139 | #qunit-tests th {
140 | text-align: right;
141 | vertical-align: top;
142 | padding: 0 .5em 0 0;
143 | }
144 |
145 | #qunit-tests td {
146 | vertical-align: top;
147 | }
148 |
149 | #qunit-tests pre {
150 | margin: 0;
151 | white-space: pre-wrap;
152 | word-wrap: break-word;
153 | }
154 |
155 | #qunit-tests del {
156 | background-color: #e0f2be;
157 | color: #374e0c;
158 | text-decoration: none;
159 | }
160 |
161 | #qunit-tests ins {
162 | background-color: #ffcaca;
163 | color: #500;
164 | text-decoration: none;
165 | }
166 |
167 | /*** Test Counts */
168 |
169 | #qunit-tests b.counts { color: black; }
170 | #qunit-tests b.passed { color: #5E740B; }
171 | #qunit-tests b.failed { color: #710909; }
172 |
173 | #qunit-tests li li {
174 | padding: 5px;
175 | background-color: #fff;
176 | border-bottom: none;
177 | list-style-position: inside;
178 | }
179 |
180 | /*** Passing Styles */
181 |
182 | #qunit-tests li li.pass {
183 | color: #3c510c;
184 | background-color: #fff;
185 | border-left: 10px solid #C6E746;
186 | }
187 |
188 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; }
189 | #qunit-tests .pass .test-name { color: #366097; }
190 |
191 | #qunit-tests .pass .test-actual,
192 | #qunit-tests .pass .test-expected { color: #999999; }
193 |
194 | #qunit-banner.qunit-pass { background-color: #C6E746; }
195 |
196 | /*** Failing Styles */
197 |
198 | #qunit-tests li li.fail {
199 | color: #710909;
200 | background-color: #fff;
201 | border-left: 10px solid #EE5757;
202 | white-space: pre;
203 | }
204 |
205 | #qunit-tests > li:last-child {
206 | border-radius: 0 0 5px 5px;
207 | -moz-border-radius: 0 0 5px 5px;
208 | -webkit-border-bottom-right-radius: 5px;
209 | -webkit-border-bottom-left-radius: 5px;
210 | }
211 |
212 | #qunit-tests .fail { color: #000000; background-color: #EE5757; }
213 | #qunit-tests .fail .test-name,
214 | #qunit-tests .fail .module-name { color: #000000; }
215 |
216 | #qunit-tests .fail .test-actual { color: #EE5757; }
217 | #qunit-tests .fail .test-expected { color: green; }
218 |
219 | #qunit-banner.qunit-fail { background-color: #EE5757; }
220 |
221 |
222 | /** Result */
223 |
224 | #qunit-testresult {
225 | padding: 0.5em 0.5em 0.5em 2.5em;
226 |
227 | color: #2b81af;
228 | background-color: #D2E0E6;
229 |
230 | border-bottom: 1px solid white;
231 | }
232 | #qunit-testresult .module-name {
233 | font-weight: bold;
234 | }
235 |
236 | /** Fixture */
237 |
238 | #qunit-fixture {
239 | position: absolute;
240 | top: -10000px;
241 | left: -10000px;
242 | width: 1000px;
243 | height: 1000px;
244 | }
245 |
--------------------------------------------------------------------------------
/tests/unit/arrayDslTest.js:
--------------------------------------------------------------------------------
1 | import dsl from '../../lib/array-dsl'
2 | import 'chai/chai.js'
3 |
4 | const { assert } = window.chai
5 | const { describe, it } = window
6 |
7 | describe('array-dsl', () => {
8 | it('simple route map', () => {
9 | const routes = dsl([{ name: 'application' }])
10 | assert.deepEqual(routes, [
11 | {
12 | name: 'application',
13 | path: 'application',
14 | options: { path: 'application' },
15 | routes: [],
16 | },
17 | ])
18 | })
19 |
20 | it('simple route map with options', () => {
21 | const routes = dsl([
22 | {
23 | name: 'application',
24 | path: '/',
25 | foo: 'bar',
26 | },
27 | ])
28 |
29 | assert.deepEqual(routes, [
30 | {
31 | name: 'application',
32 | path: '/',
33 | options: { foo: 'bar', path: '/' },
34 | routes: [],
35 | },
36 | ])
37 | })
38 |
39 | it('simple nested route map', () => {
40 | const routes = dsl([
41 | {
42 | name: 'application',
43 | children: [
44 | {
45 | name: 'child',
46 | },
47 | ],
48 | },
49 | ])
50 |
51 | assert.deepEqual(routes, [
52 | {
53 | name: 'application',
54 | path: 'application',
55 | options: { path: 'application' },
56 | routes: [
57 | {
58 | name: 'child',
59 | path: 'child',
60 | options: { path: 'child' },
61 | routes: [],
62 | },
63 | ],
64 | },
65 | ])
66 | })
67 |
68 | it('route with dot names and no path', () => {
69 | const routes = dsl([
70 | {
71 | name: 'application',
72 | children: [
73 | {
74 | name: 'application.child',
75 | },
76 | ],
77 | },
78 | ])
79 |
80 | assert.deepEqual(routes, [
81 | {
82 | name: 'application',
83 | path: 'application',
84 | options: { path: 'application' },
85 | routes: [
86 | {
87 | name: 'application.child',
88 | path: 'child',
89 | options: { path: 'child' },
90 | routes: [],
91 | },
92 | ],
93 | },
94 | ])
95 | })
96 |
97 | it('complex example', () => {
98 | const routes = dsl([
99 | {
100 | name: 'application',
101 | abstract: true,
102 | children: [
103 | {
104 | name: 'notifications',
105 | },
106 | {
107 | name: 'messages',
108 | children: [
109 | {
110 | name: 'unread',
111 | children: [
112 | {
113 | name: 'priority',
114 | },
115 | ],
116 | },
117 | {
118 | name: 'read',
119 | },
120 | {
121 | name: 'draft',
122 | abstract: true,
123 | children: [
124 | {
125 | name: 'recent',
126 | },
127 | ],
128 | },
129 | ],
130 | },
131 | {
132 | name: 'status',
133 | path: ':user/status/:id',
134 | },
135 | ],
136 | },
137 | {
138 | name: 'anotherTopLevel',
139 | children: [
140 | {
141 | name: 'withChildren',
142 | },
143 | ],
144 | },
145 | ])
146 |
147 | assert.deepEqual(routes, [
148 | {
149 | name: 'application',
150 | path: 'application',
151 | options: { path: 'application', abstract: true },
152 | routes: [
153 | {
154 | name: 'notifications',
155 | path: 'notifications',
156 | options: { path: 'notifications' },
157 | routes: [],
158 | },
159 | {
160 | name: 'messages',
161 | path: 'messages',
162 | options: { path: 'messages' },
163 | routes: [
164 | {
165 | name: 'unread',
166 | path: 'unread',
167 | options: { path: 'unread' },
168 | routes: [
169 | {
170 | name: 'priority',
171 | path: 'priority',
172 | options: { path: 'priority' },
173 | routes: [],
174 | },
175 | ],
176 | },
177 | {
178 | name: 'read',
179 | path: 'read',
180 | options: { path: 'read' },
181 | routes: [],
182 | },
183 | {
184 | name: 'draft',
185 | path: 'draft',
186 | options: { path: 'draft', abstract: true },
187 | routes: [
188 | {
189 | name: 'recent',
190 | path: 'recent',
191 | options: { path: 'recent' },
192 | routes: [],
193 | },
194 | ],
195 | },
196 | ],
197 | },
198 | {
199 | name: 'status',
200 | path: ':user/status/:id',
201 | options: { path: ':user/status/:id' },
202 | routes: [],
203 | },
204 | ],
205 | },
206 | {
207 | name: 'anotherTopLevel',
208 | path: 'anotherTopLevel',
209 | options: { path: 'anotherTopLevel' },
210 | routes: [
211 | {
212 | name: 'withChildren',
213 | path: 'withChildren',
214 | options: { path: 'withChildren' },
215 | routes: [],
216 | },
217 | ],
218 | },
219 | ])
220 | })
221 | })
222 |
--------------------------------------------------------------------------------
/types/router.d.ts:
--------------------------------------------------------------------------------
1 | export type routeCallback = import('./function-dsl.js').routeCallback;
2 | export type RouteDef = import('./array-dsl.js').RouteDef;
3 | export type Transition = import('./transition.js').Transition;
4 | export type Route = {
5 | name: string;
6 | path: string;
7 | options: any;
8 | routes: Route[];
9 | };
10 | export type LocationParam = any | 'browser' | 'memory';
11 | export type RouterOptions = {
12 | routes?: routeCallback | RouteDef[];
13 | location?: LocationParam;
14 | logError?: boolean;
15 | qs?: any;
16 | patternCompiler?: any;
17 | };
18 | /**
19 | * @typedef {import('./function-dsl.js').routeCallback} routeCallback
20 | * @typedef {import('./array-dsl.js').RouteDef} RouteDef
21 | * @typedef {import('./transition.js').Transition} Transition
22 | *
23 | * @typedef Route
24 | * @property {String} name
25 | * @property {String} path
26 | * @property {Object} options
27 | * @property {Route[]} routes
28 | *
29 |
30 | * @typedef {Object | 'browser' | 'memory'} LocationParam
31 | *
32 | *
33 | * @typedef RouterOptions
34 | * @property {routeCallback | RouteDef[]} [routes]
35 | * @property {LocationParam} [location]
36 | * @property {Boolean} [logError]
37 | * @property {Object} [qs]
38 | * @property {Object} [patternCompiler]
39 | *
40 | */
41 | export class Router {
42 | /**
43 | * @param {RouterOptions} [options]
44 | */
45 | constructor(options?: RouterOptions);
46 | nextId: number;
47 | state: {};
48 | middleware: any[];
49 | options: {
50 | location: string;
51 | logError: boolean;
52 | qs: {
53 | parse(querystring: any): any;
54 | stringify(params: any): string;
55 | };
56 | patternCompiler: typeof patternCompiler;
57 | } & RouterOptions;
58 | /**
59 | * Add a middleware
60 | * @param {Function} middleware
61 | * @return {Router}
62 | * @api public
63 | */
64 | use(middleware: Function, options?: {}): Router;
65 | /**
66 | * Add the route map
67 | * @param {routeCallback | RouteDef[]} routes
68 | * @return {Router}
69 | * @api public
70 | */
71 | map(routes: routeCallback | RouteDef[]): Router;
72 | routes: Route[];
73 | matchers: any[];
74 | /**
75 | * Starts listening to the location changes.
76 | * @param {String} [path]
77 | * @return {Transition} initial transition
78 | *
79 | * @api public
80 | */
81 | listen(path?: string): Transition;
82 | location: any;
83 | /**
84 | * Transition to a different route. Passe in url or a route name followed by params and query
85 | * @param {String} name url or route name
86 | * @param {Object} [params] Optional
87 | * @param {Object} [query] Optional
88 | * @return {Transition} transition
89 | *
90 | * @api public
91 | */
92 | transitionTo(name: string, params?: any, query?: any): Transition;
93 | /**
94 | * Like transitionTo, but doesn't leave an entry in the browser's history,
95 | * so clicking back will skip this route
96 | * @param {String} name url or route name followed by params and query
97 | * @param {Record} [params]
98 | * @param {Record} [query]
99 | * @return {Transition} transition
100 | *
101 | * @api public
102 | */
103 | replaceWith(name: string, params?: Record, query?: Record): Transition;
104 | /**
105 | * Create an href
106 | * @param {String} name target route name
107 | * @param {Object} [params]
108 | * @param {Object} [query]
109 | * @return {String} href
110 | *
111 | * @api public
112 | */
113 | generate(name: string, params?: any, query?: any): string;
114 | /**
115 | * Stop listening to URL changes
116 | * @api public
117 | */
118 | destroy(): void;
119 | /**
120 | * Check if the given route/params/query combo is active
121 | * @param {String} name target route name
122 | * @param {Record} [params]
123 | * @param {Record} [query]
124 | * @param {Boolean} [exact]
125 | * @return {Boolean}
126 | *
127 | * @api public
128 | */
129 | isActive(name: string, params?: Record, query?: Record, exact?: boolean): boolean;
130 | /**
131 | * @api private
132 | * @param {String} method pushState or replaceState
133 | * @param {String} name target route name
134 | * @param {Object} [params]
135 | * @param {Object} [query]
136 | * @return {Transition} transition
137 | */
138 | doTransition(method: string, name: string, params?: any, query?: any): Transition;
139 | /**
140 | * Match the path against the routes
141 | * @param {String} path
142 | * @return {Object} the list of matching routes and params
143 | *
144 | * @api private
145 | */
146 | match(path: string): any;
147 | /**
148 | *
149 | * @param {string} path
150 | * @returns {Transition}
151 | */
152 | dispatch(path: string): Transition;
153 | /**
154 | * Create the default location.
155 | * This is used when no custom location is passed to
156 | * the listen call.
157 | * @param {LocationParam} path
158 | * @return {Object} location
159 | *
160 | * @api private
161 | */
162 | createLocation(path: LocationParam): any;
163 | log(...args: any[]): void;
164 | logError(...args: any[]): void;
165 | }
166 | /**
167 | * @description Helper to intercept links when using pushState but server is not configured for it
168 | * Link clicks are handled via the router avoiding browser page reload
169 | * @param {Router} router
170 | * @param {HTMLElement} el
171 | * @param {(e: Event, link: HTMLAnchorElement, router: Router) => void} clickHandler
172 | * @returns {Function} dispose
173 | */
174 | export function interceptLinks(router: Router, el?: HTMLElement, clickHandler?: (e: Event, link: HTMLAnchorElement, router: Router) => void): Function;
175 | import { patternCompiler } from './patternCompiler.js';
176 | //# sourceMappingURL=router.d.ts.map
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Slick Router
2 |
3 | Slick Router is a powerful, flexible router that translates URL changes into route transitions allowing to put the application into a desired state. It is derived from [cherrytree](https://github.com/QubitProducts/cherrytree) library (see [differences](docs/versions-differences.md)).
4 |
5 | ## Features
6 |
7 | - Out of the box support for Web Components:
8 | - Streamlined support for code spliting and lazy loading
9 | - Expose route state (query, params) to components
10 | - Property hooks to map global state to component props
11 | - Declarative handling of router links
12 | - Can nest routes allowing to create nested UI and/or state
13 | - Route transition is a first class citizen - abort, pause, resume, retry
14 | - Generate links in a systematic way, e.g. `router.generate('commit', {sha: '1e2760'})`
15 | - Use pushState or hashchange for URL change detection
16 | - Define path dynamic segments
17 | - Trigger router navigate programatically
18 | - With builtin middlewares/components:
19 | - components/animated-outlet: enable animation on route transitions
20 | - components/router-links: handle route related links state
21 | - middlewares/wc: advanced Web Component rendering and lifecycle (included in default export)
22 | - middlewares/router-links: handle route related links state (included in default export)
23 | - middlewares/events: fires route events on window
24 |
25 | ## Installation
26 |
27 | The default export (including web component support and routerLinks) is 17kb. The core Router class is ~12kB minified (without gzip compression). AnimatedOutlet web component, which can be used independent from the router, has a 2.5kb size.
28 | See [webpack test project](examples/tree-shaking) for more results.
29 |
30 | $ npm install --save slick-router
31 |
32 | ## Docs
33 |
34 | - [Intro Guide](docs/intro.md)
35 | - [Router Configuration](docs/router-configuration.md)
36 | - [Programmatic Navigation and Link Handling](docs/programmatic-navigation-and-link.md)
37 | - [Route Transition](docs/route-transition.md)
38 | - [Common Situations](docs/common-situations.md)
39 | - [Changelog](CHANGELOG.md)
40 |
41 | ## Builtin middlewares
42 |
43 | - [wc](docs/middlewares/wc.md) (advanced Web Component rendering and lifecycle)
44 | - [routerLinks](docs/middlewares/routerlinks.md) (handle route related links state)
45 | - [events](docs/middlewares/events.md) (fires route events on window)
46 |
47 | ## Builtin components
48 |
49 | - [animated-outlet](docs/components/animated-outlet.md) (enable animation on route transitions)
50 | - [router-links](docs/middlewares/routerlinks.md) (handle route related links state)
51 |
52 | ## Usage
53 |
54 | ### With Web Components
55 |
56 | The default Router class comes with Web Components and router links support.
57 |
58 | ```js
59 | import { Router } from 'slick-router'
60 |
61 | function checkAuth(transition) {
62 | if (!!localStorage.getItem('token')) {
63 | transition.redirectTo('login')
64 | }
65 | }
66 |
67 | // route tree definition
68 | const routes = function (route) {
69 | route('application', { path: '/', component: 'my-app' }, function () {
70 | route('feed', { path: '' })
71 | route('messages')
72 | route('status', { path: ':user/status/:id' })
73 | route('profile', { path: ':user', beforeEnter: checkAuth }, function () {
74 | route('profile.lists', { component: 'profile-lists' })
75 | route('profile.edit', { component: 'profile-edit' })
76 | })
77 | })
78 | }
79 |
80 | // create the router
81 | var router = new Router({
82 | routes,
83 | })
84 |
85 | // start listening to URL changes
86 | router.listen()
87 | ```
88 |
89 | ### Custom middlewares
90 |
91 | Is possible to create a router with customized behavior by using the core Router with middlewares.
92 |
93 | Check how to create middlewares in the [Router Configuration Guide](docs/router-configuration.md).
94 |
95 | ```js
96 | import { Router } from 'slick-router/core.js'
97 |
98 | // create a router similar to page.js - https://github.com/visionmedia/page.js
99 |
100 | const user = {
101 | list() {},
102 | async load() {},
103 | show() {},
104 | edit() {},
105 | }
106 |
107 | const routes = [
108 | {
109 | name: 'users',
110 | path: '/',
111 | handler: user.list,
112 | },
113 | {
114 | name: 'user.show',
115 | path: '/user/:id/edit',
116 | handler: [user.load, user.show],
117 | },
118 | ,
119 | {
120 | name: 'user.edit',
121 | path: '/user/:id/edit',
122 | handler: [user.load, user.edit],
123 | },
124 | ]
125 |
126 | const router = new Router({ routes })
127 |
128 | function normalizeHandlers(handlers) {
129 | return Array.isArray(handlers) ? handlers : [handlers]
130 | }
131 |
132 | router.use(async function (transition) {
133 | for (const route of transition.routes) {
134 | const handlers = normalizeHandlers(route.options.handler)
135 | for (const handler of handlers) {
136 | await handler(transition)
137 | }
138 | }
139 | })
140 |
141 | // protect private routes
142 | router.use(function privateHandler(transition) {
143 | if (transition.routes.some((route) => route.options.private)) {
144 | if (!userLogged()) {
145 | transition.cancel()
146 | }
147 | }
148 | })
149 |
150 | // for error logging attach a catch handler to transition promise...
151 | router.use(function errorHandler(transition) {
152 | transition.catch(function (err) {
153 | if (err.type !== 'TransitionCancelled' && err.type !== 'TransitionRedirected') {
154 | console.error(err.stack)
155 | }
156 | })
157 | })
158 |
159 | // ...or use the error hook
160 | router.use({
161 | error: function (transition, err) {
162 | if (err.type !== 'TransitionCancelled' && err.type !== 'TransitionRedirected') {
163 | console.error(err.stack)
164 | }
165 | },
166 | })
167 |
168 | // start listening to URL changes
169 | router.listen()
170 | ```
171 |
172 | ## Examples
173 |
174 | - [lit-element-mobx-realworld](https://github.com/blikblum/lit-element-mobx-realworld-example-app) A complete app that uses router advanced features. [Live demo](https://blikblum.github.io/lit-element-mobx-realworld-example-app)
175 |
176 | You can also clone this repo and run the `examples` locally:
177 |
178 | - [hello-world-jquery](examples/hello-world-jquery) - minimal example with good old jquery
179 | - [hello-world-wc](examples/hello-world-wc) - minimal example with Web Component (no build required)
180 | - [vanilla-blog](examples/vanilla-blog) - a small static demo of blog like app that uses no framework
181 |
182 | ## Browser Support
183 |
184 | Slick Router works in all modern browsers. No IE support.
185 |
186 | ---
187 |
188 | Copyright (c) 2024 Luiz Américo Pereira Câmara
189 |
190 | Copyright (c) 2017 Karolis Narkevicius
191 |
--------------------------------------------------------------------------------
/lib/transition.js:
--------------------------------------------------------------------------------
1 | import { clone } from './utils.js'
2 | import invariant from './invariant.js'
3 | import { TRANSITION_CANCELLED, TRANSITION_REDIRECTED } from './constants.js'
4 |
5 | /**
6 | * @typedef {import("./router.js").Route} Route
7 | */
8 |
9 | /**
10 | * @typedef {Pick} TransitionData
11 | */
12 |
13 | /**
14 | * @typedef Transition
15 | * @property {Route[]} routes
16 | * @property {string} pathname
17 | * @property {string} path
18 | * @property {Object} params
19 | * @property {Object} query
20 | * @property {TransitionData} prev
21 | * @property {(name: string, params?: any, query?: any) => Transition} redirectTo
22 | * @property {(name: string, params?: any, query?: any) => Transition} retry
23 | * @property {(error: string | Error) => void} cancel
24 | * @property {() => Promise} followRedirects
25 | * @property {function} then
26 | * @property {function} catch
27 | * @property {boolean} noop
28 | * @property {boolean} isCancelled
29 | */
30 |
31 | /**
32 | * @param {*} router
33 | * @param {Transition} transition
34 | * @param {*} err
35 | */
36 | function runError(router, transition, err) {
37 | router.middleware.forEach((m) => {
38 | m.error && m.error(transition, err)
39 | })
40 | }
41 |
42 | /**
43 | * @export
44 | * @param {*} options
45 | * @return {Transition}
46 | */
47 | export default function transition(options) {
48 | options = options || {}
49 |
50 | const router = options.router
51 | const log = router.log
52 | const logError = router.logError
53 |
54 | const path = options.path
55 | const match = options.match
56 | const routes = match.routes
57 | const params = match.params
58 | const pathname = match.pathname
59 | const query = match.query
60 |
61 | const id = options.id
62 | const startTime = Date.now()
63 | log('---')
64 | log('Transition #' + id, 'to', path)
65 | log(
66 | 'Transition #' + id,
67 | 'routes:',
68 | routes.map((r) => r.name),
69 | )
70 | log('Transition #' + id, 'params:', params)
71 | log('Transition #' + id, 'query:', query)
72 |
73 | // create the transition promise
74 | let resolve, reject
75 | const promise = new Promise(function (res, rej) {
76 | resolve = res
77 | reject = rej
78 | })
79 |
80 | // 1. make transition errors loud
81 | // 2. by adding this handler we make sure
82 | // we don't trigger the default 'Potentially
83 | // unhandled rejection' for cancellations
84 | promise
85 | .then(function () {
86 | log('Transition #' + id, 'completed in', Date.now() - startTime + 'ms')
87 | })
88 | .catch(function (err) {
89 | if (err.type !== TRANSITION_REDIRECTED && err.type !== TRANSITION_CANCELLED) {
90 | log('Transition #' + id, 'FAILED')
91 | logError(err)
92 | }
93 | })
94 |
95 | let cancelled = false
96 |
97 | const transition = {
98 | id,
99 | prev: {
100 | routes: clone(router.state.routes) || [],
101 | path: router.state.path || '',
102 | pathname: router.state.pathname || '',
103 | params: clone(router.state.params) || {},
104 | query: clone(router.state.query) || {},
105 | },
106 | routes: clone(routes),
107 | path,
108 | pathname,
109 | params: clone(params),
110 | query: clone(query),
111 | redirectTo: function (...args) {
112 | return router.transitionTo(...args)
113 | },
114 | retry: function () {
115 | return router.transitionTo(path)
116 | },
117 | cancel: function (err) {
118 | if (router.state.activeTransition !== transition) {
119 | return
120 | }
121 |
122 | if (transition.isCancelled) {
123 | return
124 | }
125 |
126 | router.state.activeTransition = null
127 | transition.isCancelled = true
128 | cancelled = true
129 |
130 | if (!err) {
131 | err = new Error(TRANSITION_CANCELLED)
132 | err.type = TRANSITION_CANCELLED
133 | }
134 | if (err.type === TRANSITION_CANCELLED) {
135 | log('Transition #' + id, 'cancelled')
136 | }
137 | if (err.type === TRANSITION_REDIRECTED) {
138 | log('Transition #' + id, 'redirected')
139 | }
140 |
141 | router.middleware.forEach((m) => {
142 | m.cancel && m.cancel(transition, err)
143 | })
144 | reject(err)
145 | },
146 | followRedirects: function () {
147 | return promise.catch(function (reason) {
148 | if (router.state.activeTransition) {
149 | return router.state.activeTransition.followRedirects()
150 | }
151 | return Promise.reject(reason)
152 | })
153 | },
154 |
155 | then: promise.then.bind(promise),
156 | catch: promise.catch.bind(promise),
157 | }
158 |
159 | router.middleware.forEach((m) => {
160 | m.before && m.before(transition)
161 | })
162 |
163 | // here we handle calls to all of the middlewares
164 | function callNext(i, prevResult) {
165 | let middleware
166 | let middlewareName
167 | // if transition has been cancelled - nothing left to do
168 | if (cancelled) {
169 | return
170 | }
171 | // done
172 | if (i < router.middleware.length) {
173 | middleware = router.middleware[i]
174 | middlewareName = middleware.name || 'anonymous'
175 | log('Transition #' + id, 'resolving middleware:', middlewareName)
176 | let middlewarePromise
177 | try {
178 | middlewarePromise = middleware.resolve
179 | ? middleware.resolve(transition, prevResult)
180 | : prevResult
181 | invariant(
182 | transition !== middlewarePromise,
183 | 'Middleware %s returned a transition which resulted in a deadlock',
184 | middlewareName,
185 | )
186 | } catch (err) {
187 | router.state.activeTransition = null
188 | runError(router, transition, err)
189 | return reject(err)
190 | }
191 | Promise.resolve(middlewarePromise)
192 | .then(function (result) {
193 | callNext(i + 1, result)
194 | })
195 | .catch(function (err) {
196 | log('Transition #' + id, 'resolving middleware:', middlewareName, 'FAILED')
197 | router.state.activeTransition = null
198 | runError(router, transition, err)
199 | reject(err)
200 | })
201 | } else {
202 | router.state = {
203 | activeTransition: null,
204 | routes,
205 | path,
206 | pathname,
207 | params,
208 | query,
209 | }
210 | router.middleware.forEach((m) => {
211 | m.done && m.done(transition)
212 | })
213 | resolve()
214 | }
215 | }
216 |
217 | if (!options.noop) {
218 | Promise.resolve().then(() => callNext(0))
219 | } else {
220 | resolve()
221 | }
222 |
223 | if (options.noop) {
224 | transition.noop = true
225 | }
226 |
227 | return transition
228 | }
229 |
--------------------------------------------------------------------------------
/lib/middlewares/router-links.js:
--------------------------------------------------------------------------------
1 | const routerLinksData = Symbol('routerLinksData')
2 | const linkContainers = new Set()
3 | let router
4 |
5 | /**
6 | * @callback RoutePropCallback
7 | * @param {String} routeName
8 | * @param {HTMLElement} routeEl
9 | * @return {Object}
10 | *
11 | * @typedef {Object} RouterLinksOptions
12 | * @property {Object | RoutePropCallback} [params]
13 | * @property {Object | RoutePropCallback} [query]
14 | */
15 |
16 | // Make a event delegation handler for the given `eventName` and `selector`
17 | // and attach it to `el`.
18 | // If selector is empty, the listener will be bound to `el`. If not, a
19 | // new handler that will recursively traverse up the event target's DOM
20 | // hierarchy looking for a node that matches the selector. If one is found,
21 | // the event's `delegateTarget` property is set to it and the return the
22 | // result of calling bound `listener` with the parameters given to the
23 | // handler.
24 |
25 | /**
26 | * @param {HTMLElement} el
27 | * @param {String} eventName
28 | * @param {String} selector
29 | * @param {Function} listener
30 | * @param {*} context
31 | * @return {Function}
32 | */
33 | const delegate = function (el, eventName, selector, listener, context) {
34 | const handler = function (e) {
35 | let node = e.target
36 | for (; node && node !== el; node = node.parentNode) {
37 | if (node.matches && node.matches(selector)) {
38 | e.selectorTarget = node
39 | listener.call(context, e)
40 | }
41 | }
42 | }
43 |
44 | handler.eventName = eventName
45 | el.addEventListener(eventName, handler, false)
46 | return handler
47 | }
48 |
49 | function isModifiedEvent(event) {
50 | return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
51 | }
52 |
53 | const undelegate = function (el, handler) {
54 | const eventName = handler.eventName
55 | el.removeEventListener(eventName, handler, false)
56 | }
57 |
58 | const camelize = (str) => {
59 | if (str.indexOf('-') === -1) return str
60 | const words = str.split('-')
61 | let result = ''
62 | for (let i = 0; i < words.length; i++) {
63 | const word = words[i]
64 | result += i ? word.charAt(0).toUpperCase() + word.slice(1) : word
65 | }
66 | return result
67 | }
68 |
69 | function mutationHandler(mutations, observer) {
70 | mutations.forEach(function (mutation) {
71 | if (mutation.type === 'attributes') {
72 | const attr = mutation.attributeName
73 | if (attr.indexOf('param-') === 0 || attr.indexOf('query-') === 0) {
74 | updateLink(mutation.target, observer.rootEl)
75 | }
76 | } else {
77 | mutation.addedNodes.forEach((node) => {
78 | if (node.nodeType === 1) {
79 | if (node.getAttribute('route')) updateLink(node, observer.rootEl)
80 | createLinks(observer.rootEl, node)
81 | }
82 | })
83 | }
84 | })
85 | }
86 |
87 | const elementsObserverConfig = { childList: true, subtree: true, attributes: true }
88 |
89 | function getAttributeValues(el, prefix, result) {
90 | const attributes = el.attributes
91 |
92 | for (let i = 0; i < attributes.length; i++) {
93 | const attr = attributes[i]
94 | if (attr.name.indexOf(prefix) === 0) {
95 | const paramName = camelize(attr.name.slice(prefix.length))
96 | result[paramName] = attr.value
97 | }
98 | }
99 | return result
100 | }
101 |
102 | function getDefaults(rootEl, routeName, propName, routeEl, options) {
103 | let result = options[propName]
104 | if (typeof result === 'function') result = result.call(rootEl, routeName, routeEl)
105 | return result || {}
106 | }
107 |
108 | function getRouteProp(rootEl, routeName, routeEl, propName, attrPrefix) {
109 | const options = rootEl[routerLinksData].options
110 | const defaults = getDefaults(rootEl, routeName, propName, routeEl, options)
111 | getAttributeValues(rootEl, attrPrefix, defaults)
112 | return getAttributeValues(routeEl, attrPrefix, defaults)
113 | }
114 |
115 | function updateActiveClass(el, routeName, params, query) {
116 | const activeClass = el.hasAttribute('active-class') ? el.getAttribute('active-class') : 'active'
117 | if (activeClass) {
118 | const isActive = router.isActive(routeName, params, query, el.hasAttribute('exact'))
119 | el.classList.toggle(activeClass, isActive)
120 | }
121 | }
122 |
123 | function updateLink(el, rootEl) {
124 | const routeName = el.getAttribute('route')
125 | if (!routeName) return
126 | const params = getRouteProp(rootEl, routeName, el, 'params', 'param-')
127 | const query = getRouteProp(rootEl, routeName, el, 'query', 'query-')
128 | try {
129 | const href = router.generate(routeName, params, query)
130 | const anchorEl = el.tagName === 'A' ? el : el.querySelector('a')
131 | if (anchorEl) anchorEl.setAttribute('href', href)
132 | if (!router.state.activeTransition) {
133 | updateActiveClass(el, routeName, params, query)
134 | }
135 | } catch (error) {
136 | console.warn(`Error generating link for "${routeName}": ${error}`)
137 | }
138 | }
139 |
140 | /**
141 | * @param {HTMLElement} rootEl
142 | */
143 | function createLinks(rootEl) {
144 | const routeEls = rootEl.querySelectorAll('[route]')
145 |
146 | routeEls.forEach((el) => {
147 | updateLink(el, rootEl)
148 | })
149 | }
150 |
151 | function linkClickHandler(e) {
152 | if (e.button !== 0 || isModifiedEvent(e)) return
153 | e.preventDefault()
154 | const el = e.selectorTarget
155 | const routeName = el.getAttribute('route')
156 | if (!routeName) return
157 | const params = getRouteProp(this, routeName, el, 'params', 'param-')
158 | const query = getRouteProp(this, routeName, el, 'query', 'query-')
159 | const method = el.hasAttribute('replace') ? 'replaceWith' : 'transitionTo'
160 | router[method](routeName, params, query)
161 | }
162 |
163 | /**
164 | * @export
165 | * @param {HTMLElement} rootEl
166 | * @param {RouterLinksOptions} [options={}]
167 | * @return {Function}
168 | */
169 | export function bindRouterLinks(rootEl, options = {}) {
170 | const observer = new MutationObserver(mutationHandler)
171 |
172 | observer.rootEl = rootEl
173 | rootEl[routerLinksData] = { options, observer }
174 |
175 | const eventHandler = delegate(rootEl, 'click', '[route]', linkClickHandler, rootEl)
176 | createLinks(rootEl)
177 | observer.observe(rootEl, elementsObserverConfig)
178 |
179 | linkContainers.add(rootEl)
180 |
181 | return function () {
182 | linkContainers.delete(rootEl)
183 | undelegate(rootEl, eventHandler)
184 | }
185 | }
186 |
187 | function create(instance) {
188 | router = instance
189 | }
190 |
191 | function done() {
192 | linkContainers.forEach((rootEl) => {
193 | rootEl.querySelectorAll('[route]').forEach((el) => {
194 | const routeName = el.getAttribute('route')
195 | if (!routeName) return
196 | const params = getRouteProp(rootEl, routeName, el, 'params', 'param-')
197 | const query = getRouteProp(rootEl, routeName, el, 'query', 'query-')
198 | updateActiveClass(el, routeName, params, query)
199 | })
200 | })
201 | }
202 |
203 | export const routerLinks = {
204 | create,
205 | done,
206 | }
207 |
--------------------------------------------------------------------------------
/tests/functional/eventsTest.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-expressions */
2 |
3 | import 'chai/chai.js'
4 | import { Router } from '../../lib/router'
5 | import { events } from '../../lib/middlewares/events'
6 | import { expect } from '@open-wc/testing'
7 | import { spy } from 'sinon'
8 |
9 | const { describe, it, beforeEach, afterEach } = window
10 |
11 | describe('events middleware', () => {
12 | const routes = (route) => {
13 | route('application', () => {
14 | route('notifications')
15 | route('messages')
16 | route('status', { path: ':user/status/:id' })
17 | })
18 | }
19 | let router
20 |
21 | describe('events', () => {
22 | beforeEach(() => {
23 | router = new Router({ location: 'memory', routes })
24 | router.use(events)
25 | router.listen()
26 | })
27 |
28 | afterEach(() => {
29 | router.destroy()
30 | })
31 |
32 | describe('before:transition', () => {
33 | let beforeTransitionSpy, transitionSpy
34 |
35 | beforeEach(() => {
36 | beforeTransitionSpy = spy()
37 | transitionSpy = spy()
38 | window.addEventListener('router-before:transition', beforeTransitionSpy)
39 | window.addEventListener('router-transition', transitionSpy)
40 | })
41 |
42 | afterEach(() => {
43 | window.removeEventListener('router-before:transition', beforeTransitionSpy)
44 | window.removeEventListener('router-transition', transitionSpy)
45 | })
46 |
47 | it('should be fired on completed transition', async () => {
48 | await router.transitionTo('messages')
49 | expect(beforeTransitionSpy).to.be.calledOnce
50 | expect(beforeTransitionSpy).to.be.calledBefore(transitionSpy)
51 |
52 | await router.transitionTo('notifications')
53 | expect(beforeTransitionSpy).to.be.calledTwice
54 | })
55 |
56 | it('should be fired on cancelled transition', async () => {
57 | router.use((transition) => transition.cancel())
58 | try {
59 | await router.transitionTo('messages')
60 | } catch (error) {}
61 | expect(beforeTransitionSpy).to.be.called
62 | })
63 |
64 | it('should be fired on cancelled transition', async () => {
65 | router.use(() => {
66 | throw new Error('error')
67 | })
68 | try {
69 | await router.transitionTo('messages')
70 | } catch (error) {}
71 | expect(beforeTransitionSpy).to.be.called
72 | })
73 | })
74 |
75 | describe('transition', () => {
76 | let transitionSpy
77 |
78 | beforeEach(() => {
79 | transitionSpy = spy()
80 | window.addEventListener('router-transition', transitionSpy)
81 | })
82 |
83 | afterEach(() => {
84 | window.removeEventListener('router-transition', transitionSpy)
85 | })
86 |
87 | it('should be fired on completed transition', async () => {
88 | await router.transitionTo('messages')
89 | expect(transitionSpy).to.be.calledOnce
90 |
91 | await router.transitionTo('notifications')
92 | expect(transitionSpy).to.be.calledTwice
93 | })
94 |
95 | it('should not be fired on cancelled transition', async () => {
96 | router.use((transition) => transition.cancel())
97 | try {
98 | await router.transitionTo('messages')
99 | } catch (error) {}
100 | expect(transitionSpy).to.not.be.called
101 | })
102 |
103 | it('should not be fired on cancelled transition', async () => {
104 | router.use(() => {
105 | throw new Error('error')
106 | })
107 | try {
108 | await router.transitionTo('messages')
109 | } catch (error) {}
110 | expect(transitionSpy).to.not.be.called
111 | })
112 | })
113 |
114 | describe('abort', () => {
115 | let abortSpy
116 |
117 | beforeEach(() => {
118 | abortSpy = spy()
119 | window.addEventListener('router-abort', abortSpy)
120 | })
121 |
122 | afterEach(() => {
123 | window.removeEventListener('router-abort', abortSpy)
124 | })
125 |
126 | it('should not be fired on completed transition', async () => {
127 | await router.transitionTo('messages')
128 | expect(abortSpy).to.not.be.called
129 |
130 | await router.transitionTo('notifications')
131 | expect(abortSpy).to.not.be.called
132 | })
133 |
134 | it('should be fired on cancelled transition', async () => {
135 | router.use((transition) => transition.cancel())
136 | try {
137 | await router.transitionTo('messages')
138 | } catch (error) {}
139 | expect(abortSpy).to.be.calledOnce
140 | })
141 |
142 | it('should be fired on cancelled transition', async () => {
143 | router.use(() => {
144 | throw new Error('error')
145 | })
146 | try {
147 | await router.transitionTo('messages')
148 | } catch (error) {}
149 | expect(abortSpy).to.be.calledOnce
150 | })
151 | })
152 |
153 | describe('error', () => {
154 | let errorSpy
155 |
156 | beforeEach(() => {
157 | errorSpy = spy()
158 | window.addEventListener('router-error', errorSpy)
159 | })
160 |
161 | afterEach(() => {
162 | window.removeEventListener('router-error', errorSpy)
163 | })
164 |
165 | it('should not be fired on completed transition', async () => {
166 | await router.transitionTo('messages')
167 | expect(errorSpy).to.not.be.called
168 |
169 | await router.transitionTo('notifications')
170 | expect(errorSpy).to.not.be.called
171 | })
172 |
173 | it('should not be fired on cancelled transition', async () => {
174 | router.use((transition) => transition.cancel())
175 | try {
176 | await router.transitionTo('messages')
177 | } catch (error) {}
178 | expect(errorSpy).to.not.be.called
179 | })
180 |
181 | it('should be fired on cancelled transition', async () => {
182 | router.use(() => {
183 | throw new Error('error')
184 | })
185 | try {
186 | await router.transitionTo('messages')
187 | } catch (error) {}
188 | expect(errorSpy).to.be.calledOnce
189 | })
190 | })
191 | })
192 |
193 | describe('eventPrefix', () => {
194 | beforeEach(() => {
195 | router = new Router({ location: 'memory', routes, eventPrefix: 'my-router:' })
196 | router.use(events)
197 | router.listen()
198 | })
199 |
200 | afterEach(() => {
201 | router.destroy()
202 | })
203 |
204 | describe('customized', () => {
205 | let transitionSpy
206 |
207 | beforeEach(() => {
208 | transitionSpy = spy()
209 | window.addEventListener('my-router:transition', transitionSpy)
210 | })
211 |
212 | afterEach(() => {
213 | window.removeEventListener('my-router:transition', transitionSpy)
214 | })
215 |
216 | it('should be fired using eventPrefix option', async () => {
217 | await router.transitionTo('messages')
218 | expect(transitionSpy).to.be.calledOnce
219 |
220 | await router.transitionTo('notifications')
221 | expect(transitionSpy).to.be.calledTwice
222 | })
223 | })
224 | })
225 | })
226 |
--------------------------------------------------------------------------------
/lib/components/animated-outlet.js:
--------------------------------------------------------------------------------
1 | export class AnimationHook {
2 | constructor(options = {}) {
3 | this.options = options
4 | }
5 |
6 | getOption(outlet, name) {
7 | return outlet.hasAttribute(name) ? outlet.getAttribute(name) : this.options[name]
8 | }
9 |
10 | hasOption(outlet, name) {
11 | return outlet.hasAttribute(name) || this.options[name]
12 | }
13 |
14 | runParallel(outlet) {
15 | return this.hasOption(outlet, 'parallel')
16 | }
17 |
18 | beforeEnter(outlet, el) {}
19 |
20 | enter(outlet, el) {}
21 |
22 | leave(outlet, el, done) {
23 | done()
24 | }
25 | }
26 |
27 | // code extracted from vue
28 | const raf = window.requestAnimationFrame
29 | const TRANSITION = 'transition'
30 | const ANIMATION = 'animation'
31 |
32 | // Transition property/event sniffing
33 | const transitionProp = 'transition'
34 | const transitionEndEvent = 'transitionend'
35 | const animationProp = 'animation'
36 | const animationEndEvent = 'animationend'
37 |
38 | function nextFrame(fn) {
39 | raf(function () {
40 | raf(fn)
41 | })
42 | }
43 |
44 | function whenTransitionEnds(el, cb) {
45 | const ref = getTransitionInfo(el)
46 | const type = ref.type
47 | const timeout = ref.timeout
48 | const propCount = ref.propCount
49 | if (!type) {
50 | return cb()
51 | }
52 | const event = type === TRANSITION ? transitionEndEvent : animationEndEvent
53 | let ended = 0
54 | const end = function () {
55 | el.removeEventListener(event, onEnd)
56 | cb()
57 | }
58 | const onEnd = function (e) {
59 | if (e.target === el) {
60 | if (++ended >= propCount) {
61 | end()
62 | }
63 | }
64 | }
65 | setTimeout(function () {
66 | if (ended < propCount) {
67 | end()
68 | }
69 | }, timeout + 1)
70 | el.addEventListener(event, onEnd)
71 | }
72 |
73 | function getTransitionInfo(el) {
74 | const styles = window.getComputedStyle(el)
75 | // JSDOM may return undefined for transition properties
76 | const transitionDelays = (styles[transitionProp + 'Delay'] || '').split(', ')
77 | const transitionDurations = (styles[transitionProp + 'Duration'] || '').split(', ')
78 | const transitionTimeout = getTimeout(transitionDelays, transitionDurations)
79 | const animationDelays = (styles[animationProp + 'Delay'] || '').split(', ')
80 | const animationDurations = (styles[animationProp + 'Duration'] || '').split(', ')
81 | const animationTimeout = getTimeout(animationDelays, animationDurations)
82 |
83 | const timeout = Math.max(transitionTimeout, animationTimeout)
84 | const type = timeout > 0 ? (transitionTimeout > animationTimeout ? TRANSITION : ANIMATION) : null
85 | const propCount = type
86 | ? type === TRANSITION
87 | ? transitionDurations.length
88 | : animationDurations.length
89 | : 0
90 |
91 | return {
92 | type,
93 | timeout,
94 | propCount,
95 | }
96 | }
97 |
98 | function getTimeout(delays, durations) {
99 | /* istanbul ignore next */
100 | while (delays.length < durations.length) {
101 | delays = delays.concat(delays)
102 | }
103 |
104 | return Math.max.apply(
105 | null,
106 | durations.map(function (d, i) {
107 | return toMs(d) + toMs(delays[i])
108 | }),
109 | )
110 | }
111 |
112 | // Old versions of Chromium (below 61.0.3163.100) formats floating pointer numbers
113 | // in a locale-dependent way, using a comma instead of a dot.
114 | // If comma is not replaced with a dot, the input will be rounded down (i.e. acting
115 | // as a floor function) causing unexpected behaviors
116 | function toMs(s) {
117 | return Number(s.slice(0, -1).replace(',', '.')) * 1000
118 | }
119 |
120 | function runTransition(el, name, type, cb) {
121 | el.classList.add(`${name}-${type}-active`)
122 | nextFrame(function () {
123 | el.classList.remove(`${name}-${type}`)
124 | el.classList.add(`${name}-${type}-to`)
125 | whenTransitionEnds(el, function () {
126 | el.classList.remove(`${name}-${type}-active`, `${name}-${type}-to`)
127 | if (cb) cb()
128 | })
129 | })
130 | }
131 |
132 | export class GenericCSS extends AnimationHook {
133 | beforeEnter(outlet, el) {
134 | const name = outlet.getAttribute('animation') || 'outlet'
135 | el.classList.add(`${name}-enter`)
136 | }
137 |
138 | enter(outlet, el) {
139 | const name = outlet.getAttribute('animation') || 'outlet'
140 | runTransition(el, name, 'enter')
141 | }
142 |
143 | leave(outlet, el, done) {
144 | const name = outlet.getAttribute('animation') || 'outlet'
145 | el.classList.add(`${name}-leave`)
146 | runTransition(el, name, 'leave', done)
147 | }
148 | }
149 |
150 | export class AnimateCSS extends AnimationHook {
151 | beforeEnter(outlet, el) {
152 | const enter = this.getOption(outlet, 'enter')
153 | if (enter) {
154 | el.style.display = 'none'
155 | }
156 | }
157 |
158 | enter(outlet, el) {
159 | const enter = this.getOption(outlet, 'enter')
160 | if (!enter) return
161 | el.style.display = 'block'
162 | el.classList.add('animated', enter)
163 | el.addEventListener(
164 | 'animationend',
165 | () => {
166 | el.classList.remove('animated', enter)
167 | },
168 | { once: true },
169 | )
170 | }
171 |
172 | leave(outlet, el, done) {
173 | const leave = this.getOption(outlet, 'leave')
174 | if (!leave) {
175 | done()
176 | return
177 | }
178 | el.classList.add('animated', leave)
179 | el.addEventListener('animationend', done, { once: true })
180 | }
181 | }
182 |
183 | const animationRegistry = {}
184 | let defaultHook
185 |
186 | export function registerAnimation(name, AnimationHookClass, options = {}) {
187 | animationRegistry[name] = new AnimationHookClass(options)
188 | }
189 |
190 | export function setDefaultAnimation(AnimationHookClass, options = {}) {
191 | defaultHook = new AnimationHookClass(options)
192 | }
193 |
194 | function getAnimationHook(name) {
195 | return animationRegistry[name] || defaultHook || (defaultHook = new GenericCSS())
196 | }
197 |
198 | export class AnimatedOutlet extends HTMLElement {
199 | appendChild(el) {
200 | if (!this.hasAttribute('animation')) {
201 | super.appendChild(el)
202 | return
203 | }
204 | const hook = getAnimationHook(this.getAttribute('animation'))
205 | const runParallel = hook.runParallel(this)
206 |
207 | hook.beforeEnter(this, el)
208 | super.appendChild(el)
209 | if (!runParallel && this.removing) {
210 | // when removing a previous el, append animation is run after remove one
211 | this.appending = el
212 | } else {
213 | hook.enter(this, el)
214 | }
215 | }
216 |
217 | removeChild(el) {
218 | if (!this.hasAttribute('animation')) {
219 | super.removeChild(el)
220 | return
221 | }
222 | const hook = getAnimationHook(this.getAttribute('animation'))
223 |
224 | if (this.removing && this.removing.parentNode === this) {
225 | super.removeChild(this.removing)
226 | }
227 |
228 | if (el === this.appending) {
229 | if (el.parentNode === this) {
230 | super.removeChild(el)
231 | }
232 | this.removing = null
233 | return
234 | }
235 |
236 | this.removing = el
237 | hook.leave(this, el, () => {
238 | if (this.removing && this.removing.parentNode === this) {
239 | super.removeChild(this.removing)
240 | }
241 | if (this.appending) hook.enter(this, this.appending)
242 | this.appending = null
243 | this.removing = null
244 | })
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/tests/functional/routerTest.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-return-assign */
2 | import $ from './nanodom'
3 | import TestApp from './testApp'
4 | import 'chai/chai.js'
5 |
6 | const { assert } = window.chai
7 | const { describe, it, beforeEach, afterEach } = window
8 | let app, router
9 |
10 | describe('app', () => {
11 | beforeEach(() => {
12 | window.location.hash = '/'
13 | app = new TestApp()
14 | router = app.router
15 | return app.start()
16 | })
17 |
18 | afterEach(() => {
19 | app.destroy()
20 | })
21 |
22 | it('transition occurs when location.hash changes', (done) => {
23 | router.use((transition) => {
24 | transition
25 | .then(() => {
26 | assert.equal(transition.path, '/about')
27 | assert.equal($('.application .outlet').html(), 'This is about page')
28 | done()
29 | })
30 | .catch(done, done)
31 | })
32 |
33 | window.location.hash = '#about'
34 | })
35 |
36 | it('programmatic transition via url and route names', async function () {
37 | await router.transitionTo('about')
38 | await router.transitionTo('/faq?sortBy=date')
39 | assert.equal($('.application .outlet').html(), 'FAQ. Sorted By: date')
40 | await router.transitionTo('faq', {}, { sortBy: 'user' })
41 | assert.equal($('.application .outlet').html(), 'FAQ. Sorted By: user')
42 | })
43 |
44 | it('cancelling and retrying transitions', async function () {
45 | await router.transitionTo('/posts/filter/foo')
46 | assert.equal(router.location.getURL(), '/posts/filter/foo')
47 | const transition = router.transitionTo('about')
48 | transition.cancel()
49 | await transition.catch(() => {})
50 | assert.equal(router.location.getURL(), '/posts/filter/foo')
51 |
52 | await transition.retry()
53 | assert.equal(router.location.getURL(), '/about')
54 | })
55 |
56 | it('transition.followRedirects resolves when all of the redirects have finished', async function () {
57 | await router.transitionTo('application')
58 | // initiate a transition
59 | const transition = router.transitionTo('/posts/filter/foo')
60 | // and a redirect
61 | router.transitionTo('/about')
62 |
63 | // if followRedirects is not used - the original transition is rejected
64 | let rejected = false
65 | await transition.catch(() => (rejected = true))
66 | assert(rejected)
67 |
68 | await router.transitionTo('application')
69 | // initiate a transition
70 | const t = router.transitionTo('/posts/filter/foo')
71 | // and a redirect, this time using `redirectTo`
72 | t.redirectTo('/about')
73 |
74 | // when followRedirects is used - the promise is only
75 | // resolved when both transitions finish
76 | await transition.followRedirects()
77 | assert.equal(router.location.getURL(), '/about')
78 | })
79 |
80 | it('transition.followRedirects is rejected if transition fails', async function () {
81 | // silence the errors for the tests
82 | router.logError = () => {}
83 |
84 | // initiate a transition
85 | const transition = router.transitionTo('/posts/filter/foo')
86 | // install a breaking middleware
87 | router.use(() => {
88 | throw new Error('middleware error')
89 | })
90 | // and a redirect
91 | router.transitionTo('/about')
92 |
93 | let rejected = false
94 | await transition.followRedirects().catch((err) => (rejected = err.message))
95 | assert.equal(rejected, 'middleware error')
96 | })
97 |
98 | it('transition.followRedirects is rejected if transition fails asynchronously', async function () {
99 | // silence the errors for the tests
100 | router.logError = () => {}
101 |
102 | // initiate a transition
103 | const transition = router.transitionTo('/posts/filter/foo')
104 | // install a breaking middleware
105 | router.use(() => {
106 | return Promise.reject(new Error('middleware promise error'))
107 | })
108 | // and a redirect
109 | router.transitionTo('/about')
110 |
111 | let rejected = false
112 | await transition.followRedirects().catch((err) => (rejected = err.message))
113 | assert.equal(rejected, 'middleware promise error')
114 | })
115 |
116 | it.skip('cancelling transition does not add a history entry', async function () {
117 | // we start of at faq
118 | await router.transitionTo('faq')
119 | // then go to posts.filter
120 | await router.transitionTo('posts.filter', { filterId: 'foo' })
121 | assert.equal(window.location.hash, '#posts/filter/foo')
122 |
123 | // now attempt to transition to about and cancel
124 | const transition = router.transitionTo('/about')
125 | transition.cancel()
126 | await transition.catch(() => {})
127 |
128 | // the url is still posts.filter
129 | assert.equal(window.location.hash, '#posts/filter/foo')
130 |
131 | // at first look going back now should take to #faq but it does not:
132 | // the initial steps creates this history: #faq > #posts/filter/foo > #about
133 | // calling cancel replaces #about by #posts/filter/foo so the history is now
134 | // #faq > #posts/filter/foo > #posts/filter/foo
135 | // calling history.back goes from #posts/filter/foo to #posts/filter/foo which is ignored
136 | // the native history API does not provide a way to cancel a navigation
137 | // or to go back synchronously and without triggering the events
138 | // the solution would be to handle router own history
139 | await new Promise((resolve, reject) => {
140 | router.use((transition) => {
141 | transition
142 | .then(() => {
143 | assert.equal(window.location.hash, '#faq')
144 | resolve()
145 | })
146 | .catch(reject)
147 | })
148 | window.history.back()
149 | })
150 | })
151 |
152 | it('navigating around the app', async function () {
153 | assert.equal($('.application .outlet').html(), 'Welcome to this application')
154 |
155 | await router.transitionTo('about')
156 | assert.equal($('.application .outlet').html(), 'This is about page')
157 |
158 | await router.transitionTo('/faq?sortBy=date')
159 | assert.equal($('.application .outlet').html(), 'FAQ. Sorted By: date')
160 |
161 | await router.transitionTo('faq', {}, { sortBy: 'user' })
162 | assert.equal($('.application .outlet').html(), 'FAQ. Sorted By: user')
163 |
164 | // we can also change the url directly to cause another transition to happen
165 | await new Promise(function (resolve) {
166 | router.use(resolve)
167 | window.location.hash = '#posts/filter/mine'
168 | })
169 | assert.equal($('.application .outlet').html(), 'My posts...')
170 |
171 | await new Promise(function (resolve) {
172 | router.use(resolve)
173 | window.location.hash = '#posts/filter/foo'
174 | })
175 | assert.equal($('.application .outlet').html(), 'Filter not found')
176 | })
177 |
178 | it('url behaviour during transitions', async function () {
179 | assert.equal(window.location.hash, '#/')
180 | const transition = router.transitionTo('about')
181 | assert.equal(window.location.hash, '#about')
182 | await transition
183 | assert.equal(window.location.hash, '#about')
184 | // would be cool to it history.back() here
185 | // but in IE it reloads the karma iframe, so let's
186 | // use a regular location.hash assignment instead
187 | // window.history.back()
188 | window.location.hash = '#/'
189 | await new Promise((resolve) => {
190 | router.use((transition) => {
191 | assert.equal(window.location.hash, '#/')
192 | resolve()
193 | })
194 | })
195 | })
196 |
197 | it('url behaviour during failed transitions', async function () {
198 | router.logError = () => {}
199 | await router.transitionTo('about')
200 | await new Promise((resolve, reject) => {
201 | // setup a middleware that will fail
202 | router.use((transition) => {
203 | // but catch the error
204 | transition
205 | .catch((err) => {
206 | assert.equal(err.message, 'failed')
207 | assert.equal(window.location.hash, '#faq')
208 | resolve()
209 | })
210 | .catch(reject)
211 | throw new Error('failed')
212 | })
213 | router.transitionTo('faq')
214 | })
215 | })
216 | })
217 |
--------------------------------------------------------------------------------
/tests/functional/routerLinksTest.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-expressions */
2 | /* global describe,beforeEach,afterEach,it,$ */
3 |
4 | import { Router } from '../../lib/router'
5 | import { wc } from '../../lib/middlewares/wc'
6 | import { routerLinks, bindRouterLinks } from '../../lib/middlewares/router-links'
7 | import { defineCE, expect, fixtureSync } from '@open-wc/testing'
8 | import { LitElement, html } from 'lit-element'
9 | import sinon from 'sinon'
10 | import 'jquery'
11 |
12 | class ParentView extends LitElement {
13 | createRenderRoot() {
14 | return this
15 | }
16 |
17 | render() {
18 | return html`
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
38 |
39 |
40 |
41 |
42 | `
43 | }
44 | }
45 | const parentTag = defineCE(ParentView)
46 |
47 | class ChildView extends LitElement {
48 | createRenderRoot() {
49 | return this
50 | }
51 |
52 | render() {
53 | return html` `
54 | }
55 | }
56 | defineCE(ChildView)
57 |
58 | describe('bindRouterLinks', () => {
59 | let router, outlet, parentComponent
60 | beforeEach(() => {
61 | outlet = document.createElement('div')
62 | document.body.appendChild(outlet)
63 | const routes = function (route) {
64 | route('parent', { component: () => parentComponent }, function () {
65 | route('child', { component: ChildView }, function () {
66 | route('grandchild', { component: ParentView })
67 | })
68 | })
69 | route('root', { path: 'root/:id', component: ParentView })
70 | route('secondroot', { path: 'secondroot/:personId', component: ParentView })
71 | }
72 | parentComponent = ParentView
73 | router = new Router({ location: 'memory', outlet, routes })
74 | router.use(wc)
75 | router.use(routerLinks)
76 | router.listen()
77 | })
78 |
79 | afterEach(() => {
80 | outlet.remove()
81 | router.destroy()
82 | })
83 |
84 | describe('when calling bindRouterLinks in pre-rendered HTML', function () {
85 | let unbind, preRenderedEl
86 | beforeEach(function () {
87 | preRenderedEl = fixtureSync(`
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
`)
97 | unbind = bindRouterLinks(preRenderedEl)
98 | })
99 |
100 | it('should generate href attributes in anchor tags with route attribute', function () {
101 | return router.transitionTo('parent').then(async function () {
102 | expect($('#a-preparentlink').attr('href')).to.be.equal('/parent')
103 | expect($('#a-prerootlink2').attr('href')).to.be.equal('/root/2')
104 | expect($('#a-pregrandchildlink').attr('href')).to.be.equal(
105 | '/parent/child/grandchild?name=test',
106 | )
107 | })
108 | })
109 |
110 | it('should set active class in tags with route attribute', function () {
111 | return router.transitionTo('parent').then(async function () {
112 | expect($('#a-preparentlink').hasClass('active')).to.be.true
113 | expect($('#a-prerootlink2').hasClass('active')).to.be.false
114 | expect($('#div-preparentlink').hasClass('active')).to.be.true
115 | expect($('#div-prerootlink1').hasClass('active')).to.be.false
116 | })
117 | })
118 |
119 | it('should call transitionTo when a non anchor tags with route attribute is clicked', function () {
120 | return router.transitionTo('parent').then(async function () {
121 | const spy = sinon.spy(router, 'transitionTo')
122 | $('#div-prerootlink1').click()
123 | expect(spy).to.be.calledOnce.and.calledWithExactly('root', { id: '1' }, {})
124 |
125 | spy.resetHistory()
126 | $('#div-pregrandchildlink').click()
127 | expect(spy).to.be.calledOnce.and.calledWithExactly('grandchild', {}, { name: 'test' })
128 |
129 | spy.resetHistory()
130 | $('#preinnerparent').click()
131 | expect(spy).to.be.calledOnce.and.calledWithExactly('parent', {}, {})
132 | })
133 | })
134 |
135 | it('should not call transitionTo after calling function returned by bindRouterLinks', function () {
136 | unbind()
137 | return router.transitionTo('parent').then(async function () {
138 | const spy = sinon.spy(router, 'transitionTo')
139 | $('#div-prerootlink1').click()
140 | $('#div-pregrandchildlink').click()
141 | $('#preinnerparent').click()
142 | expect(spy).to.not.be.called
143 | })
144 | })
145 |
146 | describe('and nodes are added dynamically', () => {
147 | it('should generate href attributes in anchor tags with route attribute', function (done) {
148 | router.transitionTo('parent').then(async function () {
149 | const parentEl = document.querySelector(parentTag)
150 | await parentEl.updateComplete
151 | $(`
152 |
153 |
154 | `).appendTo(document.querySelector('#prerendered'))
155 |
156 | // links are updated asynchronously by MutationObserver
157 | setTimeout(() => {
158 | expect($('#a-dyn-preparentlink').attr('href')).to.be.equal('/parent')
159 | expect($('#a-dyn-prerootlink2').attr('href')).to.be.equal('/root/2')
160 | expect($('#a-dyn-pregrandchildlink').attr('href')).to.be.equal(
161 | '/parent/child/grandchild?name=test',
162 | )
163 | done()
164 | }, 0)
165 | })
166 | })
167 |
168 | it('should set active class in tags with route attribute', function (done) {
169 | router.transitionTo('parent').then(async function () {
170 | const parentEl = document.querySelector(parentTag)
171 | await parentEl.updateComplete
172 | $(`
173 |
174 |
175 | `).appendTo(document.querySelector('#prerendered'))
176 |
177 | // links are updated asynchronously by MutationObserver
178 | setTimeout(() => {
179 | expect($('#a-dyn-preparentlink').hasClass('active')).to.be.true
180 | expect($('#a-dyn-prerootlink2').hasClass('active')).to.be.false
181 | expect($('#a-dyn-pregrandchildlink').hasClass('active')).to.be.false
182 | done()
183 | }, 0)
184 | })
185 | })
186 | })
187 | })
188 | })
189 |
--------------------------------------------------------------------------------
/lib/locations/location-bar.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | // LocationBar module extracted from Backbone.js 1.1.0
3 | //
4 | // the dependency on backbone, underscore and jquery have been removed to turn
5 | // this into a small standalone library for handling browser's history API
6 | // cross browser and with a fallback to hashchange events or polling.
7 |
8 | import {extend} from '../utils.js'
9 | import {bindEvent, unbindEvent} from '../events.js'
10 |
11 | // this is mostly original code with minor modifications
12 | // to avoid dependency on 3rd party libraries
13 | //
14 | // Backbone.History
15 | // ----------------
16 |
17 | // Handles cross-browser history management, based on either
18 | // [pushState](http://diveintohtml5.info/history.html) and real URLs, or
19 | // [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange)
20 | // and URL fragments.
21 | class History {
22 | constructor() {
23 | this.handlers = [];
24 | this.checkUrl = this.checkUrl.bind(this);
25 | this.location = window.location;
26 | this.history = window.history;
27 | }
28 |
29 | // Set up all inheritable **Backbone.History** properties and methods.
30 | // Are we at the app root?
31 | atRoot() {
32 | return this.location.pathname.replace(/[^\/]$/, '$&/') === this.root;
33 | }
34 |
35 | // Gets the true hash value. Cannot use location.hash directly due to bug
36 | // in Firefox where location.hash will always be decoded.
37 | getHash() {
38 | const match = this.location.href.match(/#(.*)$/);
39 | return match ? match[1] : '';
40 | }
41 |
42 | // Get the cross-browser normalized URL fragment, either from the URL,
43 | // the hash, or the override.
44 | getFragment(fragment, forcePushState) {
45 | if (fragment == null) {
46 | if (this._hasPushState || !this._wantsHashChange || forcePushState) {
47 | fragment = decodeURI(this.location.pathname + this.location.search);
48 | const root = this.root.replace(trailingSlash, '');
49 | if (!fragment.indexOf(root)) fragment = fragment.slice(root.length);
50 | } else {
51 | fragment = this.getHash();
52 | }
53 | }
54 | return fragment.replace(routeStripper, '');
55 | }
56 |
57 | // Start the hash change handling, returning `true` if the current URL matches
58 | // an existing route, and `false` otherwise.
59 | start(options = {}) {
60 | // MODIFICATION OF ORIGINAL BACKBONE.HISTORY
61 | // if (History.started) throw new Error("LocationBar has already been started");
62 | // History.started = true;
63 | this.started = true;
64 |
65 | // Figure out the initial configuration.
66 | // Is pushState desired ... is it available?
67 | this.options = extend({root: '/'}, options);
68 | this.location = this.options.location || this.location;
69 | this.history = this.options.history || this.history;
70 | this.root = this.options.root;
71 | this._wantsHashChange = this.options.hashChange !== false;
72 | this._wantsPushState = !!this.options.pushState;
73 | this._hasPushState = this._wantsPushState;
74 | const fragment = this.getFragment();
75 |
76 | // Normalize root to always include a leading and trailing slash.
77 | this.root = (`/${this.root}/`).replace(rootStripper, '/');
78 |
79 | // Depending on whether we're using pushState or hashes, and whether
80 | // 'onhashchange' is supported, determine how we check the URL state.
81 | bindEvent(window, this._hasPushState ? 'popstate' : 'hashchange', this.checkUrl);
82 |
83 | // Determine if we need to change the base url, for a pushState link
84 | // opened by a non-pushState browser.
85 | this.fragment = fragment;
86 | const loc = this.location;
87 |
88 | // Transition from hashChange to pushState or vice versa if both are
89 | // requested.
90 | if (this._wantsHashChange && this._wantsPushState) {
91 |
92 | // If we've started off with a route from a `pushState`-enabled
93 | // browser, but we're currently in a browser that doesn't support it...
94 | if (!this._hasPushState && !this.atRoot()) {
95 | this.fragment = this.getFragment(null, true);
96 | this.location.replace(`${this.root}#${this.fragment}`);
97 | // Return immediately as browser will do redirect to new url
98 | return true;
99 |
100 | // Or if we've started out with a hash-based route, but we're currently
101 | // in a browser where it could be `pushState`-based instead...
102 | } else if (this._hasPushState && this.atRoot() && loc.hash) {
103 | this.fragment = this.getHash().replace(routeStripper, '');
104 | this.history.replaceState({}, document.title, this.root + this.fragment);
105 | }
106 |
107 | }
108 |
109 | if (!this.options.silent) return this.loadUrl();
110 | }
111 |
112 | // Disable Backbone.history, perhaps temporarily. Not useful in a real app,
113 | // but possibly useful for unit testing Routers.
114 | stop() {
115 | unbindEvent(window, this._hasPushState ? 'popstate' : 'hashchange', this.checkUrl);
116 | this.started = false;
117 | }
118 |
119 | // Add a route to be tested when the fragment changes. Routes added later
120 | // may override previous routes.
121 | route(route, callback) {
122 | this.handlers.unshift({route, callback});
123 | }
124 |
125 | // Checks the current URL to see if it has changed, and if it has,
126 | // calls `loadUrl`.
127 | checkUrl() {
128 | const current = this.getFragment();
129 | if (current === this.fragment) return false;
130 | this.loadUrl();
131 | }
132 |
133 | // Attempt to load the current URL fragment. If a route succeeds with a
134 | // match, returns `true`. If no defined routes matches the fragment,
135 | // returns `false`.
136 | loadUrl(fragment) {
137 | fragment = this.fragment = this.getFragment(fragment);
138 | return this.handlers.some(handler => {
139 | if (handler.route.test(fragment)) {
140 | handler.callback(fragment);
141 | return true;
142 | }
143 | });
144 | }
145 |
146 | // Save a fragment into the hash history, or replace the URL state if the
147 | // 'replace' option is passed. You are responsible for properly URL-encoding
148 | // the fragment in advance.
149 | //
150 | // The options object can contain `trigger: true` if you wish to have the
151 | // route callback be fired (not usually desirable), or `replace: true`, if
152 | // you wish to modify the current URL without adding an entry to the history.
153 | update(fragment, options) {
154 | if (!this.started) return false;
155 | if (!options || options === true) options = {trigger: !!options};
156 |
157 | let url = this.root + (fragment = this.getFragment(fragment || ''));
158 |
159 | // Strip the hash for matching.
160 | fragment = fragment.replace(pathStripper, '');
161 |
162 | if (this.fragment === fragment) return;
163 | this.fragment = fragment;
164 |
165 | // Don't include a trailing slash on the root.
166 | if (fragment === '' && url !== '/') url = url.slice(0, -1);
167 |
168 | // If pushState is available, we use it to set the fragment as a real URL.
169 | if (this._hasPushState) {
170 | this.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url);
171 |
172 | // If hash changes haven't been explicitly disabled, update the hash
173 | // fragment to store history.
174 | } else if (this._wantsHashChange) {
175 | this._updateHash(this.location, fragment, options.replace);
176 | // If you've told us that you explicitly don't want fallback hashchange-
177 | // based history, then `update` becomes a page refresh.
178 | } else {
179 | return this.location.assign(url);
180 | }
181 | if (options.trigger) return this.loadUrl(fragment);
182 | }
183 |
184 | // Update the hash location, either replacing the current entry, or adding
185 | // a new one to the browser history.
186 | _updateHash(location, fragment, replace) {
187 | if (replace) {
188 | const href = location.href.replace(/(javascript:|#).*$/, '');
189 | location.replace(`${href}#${fragment}`);
190 | } else {
191 | // Some browsers require that `hash` contains a leading #.
192 | location.hash = `#${fragment}`;
193 | }
194 | }
195 |
196 | // add some features to History
197 |
198 | // a generic callback for any changes
199 | onChange(callback) {
200 | this.route(/^(.*?)$/, callback);
201 | }
202 |
203 | // checks if the browser has pushstate support
204 | hasPushState() {
205 | // MODIFICATION OF ORIGINAL BACKBONE.HISTORY
206 | if (!this.started) {
207 | throw new Error("only available after LocationBar.start()");
208 | }
209 | return this._hasPushState;
210 | }
211 | }
212 |
213 | // Cached regex for stripping a leading hash/slash and trailing space.
214 | const routeStripper = /^[#\/]|\s+$/g;
215 |
216 | // Cached regex for stripping leading and trailing slashes.
217 | const rootStripper = /^\/+|\/+$/g;
218 |
219 | // Cached regex for removing a trailing slash.
220 | const trailingSlash = /\/$/;
221 |
222 | // Cached regex for stripping urls of hash.
223 | const pathStripper = /#.*$/;
224 |
225 |
226 | // export
227 | export default History;
228 |
--------------------------------------------------------------------------------