384 lines
9.8 KiB
JavaScript
384 lines
9.8 KiB
JavaScript
/**
|
|
@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';
|
|
import { closest } from '@tp/helpers/closest.js';
|
|
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 => {
|
|
if (routeEl.nodeName === 'TP-ROUTE') {
|
|
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}'),
|
|
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);
|
|
|
|
this.routes[namespace][path] = {
|
|
path: path,
|
|
regex: parsed,
|
|
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));
|
|
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);
|