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);