diff --git a/README.md b/README.md index 1ab27b7..e066c83 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# tp-element +# tp-router diff --git a/package.json b/package.json index c39fdff..7de5b01 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "@tp/tp-element", - "version": "0.0.1", + "name": "@tp/tp-router", + "version": "1.0.0", "description": "", - "main": "tp-element.js", + "main": "tp-router.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", - "url": "https://gitea.codeblob.work/tp-elements/tp-element.git" + "url": "https://gitea.codeblob.work/tp-elements/tp-router.git" }, "author": "trading_peter", "license": "Apache-2.0", diff --git a/the-route.js b/the-route.js new file mode 100644 index 0000000..78db786 --- /dev/null +++ b/the-route.js @@ -0,0 +1,36 @@ +/** +@license +Copyright (c) 2022 trading_peter +This program is available under Apache License Version 2.0 +*/ + +import { LitElement, css } from 'lit'; + +class TpRoute extends LitElement { + static get styles() { + return [ + css` + :host { + display: block; + } + ` + ]; + } + + static get properties() { + return { + path: { type: String }, + data: { type: String }, + namespace: { type: String }, + redirect: { type: String }, + }; + } + + constructor() { + super(); + this.path = '*'; + this.namespace = 'default'; + } +} + +window.customElements.define('tp-route', TpRoute); diff --git a/the-router.js b/the-router.js new file mode 100644 index 0000000..dec9562 --- /dev/null +++ b/the-router.js @@ -0,0 +1,381 @@ +/** +@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 '../../helpers/closest.js'; +import { LitElement, html, css } from 'lit'; + +/* +# tp-router + +A simple router element to declaratively handle routing in web applications. + +## Example + +```html + + + + + + +``` + +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` + + `; + } + + 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('(.*)'), + 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] = {}; + } + + this.routes[namespace][path] = { + path: path, + regex: pathToRegexp(path), + 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.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); diff --git a/tp-element.js b/tp-element.js deleted file mode 100644 index 6a92a2f..0000000 --- a/tp-element.js +++ /dev/null @@ -1,35 +0,0 @@ -/** -@license -Copyright (c) 2022 trading_peter -This program is available under Apache License Version 2.0 -*/ - -import { LitElement, html, css } from 'lit'; - -class TpElement extends LitElement { - static get styles() { - return [ - css` - :host { - display: block; - } - ` - ]; - } - - render() { - const { } = this; - - return html` - - `; - } - - static get properties() { - return { }; - } - - -} - -window.customElements.define('tp-element', TpElement);