A simple, modular Single Page Application router.
npm i -S @thepassle/app-tools
import { Router } from '@thepassle/app-tools/router.js';
import { lazy } from '@thepassle/app-tools/router/plugins/lazy.js';
import { offline } from '@thepassle/app-tools/router/plugins/offline.js';
import { resetFocus } from '@thepassle/app-tools/router/plugins/resetFocus.js';
import { scrollToTop } from '@thepassle/app-tools/router/plugins/scrollToTop.js';
import { checkServiceWorkerUpdate } from '@thepassle/app-tools/router/plugins/checkServiceWorkerUpdate.js';
export const router = new Router({
/** Plugins to be run for every route */
plugins: [
/** Redirects to an offline page */
offline,
/** Checks for service worker updates on route navigations */
checkServiceWorkerUpdate,
scrollToTop,
resetFocus
],
/** Fallback route when the user navigates to a route that doesnt exist */
fallback: '/404',
routes: [
{
path: '/',
title: 'home',
render: () => html`<product-list></product-list>`
},
{
path: '/cart',
title: 'cart',
plugins: [
lazy(() => import('./shopping-card.js'))
],
render: () => html`<shopping-cart></shopping-cart>`
},
{
path: '/product/:name',
title: ({params}) => `Product ${params.name}`,
plugins: [
lazy(() => import('./product-page.js'))
],
render: ({params}) => html`<product-page id="${params.name}"></product-page>`
},
{
path: '/admin',
title: 'Admin',
plugins: [
{
shouldNavigate: () => ({
condition: () => state.user.isAdmin,
redirect: '/'
})
}
],
render: () => html`<admin-page></admin-page>`
},
{
path: '/offline',
title: 'Offline',
render: () => html`<offline-page></offline-page>`
},
{
path: '/404',
title: 'Not found',
render: () => html`<404-page></404-page>`
}
]
});
router.addEventListener('route-changed', ({context}) => {
document.querySelector('#outlet').innerHTML = router.render();
});
router.navigate('/cart');
router.context.url;
router.context.params;
router.context.query;
router.context.title;
// Cleans up global event listeners
router.uninstall();
The router makes use of the URLPattern
api, which you may need to polyfill in your application. You can use urlpattern-polyfill
for this.
If you use @web/rollup-plugin-polyfills-loader
in your rollup build you can use the URLPattern
config option:
polyfillsLoader({
polyfills: {
URLPattern: true,
},
})
When using a Single Page Application (SPA) router, make sure to set the <base href="/">
element in your HTML. Also make sure that your dev server is configured for SPA Navigations. When using @web/dev-server
, you can use the --app-index
configuration options.
E.g.: web-dev-server --app-index index.html
, or web-dev-server --app-index foo/index.html
When using a different base, for example if your app is running on my-app.com/foo/
, make sure to adjust your routing configuration:
<html>
<head>
<base href="/foo/">
</head>
<body>
<a href="/foo/">home</a>
<a href="foo">foo</a>
<a href="bar/123">bar</a>
<main></main>
</body>
<script type="module">
import { Router } from 'https://unpkg.com/@thepassle/app-tools/router.js';
const router = new Router({
routes: [
{
path: '/foo/',
title: 'Hello',
render: () => 'home'
},
{
path: 'foo',
title: 'Foo',
render: () => 'foo'
},
{
path: 'bar/:id',
title: ({params}) => `Bar ${params.id}`,
render: ({params}) => `bar ${params.id}`
},
]
});
router.addEventListener('route-changed', ({context}) => {
const route = router.render();
document.querySelector('main').innerHTML = route;
});
</script>
</html>
The router is framework agnostic. Rendering the route is left to the consumer of the router. The application is then in charge of rendering whatever is returned from the render
function. Here's a basic example:
Route:
{
path: '/',
title: 'Home',
render: (context) => 'Home route'
}
App:
router.addEventListener('route-changed', () => {
const route = router.render();
document.querySelector('#outlet').innerHTML = route;
});
Route:
{
path: '/',
title: 'Home',
render: (context) => html`<my-el></my-el>`
}
App:
import { html, render } from 'lit';
router.addEventListener('route-changed', () => {
render(router.render(), document.querySelector('#outlet'))
});
Route:
{
path: '/',
title: 'Home',
render: (context) => html`<my-el></my-el>`
}
App:
import { LitElement } from 'lit';
class MyEl extends LitElement {
static properties = {
route: {}
}
firstUpdated() {
router.addEventListener('route-changed', () => {
this.route = router.render();
});
}
render() {
return this.route;
}
}
Use plugins to customize your navigations to fit your needs. You can add plugins for all navigations, or for specific routes.
const router = new Router({
/** These plugins will run for any navigation */
plugins: [],
routes: [
{
path: '/foo',
title: 'Foo',
/** These plugins will run for this route only */
plugins: [],
render: () => html`<my-el></my-el>`
}
]
})
Lazily import resources or components on route navigations
import { lazy } from '@thepassle/app-tools/router/plugins/lazy.js';
const router = new Router({
routes: [
{
path: '/foo',
title: 'Foo',
plugins: [
lazy(() => import('./my-el.js')),
],
render: () => html`<my-el></my-el>`
},
]
});
import { api } from '@thepassle/app-tools/api.js';
import { data } from '@thepassle/app-tools/router/plugins/data.js';
const router = new Router({
routes: [
{
path: '/pokemon/:id',
title: (context) => `Pokemon ${context.params.id}`,
plugins: [
data((context) => api.get(`https://pokeapi.co/api/v2/pokemon/${context.params.id}`)),
],
// context.data is a promise
render: (context) => html`<pokemon-card .data=${context.data}></pokemon-card>`
},
]
});
import { redirect } from '@thepassle/app-tools/router/plugins/redirect.js';
const router = new Router({
routes: [
{
path: '/foo',
title: 'Foo',
plugins: [
redirect('/404'),
],
},
{
path: '/legacy/detail/:product',
title: 'Foo',
plugins: [
redirect(context => '/detail/${context.params.product}'),
],
},
]
});
Checks for service worker updates on route navigations
import { checkServiceWorkerUpdate } from '@thepassle/app-tools/router/plugins/checkServiceWorkerUpdate.js';
const router = new Router({
plugins: [
checkServiceWorkerUpdate
],
routes: [
{
path: '/foo',
title: 'Foo',
render: () => html`<my-el></my-el>`
},
]
});
Redirects to an offline page when the user is offline
import { offline, offlinePlugin } from '@thepassle/app-tools/router/plugins/offline.js';
const router = new Router({
plugins: [
/** Redirects to `/offline` by default */
offline
/** Or */
offlinePlugin('/my-offline-page')
],
routes: [
{
path: '/offline',
title: 'Offline',
render: () => html`<offline-page></offline-page>`
},
]
});
Prepends the name of your app to the title
import { appName } from '@thepassle/app-tools/router/plugins/appName.js';
const router = new Router({
plugins: [
appName('My App -')
],
routes: [
{
path: '/foo',
title: 'Foo',
render: () => html`<my-el></my-el>`
},
]
});
Will result in the title for route /foo
being:
My App - Foo
Scrolls the page to the top after a navigation
import { scrollToTop } from '@thepassle/app-tools/router/plugins/scrollToTop.js';
const router = new Router({
plugins: [
scrollToTop
],
routes: [
{
path: '/foo',
title: 'Foo',
render: () => html`<my-el></my-el>`
},
]
});
Resets focus to the start of the document
import { resetFocus } from '@thepassle/app-tools/router/plugins/resetFocus.js';
const router = new Router({
plugins: [
resetFocus
],
routes: [
{
path: '/foo',
title: 'Foo',
render: () => html`<my-el></my-el>`
},
]
});
const router = new Router({
plugins: [
{
shouldNavigate: (context) => ({
condition: () => false,
redirect: '/'
}),
beforeNavigation: (context) => {
},
afterNavigation: (context) => {
}
}
]
});
All plugins have access to the context
object for the current route. Given the following route, the context includes:
{
path: '/users/:id',
title: ({params, query}) => `Hello world ${params.id} ${query.foo}`,
}
www.my-website.com/users/123?foo=bar
context.params; // { id: 123 }
context.query; // { foo: 'bar' }
context.title; // "Hello world 123 bar"
context.url; // URL instance of "www.my-website.com/users/123?foo=bar"
Can be used to protect routes based on a condition function. Should return an object containing a condition
function, and a redirect
. When the condition
returns false
, it will redirect to the path provided by redirect
.
const myPlugin = {
shouldNavigate: (context) => ({
/** A condition function to determine whether or not the navigation should take place */
condition: () => state.user.isAdmin,
/** Where to send the user in case the condition is false */
redirect: '/'
}),
}
Runs before the navigation takes place
const myPlugin = {
beforeNavigation: (context) => {}
}
Runs after the navigation takes place
const myPlugin = {
afterNavigation: (context) => {}
}