tp-router/tp-router.js

384 lines
9.8 KiB
JavaScript
Raw Normal View History

2022-03-12 22:43:21 +01:00
/**
@license
Copyright (c) 2022 trading_peter
This program is available under Apache License Version 2.0
*/
import './tp-route';
import { pathToRegexp } from 'path-to-regexp';
2022-03-12 23:34:46 +01:00
import { closest } from '@tp/helpers/closest.js';
2022-03-12 22:43:21 +01:00
import { LitElement, html, css } from 'lit';
/*
# tp-router
A simple router element to declaratively handle routing in web applications.
## Example
```html
<tp-router id="mainRouter" data="{{route}}">
<tp-route path="*" data="404"></tp-route>
<tp-route path="/" redirect="/member/list"></tp-route>
<tp-route path="/myaccount" redirect="/myaccount/profile"></tp-route>
<tp-route path="/myaccount/profile" data="myaccount-profile"></tp-route>
</tp-router>
```
A `tp-route` element with the path set to `*` can be used to catch all routing request that can't be resolved.
The `redirect` attribute lets you redirect to another path.
The router listens for the `trigger-router` event. The event detail is forwarded to the `trigger` function.
*/
export class TpRouter extends LitElement {
static get styles() {
return [
css`
:host {
display: none;
}
`
];
}
render() {
return html`
<slot></slot>
`;
}
static get properties() {
return {
// The current path thats active.
path: { type: String, notify: true },
data: { type: String, notify: true },
/**
* The path params extracted from the active path.
*/
params: { type: Object, notify: true },
/** Base URL for all routes */
base: { type: String, },
/**
* Holds all defined routes.
* Routes can be grouped by a namespace.
* This makes it easier to manage whole batches of routes at once.
*
* Structure:
* ```js
* {
* default: {
* '/path/:param': {
* path: '/path/:param',
* regex: {RegExp},
* keys: {Array},
* data: {*},
* redirect: null,
* params: {}
* }
* },
* namespace: {
* '/path/:param': {
* path: '/path/:param',
* regex: {RegExp},
* keys: {Array},
* data: {*},
* redirect: null,
* params: {}
* }
* }
* }
* ```
*/
routes: { type: Object, }
};
}
constructor() {
super();
this.base = '/';
this.params = {};
this.routes = {};
this._boundDocClick = this._onDocClick.bind(this);
this._boundPopState = this._onPopstate.bind(this);
this._boundDispatchTrigger = this._dispatchTrigger.bind(this);
}
get _slottedChildren() {
const slot = this.shadowRoot.querySelector('slot');
return slot.assignedElements({flatten: true});
}
shouldUpdate(changes) {
if (changes.has('path')) {
this.dispatchEvent(new CustomEvent('path-changed', { detail: this.path, bubbles: true, composed: true }));
}
if (changes.has('data')) {
this.dispatchEvent(new CustomEvent('data-changed', { detail: this.data, bubbles: true, composed: true }));
}
if (changes.has('params')) {
this.dispatchEvent(new CustomEvent('params-changed', { detail: this.params, bubbles: true, composed: true }));
}
return true;
}
firstUpdated() {
const routes = this._slottedChildren;
routes.forEach(routeEl => {
2022-03-12 23:37:34 +01:00
if (routeEl.nodeName === 'TP-ROUTE') {
2022-03-12 22:43:21 +01:00
this.add(routeEl.path, routeEl.data, routeEl.namespace, routeEl.redirect);
}
});
document.addEventListener('click', this._boundDocClick);
window.addEventListener('popstate', this._boundPopState);
document.addEventListener('trigger-router', this._boundDispatchTrigger);
// Check for a missed route that should be restored.
if (location.hash.indexOf('#?') === 0) {
this.trigger(location.hash.substring(2), true);
} else {
this.trigger(location.pathname, true);
}
}
/**
* Add a route to the router.
* If a path is navigated to, the associated data is returned.
* Fires the `route-added` event on success.
*
* @param {string} path Path pattern
* @param {*} data Data that is send with the route changed event.
* @param {string} [namespace] Optional namespace for the route.
*/
add(path, data, namespace, redirect) {
namespace = namespace || 'default';
// Add catch all route.
if (path === '*') {
this._catchAllRoute = {
path: path,
regex: pathToRegexp('{/*path}'),
2022-03-12 22:43:21 +01:00
data: data,
params: {}
}
return;
}
path = this._normalizePath(path);
if (this._pathExists(path)) {
console.warn(this.tagName + ': Path ' + path + ' is already defined');
return;
}
if (this.routes[namespace] === undefined) {
this.routes[namespace] = {};
}
const parsed = pathToRegexp(path);
2022-06-14 15:04:08 +02:00
2022-03-12 22:43:21 +01:00
this.routes[namespace][path] = {
path: path,
2022-06-15 16:47:23 +02:00
regex: parsed,
2022-03-12 22:43:21 +01:00
data: data,
redirect: redirect,
params: {}
};
}
/**
* Trigger processing of a path.
*
* @param {String} path Path to process
* @param {Boolean} replace If true, the matching route replaces the current history state instead of pushing it on top.
* @return {Object|false} Returns the matching route, or false.
*/
trigger(path, replace) {
path = this._normalizePath(path);
if (path === this._lastPath) {
return;
}
this._lastPath = path;
// Search through all routes and try to find the matching one.
// The first one matching wins. Exept the catch all route. That one is always tried last.
for (const r in this.routes) {
const namespace = this.routes[r];
for (const n in namespace) {
const route = namespace[n];
if (this._isMatchingRoute(path, route)) {
if (route.redirect) {
return this.trigger(route.redirect, replace);
}
if (replace) {
return this._replace(route, path);
} else {
return this._push(route, path);
}
}
}
}
// As last resort trigger the catch all route if configured.
if (this._catchAllRoute) {
return this._push(this._catchAllRoute, path);
}
return false;
}
_dispatchTrigger(e) {
this.trigger(e.detail.path, e.detail.replace);
}
/**
* Pushed a route on the history stack.
*
* @param {Object} route The route object.
* @param {string} path The requested path.
*/
_push(route, path) {
history.pushState(route, document.title, path);
this._setRouteActive(route);
}
/**
* Replace the current history state with the given route.
*
* @param {Object} route The route object.
* @param {string} path The new path.
*/
_replace(route, path) {
history.replaceState(route, document.title, path);
this._setRouteActive(route);
}
/**
* Fires the `route-active` event with the popped history state.
*/
_onPopstate(e) {
if (e.state) {
this._setRouteActive(e.state);
}
}
_setRouteActive(route) {
this._lastPath = this._normalizePath(window.location.pathname);
this.path = route.path;
this.data = route.data;
this.params = Object.assign({}, route.params);
this.dispatchEvent(new CustomEvent('route-active', { detail: route, bubbles: true, composed: true }));
}
/**
* Check if the clicked element should trigger routing.
* Only A-elements trigger routing.
* To prevent routing on a link, set the attribute `download`
* or `rel` = 'external'.
*/
_onDocClick(e) {
if (1 !== e.which || e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
// Try to find a link element that's allowed to fire routing.
const el = closest(e.composedPath()[0], 'a', true);
if (!el || el.nodeName !== 'A') {
return;
}
if (el.hasAttribute('download') || el.getAttribute('rel') === 'external' || el.hasAttribute('target')) {
return;
}
const link = el.getAttribute('href');
// Need to check the origin with `el.href`, because `getAttribute('href')` doesn't include the origin string.
if (link && link.indexOf('mailto:') > -1 || !this._isSameOrigin(el.href)) {
return;
}
const path = el.pathname + el.search + el.hash;
e.preventDefault();
this.trigger(path);
}
_isMatchingRoute(path, route) {
const keys = route.regex.keys;
const params = route.params = {};
const qsIndex = path.indexOf('?');
const pathname = qsIndex > -1 ? path.slice(0, qsIndex) : path;
const m = route.regex.regexp.exec(decodeURIComponent(pathname));
2022-03-12 22:43:21 +01:00
if (!m) return false;
for (let i = 1, len = m.length; i < len; ++i) {
const key = keys[i - 1];
const val = this._decodeURLEncodedURIComponent(m[i]);
if (val !== undefined || !(hasOwnProperty.call(params, key.name))) {
params[key.name] = val;
}
}
return true;
}
_isSameOrigin(href) {
let origin = location.protocol + '//' + location.hostname;
if (location.port) origin += ':' + location.port;
return (href && (0 === href.indexOf(origin)));
}
_pathExists(path) {
for (const key in this.routes) {
const nsRoutes = this.routes[key];
for (const p in nsRoutes) {
if (p === path) {
return true;
}
}
}
return false;
}
/**
* Remove URL encoding from the given `str`.
* Accommodates whitespace in both x-www-form-urlencoded
* and regular percent-encoded form.
*
* @param {string} val URL component to decode
*/
_decodeURLEncodedURIComponent(val) {
if (typeof val !== 'string') { return val; }
return decodeURIComponent(val.replace(/\+/g, ' '));
}
_normalizePath(path) {
if (path.indexOf(this.base) !== 0) {
if (path[0] !== '/') {
path = '/' + path;
}
if (this.base !== '/') {
path = this.base + path;
}
}
return path;
}
}
window.customElements.define('tp-router', TpRouter);