diff --git a/.gitignore b/.gitignore index a1c50b6..2c3a47b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /node_modules/ yarn-error.log +*.d.ts diff --git a/README.md b/README.md index 353d868..638c1fa 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,7 @@ and ids so the strings are maintainable. * MAIN-DASHBOARD DETAIL-VIEW */ -import RouteData from '@jack-henry/web-component-router/lib/route-data.js'; -import RouteTreeNode from '@jack-henry/web-component-router/lib/route-tree-node.js'; +import {RouteData, RouteTreeNode} from '@jack-henry/web-component-router'; const dashboard = new RouteTreeNode( new RouteData('MainDashboard', 'MAIN-DASHBOARD', '/')); @@ -206,11 +205,9 @@ The root element typically has a slightly different configuration. ```js import myAppRouteTree from './route-tree.js'; -import router from '@jack-henry/web-component-router'; -import routeMixin from '@jack-henry/web-component-router/routing-mixin.js'; -import pageJs from 'page'; +import router, {Context, routingMixin} from '@jack-henry/web-component-router'; -class AppElement extends routeMixin(Polymer.Element) { +class AppElement extends routingMixin(Polymer.Element) { static get is() { return 'app-element'; } connectedCallback() { @@ -259,10 +256,9 @@ issue. ```js import myAppRouteTree from './route-tree.js'; -import router from '@jack-henry/web-component-router'; -import routeMixin from '@jack-henry/web-component-router/routing-mixin.js'; +import router, {routingMixin} from '@jack-henry/web-component-router'; -class AppElement extends routeMixin(Polymer.Element) { +class AppElement extends routingMixin(Polymer.Element) { static get is() { return 'app-element'; } connectedCallback() { @@ -280,7 +276,7 @@ class AppElement extends routeMixin(Polymer.Element) { } /** - * @param {!pageJs.Context} context + * @param {!Context} context * @param {function(boolean=)} next * @private */ @@ -338,7 +334,7 @@ router.go(path, params); /** * Register an exit callback to be invoked on every route change - * @param {function(!pageJs.Context, function(boolean=))} callback + * @param {function(!Context, function(boolean=))} callback */ router.addGlobalExitHandler(callback); @@ -372,7 +368,7 @@ router.removeRouteChangeCompleteCallback(callback); * Anonymize the route path by replacing param values with their * param name. Used for analytics tracking * - * @param {!pageJs.Context} context route enter context + * @param {!Context} context route enter context * @return {!string} */ const urlPath = router.getRouteUrlWithoutParams(context); diff --git a/animated-routing-mixin.js b/lib/animated-routing-mixin.js similarity index 93% rename from animated-routing-mixin.js rename to lib/animated-routing-mixin.js index fe0e3b1..9f6e421 100644 --- a/animated-routing-mixin.js +++ b/lib/animated-routing-mixin.js @@ -1,6 +1,5 @@ -import RouteTreeNode from './lib/route-tree-node.js'; -import page from 'page'; -const Context = page.Context; +import RouteTreeNode from './route-tree-node.js'; +import {Context} from './page.js'; import basicRoutingMixin from './routing-mixin.js'; import BasicRoutingInterface from './routing-interface.js'; @@ -16,6 +15,7 @@ function animatedRoutingMixin(Superclass, className) { * @extends {Superclass} * @implements {BasicRoutingInterface} */ + // @ts-ignore const BasicRoutingElement = basicRoutingMixin(Superclass); /** @@ -26,6 +26,7 @@ function animatedRoutingMixin(Superclass, className) { */ class AnimatedRouting extends BasicRoutingElement { connectedCallback() { + // @ts-ignore super.connectedCallback(); document.documentElement.scrollTop = document.body.scrollTop = 0; } @@ -37,6 +38,7 @@ function animatedRoutingMixin(Superclass, className) { * @param {!RouteTreeNode|undefined} nextNodeIfExists * @param {string} routeId * @param {!Context} context + * @return {!Promise} */ async routeEnter(currentNode, nextNodeIfExists, routeId, context) { const currentElement = currentNode.getValue().element; @@ -59,6 +61,7 @@ function animatedRoutingMixin(Superclass, className) { * @param {!RouteTreeNode|undefined} nextNode * @param {string} routeId * @param {!Context} context + * @return {!Promise} */ async routeExit(currentNode, nextNode, routeId, context) { const currentElement = currentNode.getValue().element; diff --git a/lib/page.js b/lib/page.js new file mode 100644 index 0000000..7d5e98c --- /dev/null +++ b/lib/page.js @@ -0,0 +1,801 @@ +/** + * @license Copyright (c) 2012 TJ Holowaychuk + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import {pathToRegexp} from 'path-to-regexp/dist.es2015/index.js'; + +/** + * Short-cuts for global-object checks + */ +const hasDocument = ('undefined' !== typeof document); +const hasWindow = ('undefined' !== typeof window); +const hasHistory = ('undefined' !== typeof history); +const hasProcess = typeof process !== 'undefined'; + +/** + * Detect click event + */ +const clickEvent = hasDocument && document.ontouchstart ? 'touchstart' : 'click'; + +/** + * To work properly with the URL + * history.location generated polyfill in https://github.com/devote/HTML5-History-API + */ +const isLocation = hasWindow && !!(/** @type {?} */ (window.history).location || window.location); + +/** + * @typedef {{ + * window: (Window|undefined), + * decodeURLComponents: (boolean|undefined), + * popstate: (boolean|undefined), + * click: (boolean|undefined), + * hashbang: (boolean|undefined), + * dispatch: (boolean|undefined) + * }} + */ +let PageOptions; + +/** @typedef {function(!Context, function():?):?} */ +let PageCallback; + +/** The page instance */ +export class Page { + constructor() { + // public things + /** @type {!Array} */ + this.callbacks = []; + /** @type {!Array} */ + this.exits = []; + this.current = ''; + this.len = 0; + /** @type {!Context} */ + this.prevContext; + + // private things + this._decodeURLComponents = true; + this._base = ''; + this._strict = false; + this._running = false; + this._hashbang = false; + + // bound functions + this.clickHandler = this.clickHandler.bind(this); + this._onpopstate = this._onpopstate.bind(this); + + /** @type {!Window|undefined} */ + this._window; + this._decodeURLComponents = true; + this._popstate = true; + this._click = true; + this._hashbang = true; + } + + /** + * Configure the instance of page. This can be called multiple times. + * + * @param {PageOptions=} options + */ + configure(options) { + const opts = options || /** @type {!PageOptions} */ ({}); + + this._window = opts.window || (hasWindow ? window : undefined); + this._decodeURLComponents = opts.decodeURLComponents !== false; + this._popstate = opts.popstate !== false && hasWindow; + this._click = opts.click !== false && hasDocument; + this._hashbang = !!opts.hashbang; + + const _window = this._window; + if (this._popstate) { + _window.addEventListener('popstate', this._onpopstate, false); + } else if (hasWindow) { + _window.removeEventListener('popstate', this._onpopstate, false); + } + + if (this._click) { + _window.document.addEventListener(clickEvent, this.clickHandler, false); + } else if (hasDocument) { + _window.document.removeEventListener(clickEvent, this.clickHandler, false); + } + + if (this._hashbang && hasWindow && !hasHistory) { + _window.addEventListener('hashchange', this._onpopstate, false); + } else if (hasWindow) { + _window.removeEventListener('hashchange', this._onpopstate, false); + } + } + + /** + * Get or set basepath to `path`. + * + * @param {string} path + */ + base(path) { + if (0 === arguments.length) { + return this._base; + } + this._base = path; + } + + /** + * Gets the `base`, which depends on whether we are using History or + * hashbang routing. + */ + _getBase() { + let base = this._base; + if (!!base) { + return base; + } + const loc = hasWindow && this._window && this._window.location; + + if (hasWindow && this._hashbang && loc && loc.protocol === 'file:') { + base = loc.pathname; + } + + return base; + } + + /** + * Get or set strict path matching to `enable` + * + * @param {boolean} enable + */ + strict(enable) { + if (0 === arguments.length) { + return this._strict; + } + this._strict = enable; + } + + + /** + * Bind with the given `options`. + * + * Options: + * + * - `click` bind to click events [true] + * - `popstate` bind to popstate [true] + * - `dispatch` perform initial dispatch [true] + * + * @param {PageOptions=} options + */ + start(options) { + const opts = options || /** @type {!PageOptions} */ ({}); + this.configure(opts); + + if (false === opts.dispatch) { + return; + } + this._running = true; + + let url; + if (isLocation) { + const window = this._window; + const loc = window.location; + + if (this._hashbang && ~loc.hash.indexOf('#!')) { + url = loc.hash.substr(2) + loc.search; + } else if (this._hashbang) { + url = loc.search + loc.hash; + } else { + url = loc.pathname + loc.search + loc.hash; + } + } + + this.replace(url, null, true, opts.dispatch); + } + + /** Unbind click and popstate event handlers. */ + stop() { + if (!this._running) { + return; + } + this.current = ''; + this.len = 0; + this._running = false; + + const window = this._window; + this._click && window.document.removeEventListener(clickEvent, this.clickHandler, false); + hasWindow && window.removeEventListener('popstate', this._onpopstate, false); + hasWindow && window.removeEventListener('hashchange', this._onpopstate, false); + } + + /** + * Show `path` with optional `state` object. + * + * @param {string} path + * @param {Object=} state + * @param {boolean=} dispatch + * @param {boolean=} push + * @return {!Context} + */ + show(path, state, dispatch, push) { + const ctx = new Context(path, state, this); + const prev = this.prevContext; + this.prevContext = ctx; + this.current = ctx.path; + if (false !== dispatch) { + this.dispatch(ctx, prev); + } + if (false !== ctx.handled && false !== push) { + ctx.pushState(); + } + return ctx; + } + + /** + * Goes back in the history + * Back should always let the current route push state and then go back. + * + * @param {string} path - fallback path to go back if no more history exists, if undefined defaults to page.base + * @param {Object=} state + */ + back(path, state) { + const page = this; + if (this.len > 0) { + const window = this._window; + // this may need more testing to see if all browsers + // wait for the next tick to go back in history + hasHistory && window.history.back(); + this.len--; + } else if (path) { + setTimeout(function () { + page.show(path, state); + }); + } else { + setTimeout(function () { + page.show(page._getBase(), state); + }); + } + } + + /** + * Register route to redirect from one path to other + * or just redirect to another route + * + * @param {string} from - if param 'to' is undefined redirects to 'from' + * @param {string=} to + */ + redirect(from, to) { + // Define route from a path to another + if ('string' === typeof from && 'string' === typeof to) { + this.register(from, (e) => { + setTimeout(() => { + this.replace(/** @type {!string} */ (to)); + }, 0); + }); + } + + // Wait for the push state and replace it with another + if ('string' === typeof from && 'undefined' === typeof to) { + setTimeout(() => { + this.replace(from); + }, 0); + } + } + + /** + * Replace `path` with optional `state` object. + * + * @param {string|undefined} path + * @param {*=} state + * @param {boolean=} init + * @param {boolean=} dispatch + * @return {!Context} + */ + replace(path, state, init, dispatch) { + const ctx = new Context(path, state, this); + const prev = this.prevContext; + this.prevContext = ctx; + this.current = ctx.path; + ctx.init = init; + ctx.save(); // save before dispatching, which may redirect + if (false !== dispatch) { + this.dispatch(ctx, prev); + } + return ctx; + } + + /** + * Dispatch the given `ctx`. + * + * @param {!Context} ctx + * @param {!Context} prev + */ + dispatch(ctx, prev) { + let i = 0; + let j = 0; + const page = this; + + function nextExit() { + let fn = page.exits[j++]; + if (!fn) { + return nextEnter(); + } + fn(prev, nextExit); + } + + function nextEnter() { + let fn = page.callbacks[i++]; + + if (ctx.path !== page.current) { + ctx.handled = false; + return; + } + if (!fn) { + return unhandled.call(page, ctx); + } + fn(ctx, nextEnter); + } + + if (prev) { + nextExit(); + } else { + nextEnter(); + } + } + + /** + * Register an exit route on `path` with + * callback `fn()`, which will be called + * on the previous context when a new + * page is visited. + * + * @param {!string|!PageCallback} path + * @param {!PageCallback} fn + * @param {...!PageCallback} fns + */ + exit(path, fn, ...fns) { + if (typeof path === 'function') { + return this.exit('*', path); + } + + const callbacks = [fn].concat(fns); + const route = new Route(path, null, this); + for (let i = 0; i < callbacks.length; ++i) { + this.exits.push(route.middleware(callbacks[i])); + } + } + + /** + * Handle "click" events. + * @param {!Event} evt + */ + clickHandler(evt) { + const e = /** @type {!MouseEvent} */ (evt); + if (1 !== this._which(e)) { + return; + } + + if (e.metaKey || e.ctrlKey || e.shiftKey) { + return; + } + if (e.defaultPrevented) { + return; + } + + // ensure link + // use shadow dom when available + let el = /** @type {!HTMLElement} */ (e.target); + if (el.nodeName !== 'A') { + const composedPath = e.composedPath(); + for (let i = 0; i < composedPath.length; i++) { + el = /** @type {!HTMLElement} */ (composedPath[i]); + // el.nodeName for svg links are 'a' instead of 'A' + if (el.nodeName.toUpperCase() === 'A') { + break; + } + } + } + + if (!el || el.nodeName.toUpperCase() !== 'A') { + return; + } + let anchor = /** @type {!HTMLAnchorElement} */ (el); + const svgAnchor = /** @type {!SVGAElement} */ (/** @type {?} */ (el)); + + // check if link is inside an svg + // in this case, both href and target are always inside an object + const svg = (typeof svgAnchor.href === 'object') && svgAnchor.href.constructor.name === 'SVGAnimatedString'; + + // Ignore if tag has + // 1. "download" attribute + // 2. rel="external" attribute + if (anchor.hasAttribute('download') || anchor.getAttribute('rel') === 'external') { + return; + } + + // ensure non-hash for the same path + const link = anchor.getAttribute('href'); + if (!this._hashbang && this._samePath(anchor) && (anchor.hash || '#' === link)) { + return; + } + + // Check for mailto: in the href + if (link && link.indexOf('mailto:') > -1) { + return; + } + + // check target + // svg target is an object and its desired value is in .baseVal property + if (svg ? svgAnchor.target.baseVal : svgAnchor.target) { + return; + } + + // x-origin + // note: svg links that are not relative don't call click events (and skip page.js) + // consequently, all svg links tested inside page.js are relative and in the same origin + if (!svg && !this.sameOrigin(anchor.href)) { + return; + } + + // rebuild path + // There aren't .pathname and .search properties in svg links, so we use href + // Also, svg href is an object and its desired value is in .baseVal property + let path = svg ? svgAnchor.href.baseVal : (anchor.pathname + anchor.search + (anchor.hash || '')); + + path = path[0] !== '/' ? '/' + path : path; + + // strip leading "/[drive letter]:" on NW.js on Windows + if (hasProcess && path.match(/^\/[a-zA-Z]:\//)) { + path = path.replace(/^\/[a-zA-Z]:\//, '/'); + } + + // same page + const orig = path; + const pageBase = this._getBase(); + + if (path.indexOf(pageBase) === 0) { + path = path.substr(pageBase.length); + } + + if (this._hashbang) { + path = path.replace('#!', ''); + } + + if (pageBase && orig === path && (!isLocation || this._window.location.protocol !== 'file:')) { + return; + } + + e.preventDefault(); + this.show(orig); + } + + /** + * Event button. + */ + _which(e) { + e = e || (hasWindow && this._window.event); + return null == e.which ? e.button : e.which; + } + + /** + * Convert to a URL object + */ + _toURL(href) { + const window = this._window; + if (typeof URL === 'function' && isLocation) { + return new URL(href, window.location.toString()); + } else if (hasDocument) { + const anc = window.document.createElement('a'); + anc.href = href; + return anc; + } + } + + /** + * Check if `href` is the same origin. + * @param {string} href + */ + sameOrigin(href) { + if (!href || !isLocation) { + return false; + } + + const url = this._toURL(href); + const window = this._window; + + const loc = window.location; + + + // When the port is the default http port 80 for http, or 443 for + // https, internet explorer 11 returns an empty string for loc.port, + // so we need to compare loc.port with an empty string if url.port + // is the default port 80 or 443. + // Also the comparition with `port` is changed from `===` to `==` because + // `port` can be a string sometimes. This only applies to ie11. + return loc.protocol === url.protocol && + loc.hostname === url.hostname && + (loc.port === url.port || loc.port === '' && (url.port == '80' || url.port == '443')); + } + + _samePath(url) { + if (!isLocation) { + return false; + } + const window = this._window; + const loc = window.location; + return url.pathname === loc.pathname && + url.search === loc.search; + } + + /** + * 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 this._decodeURLComponents ? decodeURIComponent(val.replace(/\+/g, ' ')) : val; + } + + /** + * Register `path` with callback `fn()` + * + * page.register('*', fn); + * page.register('/user/:id', load, user); + * + * @param {string} path + * @param {!PageCallback} fn + * @param {...!PageCallback} fns + */ + register(path, fn, ...fns) { + const route = new Route(/** @type {string} */ (path), null, this); + const callbacks = [fn].concat(fns); + for (let i = 0; i < callbacks.length; ++i) { + this.callbacks.push(route.middleware(callbacks[i])); + } + } +} + +/** Handle "popstate" events. */ +Page.prototype._onpopstate = (function () { + let loaded = false; + if (!hasWindow) { + return function () {}; + } + if (hasDocument && document.readyState === 'complete') { + loaded = true; + } else { + const loadFn = function () { + setTimeout(function () { + loaded = true; + }, 0); + window.removeEventListener('load', loadFn); + }; + window.addEventListener('load', loadFn); + } + + /** + * @this {!Page} + * @param {!Event} evt + */ + function onpopstate(evt) { + const e = /** @type {!PopStateEvent} */ (evt); + if (!loaded) { + return; + } + const page = this; + if (e.state) { + const path = e.state.path; + page.replace(path, e.state); + } else if (isLocation) { + const loc = page._window.location; + page.show(loc.pathname + loc.search + loc.hash, undefined, undefined, false); + } + } + return onpopstate; +})(); + +/** + * Unhandled `ctx`. When it's not the initial + * popstate then redirect. If you wish to handle + * 404s on your own use `page.register('*', callback)`. + * + * @param {Context} ctx + * @this {!Page} + */ +function unhandled(ctx) { + if (ctx.handled) { + return; + } + let current; + const page = this; + const window = page._window; + + if (page._hashbang) { + current = isLocation && this._getBase() + window.location.hash.replace('#!', ''); + } else { + current = isLocation && window.location.pathname + window.location.search; + } + + if (current === ctx.canonicalPath) { + return; + } + page.stop(); + ctx.handled = false; + isLocation && (window.location.href = ctx.canonicalPath); +} + +/** + * Escapes RegExp characters in the given string. + * + * @param {string} s + */ +function escapeRegExp(s) { + return s.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1'); +} + +export class Context { + /** + * Initialize a new "request" `Context` + * with the given `path` and optional initial `state`. + * + * @param {string|undefined} path + * @param {*=} state + * @param {!Page=} pageInstance + */ + constructor(path, state, pageInstance) { + if (!pageInstance) { + pageInstance = new Page(); + pageInstance.configure(); + } + const _page = this.page = pageInstance; + const window = _page._window; + const hashbang = _page._hashbang; + + const pageBase = _page._getBase(); + if ('/' === path[0] && 0 !== path.indexOf(pageBase)) { + path = pageBase + (hashbang ? '#!' : '') + path; + } + const i = path.indexOf('?'); + + this.canonicalPath = path; + const re = new RegExp('^' + escapeRegExp(pageBase)); + this.path = path.replace(re, '') || '/'; + if (hashbang) { + this.path = this.path.replace('#!', '') || '/'; + } + + this.title = (hasDocument ? window.document.title : undefined); + this.state = state || {}; + this.state.path = path; + this.querystring = ~i ? _page._decodeURLEncodedURIComponent(path.slice(i + 1)) : ''; + this.pathname = _page._decodeURLEncodedURIComponent(~i ? path.slice(0, i) : path); + /** @type {!Object} */ + this.params = {}; + this.query = new URLSearchParams(); + + // fragment + this.hash = ''; + if (!hashbang) { + if (!~this.path.indexOf('#')) { + return; + } + const parts = this.path.split('#'); + this.path = this.pathname = parts[0]; + this.hash = _page._decodeURLEncodedURIComponent(parts[1]) || ''; + this.querystring = this.querystring.split('#')[0]; + } + this.handled = false; + /** @type {boolean|undefined} */ + this.init; + /** @type {string|undefined} */ + this.routePath; + } + + /** Push state. */ + pushState() { + const page = this.page; + const window = page._window; + const hashbang = page._hashbang; + + page.len++; + if (hasHistory) { + window.history.pushState(this.state, this.title || '', + hashbang && this.path !== '/' ? '#!' + this.path : this.canonicalPath); + } + } + + /** Save the context state. */ + save() { + const page = this.page; + if (hasHistory) { + page._window.history.replaceState(this.state, this.title || '', + page._hashbang && this.path !== '/' ? '#!' + this.path : this.canonicalPath); + } + } +} + +export class Route { + /** + * Initialize `Route` with the given HTTP `path`, + * and an array of `callbacks` and `options`. + * + * Options: + * + * - `sensitive` enable case-sensitive routes + * - `strict` enable strict matching for trailing slashes + * + * @param {string} path + * @param {Object=} options + * @param {!Page=} page + */ + constructor(path, options, page) { + const _page = this.page = page || new Page(); + const opts = options || {}; + opts.strict = opts.strict || _page._strict; + this.path = (path === '*') ? '(.*)' : path; + this.method = 'GET'; + this.regexp = pathToRegexp(this.path, this.keys = [], opts); + } + + /** + * Return route middleware with + * the given callback `fn()`. + * + * @param {!PageCallback} fn + * @return {!PageCallback} + */ + middleware(fn) { + /** + * @param {!Context} ctx + * @param {function():?} next + */ + const callback = (ctx, next) => { + if (this.match(ctx.path, ctx.params)) { + ctx.routePath = this.path; + return fn(ctx, next); + } + next(); + }; + return callback; + } + + /** + * Check if this route matches `path`, if so + * populate `params`. + * + * @param {string} path + * @param {Object} params + * @return {boolean} + */ + match(path, params) { + const keys = this.keys; + const qsIndex = path.indexOf('?'); + const pathname = ~qsIndex ? path.slice(0, qsIndex) : path; + const m = this.regexp.exec(decodeURIComponent(pathname)); + + if (!m) { + return false; + } + + delete params[0]; + + for (let i = 1, len = m.length; i < len; ++i) { + let key = keys[i - 1]; + let val = this.page._decodeURLEncodedURIComponent(m[i]); + if (val !== undefined || !(Object.hasOwnProperty.call(params, key.name))) { + params[key.name] = val; + } + } + + return true; + } +} diff --git a/lib/route-tree-node.js b/lib/route-tree-node.js index 9f202e3..2082480 100644 --- a/lib/route-tree-node.js +++ b/lib/route-tree-node.js @@ -18,10 +18,9 @@ * @see https://google.github.io/closure-library/api/goog.structs.TreeNode.html */ -import page from 'page'; -const Context = page.Context; +import {Context} from './page.js'; import RouteData from './route-data.js'; -import BasicRoutingInterface from '../routing-interface.js'; +import BasicRoutingInterface from './routing-interface.js'; class RouteTreeNode { /** @param {!RouteData} data */ @@ -97,9 +96,10 @@ class RouteTreeNode { * parents. */ getRoot() { + /** @type {!RouteTreeNode} */ let root = this; while (root.getParent()) { - root = root.getParent(); + root = /** @type {!RouteTreeNode} */ (root.getParent()); } return root; } @@ -129,7 +129,7 @@ class RouteTreeNode { * Traverses the subtree with the possibility to skip branches. Starts with * this node, and visits the descendant nodes depth-first, in preorder. * @param {function(this:RouteTreeNode, !RouteTreeNode): - * (boolean|undefined)} f Callback function. It takes the node as argument. + * (boolean|undefined|void)} f Callback function. It takes the node as argument. * The children of this node will be visited if the callback returns true or * undefined, and will be skipped if the callback returns false. */ @@ -253,7 +253,7 @@ class RouteTreeNode { const routeId = this.getKey(); // Ancestors are a path from the parent up the tree to the root - const entryNodes = [this].concat(this.getAncestors()); + const entryNodes = [/** @type {!RouteTreeNode} */ (this)].concat(this.getAncestors()); entryNodes.reverse(); let exitNodes = []; @@ -308,7 +308,7 @@ class RouteTreeNode { } } - /** @param {boolean=} isNotCancelled */ + /** @param {(boolean|void)=} isNotCancelled */ async function nextEntry(isNotCancelled) { if (isNotCancelled === false) { return; @@ -319,7 +319,9 @@ class RouteTreeNode { if (!currentEntryNode) { return; } else if (currentEntryNode.getValue().element) { - const routingElem = /** @type {!BasicRoutingInterface} */ (currentEntryNode.getValue().element); + const routingElem = /** @type {!BasicRoutingInterface} */ ( + /** @type {?} */ (currentEntryNode.getValue().element) + ); if (!routingElem.routeEnter) { throw new Error(`Element '${currentEntryNode.getValue().tagName}' does not implement routeEnter`); } diff --git a/routing-interface.js b/lib/routing-interface.js similarity index 82% rename from routing-interface.js rename to lib/routing-interface.js index 9549cd0..d5ab80d 100644 --- a/routing-interface.js +++ b/lib/routing-interface.js @@ -1,6 +1,8 @@ +import RouteTreeNode from './route-tree-node.js'; +import {Context} from './page.js'; /** @interface */ -const BasicRoutingInterface = class { +class BasicRoutingInterface { /** * Default implementation for the callback on entering a route node. * This will only be used if an element does not define it's own routeEnter method. @@ -9,7 +11,7 @@ const BasicRoutingInterface = class { * @param {!RouteTreeNode|undefined} nextNodeIfExists * @param {string} routeId * @param {!Context} context - * @return {!Promise} + * @return {!Promise} */ async routeEnter(currentNode, nextNodeIfExists, routeId, context) { } @@ -21,7 +23,7 @@ const BasicRoutingInterface = class { * @param {!RouteTreeNode|undefined} nextNode * @param {string} routeId * @param {!Context} context - * @return {!Promise} + * @return {!Promise} */ async routeExit(currentNode, nextNode, routeId, context) { } }; diff --git a/routing-mixin.js b/lib/routing-mixin.js similarity index 94% rename from routing-mixin.js rename to lib/routing-mixin.js index 2ab24af..84e55fd 100644 --- a/routing-mixin.js +++ b/lib/routing-mixin.js @@ -1,8 +1,7 @@ -import RouteTreeNode from './lib/route-tree-node.js'; -import RouteData from './lib/route-data.js'; -import page from 'page'; +import RouteTreeNode from './route-tree-node.js'; +import RouteData from './route-data.js'; +import {Context} from './page.js'; import BasicRoutingInterface from './routing-interface.js'; -const Context = page.Context; /** * @param {function(new:HTMLElement)} Superclass @@ -24,7 +23,7 @@ function routingMixin(Superclass) { * @param {!RouteTreeNode|undefined} nextNodeIfExists * @param {string} routeId * @param {!Context} context - * @return {!Promise} + * @return {!Promise} */ async routeEnter(currentNode, nextNodeIfExists, routeId, context) { context.handled = true; @@ -36,7 +35,7 @@ function routingMixin(Superclass) { const nextNodeData = /** @type {!RouteData} */(nextNode.getValue()); const thisElem = /** @type {!Element} */ (/** @type {?} */ (this)); - /** @type {!Element>} */ + /** @type {Element} */ let nextElement = nextNodeData.element || thisElem.querySelector(nextNodeData.tagName.toLowerCase()); // Reuse the element if it already exists in the dom. @@ -100,6 +99,7 @@ function routingMixin(Superclass) { * @param {!RouteTreeNode|undefined} nextNode * @param {string} routeId * @param {!Context} context + * @return {!Promise} */ async routeExit(currentNode, nextNode, routeId, context) { const currentElement = currentNode.getValue().element; diff --git a/package.json b/package.json index 3f8a4ea..921888b 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,14 @@ "files": [ "lib/", "*.js", + "*.d.ts", "package.json", "README.md" ], "scripts": { + "build": "tsc", + "clean": "rimraf './lib/**/*.d.ts' && rimraf './*.d.ts'", + "prepublish": "yarn clean && yarn build", "test": "karma start test/karma.config.cjs --single-run" }, "repository": { @@ -24,8 +28,7 @@ }, "homepage": "https://github.com/Banno/web-component-router", "dependencies": { - "page": "1.7.x", - "qs": "6.11.0" + "path-to-regexp": "^6.2.1" }, "devDependencies": { "@polymer/polymer": "^3.4.1", @@ -33,6 +36,7 @@ "karma": "6.x", "karma-chrome-launcher": "^3.1.0", "karma-jasmine": "4.x", - "karma-spec-reporter": "^0.0.34" + "karma-spec-reporter": "^0.0.34", + "typescript": "^4.8.0-beta" } } diff --git a/router.js b/router.js index 93d7b41..fcf871c 100644 --- a/router.js +++ b/router.js @@ -15,46 +15,12 @@ * B C E */ -import pageJs from 'page'; -import qsParse from 'qs/lib/parse.js'; +import {Context, Page} from './lib/page.js'; import RouteTreeNode from './lib/route-tree-node.js'; - -/** - * @param {!URL|!HTMLAnchorElement} url - * @return {!{ - * protocol: string, - * hostname: string, - * port: string - * }} - */ -function urlParts(url) { - const parts = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port - }; - if (url.port.trim().length > 0) { - parts.port = url.port; - } else if (/^https:$/i.test(url.protocol)) { - parts.port = '443'; - } else if (/^http:$/i.test(url.protocol)) { - parts.port = '80'; - } - return parts; -} - -/** - * @param {string} href1 - * @param {string} href2 - * @return {boolean} - */ -function compareOrigins(href1, href2) { - const url1 = urlParts(new URL(href1, location.toString())); - const url2 = urlParts(new URL(href2, location.toString())); - return url1.protocol === url2.protocol && - url1.hostname === url2.hostname && - url1.port === url2.port; -} +import routingMixin from './lib/routing-mixin.js'; +import animatedRoutingMixin from './lib/animated-routing-mixin.js'; +import BasicRoutingInterface from './lib/routing-interface.js'; +import RouteData from './lib/route-data.js'; class Router { constructor() { @@ -74,10 +40,12 @@ class Router { this.nextStateWasPopped = true; }, true); - /** @type {!Set} */ + /** @type {!Set} */ this.routeChangeStartCallbacks_ = new Set(); - /** @type {!Set} */ + /** @type {!Set} */ this.routeChangeCompleteCallbacks_ = new Set(); + + this.page = new Page(); } /** @return {!RouteTreeNode|undefined} */ @@ -102,19 +70,21 @@ class Router { /** * Build the routing tree and begin routing - * @return {undefined} + * @return {void} */ start() { this.registerRoutes_(); - document.addEventListener('tap', Router.navigationEvent_, false); - document.addEventListener('click', Router.navigationEvent_, false); + document.addEventListener('tap', this.page.clickHandler.bind(this.page), false); + document.addEventListener('click', this.page.clickHandler.bind(this.page), false); - pageJs.start({ + this.page.start({ click: false, popstate: true, hashbang: false, - decodeURLComponents: true + decodeURLComponents: true, + window: undefined, + dispatch: undefined }); } @@ -125,7 +95,7 @@ class Router { */ go(path, params) { path = this.url(path, params); - pageJs.show(path); + this.page.show(path); } /** @@ -136,7 +106,7 @@ class Router { */ redirect(path, params) { path = this.url(path, params); - pageJs.replace(path); + this.page.replace(path); } /** @@ -181,147 +151,59 @@ class Router { /** * Register an exit callback to be invoked on every route change - * @param {function(!pageJs.Context, function(boolean=))} callback + * @param {function(!Context, function(boolean=):?):?} callback */ addGlobalExitHandler(callback) { - pageJs.exit('*', callback); + this.page.exit('*', callback); } /** * Register an exit callback for a particular route * @param {!string} route - * @param {function(!pageJs.Context, function(boolean=))} callback + * @param {function(!Context, function(boolean=):?):?} callback */ addExitHandler(route, callback) { - pageJs.exit(route, callback); + this.page.exit(route, callback); } /** * Register an entry callback for a particular route * @param {!string} route - * @param {function(!pageJs.Context, function(boolean=))} callback + * @param {function(!Context, function(boolean=):?):?} callback */ addRouteHandler(route, callback) { - pageJs(route, callback); + this.page.register(route, callback); } - /** @param {!function()} callback */ + /** @param {!function():?} callback */ addRouteChangeStartCallback(callback) { this.routeChangeStartCallbacks_.add(callback); } - /** @param {!function()} callback */ + /** @param {!function():?} callback */ removeRouteChangeStartCallback(callback) { this.routeChangeStartCallbacks_.delete(callback); } - /** @param {!function(!Error=)} callback */ + /** @param {!function(!Error=):?} callback */ addRouteChangeCompleteCallback(callback) { this.routeChangeCompleteCallbacks_.add(callback); } - /** @param {!function(!Error=)} callback */ + /** @param {!function(!Error=):?} callback */ removeRouteChangeCompleteCallback(callback) { this.routeChangeCompleteCallbacks_.delete(callback); } - /** - * A modified copy of the pagejs onclick function. - * Properly handles Polymer "tap" events. - * - * @param {!Event} e - */ - static navigationEvent_(e) { - if (e.type !== 'tap' && (e.which === null ? e.button : e.which) !== 1) { - return; - } - - if (e.metaKey || e.ctrlKey || e.shiftKey) { - return; - } - if (e.defaultPrevented) { - return; - } - - // ensure link - // use shadow dom when available - let el = e.target; - if (el.nodeName !== 'A') { - const composedPath = e.composedPath(); - for (let i = 0; i < composedPath.length; i++) { - el = composedPath[i]; - if (el.nodeName === 'A') { - break; - } - } - } - - if (!el || el.nodeName !== 'A') { - return; - } - - // Ignore if tag has - // 1. "download" attribute - // 2. rel="external" attribute - if (el.hasAttribute('download') || el.getAttribute('rel') === 'external') { - return; - } - - // ensure a href exists and non-hash for the same path - const link = el.getAttribute('href'); - if (!link || ((el.pathname === location.pathname || el.pathname === '') && (el.hash || link === '#'))) { - return; - } - - // Check for mailto: in the href - if (el.protocol && el.protocol.length > 0 && !/^https?:$/.test(el.protocol)) { - return; - } - - // check target - if (el.target) { - return; - } - - // x-origin - // IE anchor tags have default ports ":80", ":443", ete, - // but the location url does not. - // Normalize the location href and compare. - if (!compareOrigins(location.href, el.href)) { - return; - } - - // rebuild path - const path = el.pathname + el.search + (el.hash || ''); - - /* If we ever support running the app in a subfolder, we'll need this block - // same page - var orig = path; - - let base = ''; - if (path.indexOf(base) === 0) { - path = path.substr(base.length); - } - - if (base && orig === path) { - return; - } - */ - - e.preventDefault(); - // pageJs.show(orig); - pageJs.show(path); - } - /** * Adds the query parameters to the Page.js context. * - * @param {!pageJs.Context} context - * @param {function()} next + * @param {!Context} context + * @param {function():?} next * @private */ parseQueryString_(context, next) { - context.query = qsParse(context.querystring, {}); + context.query = new URLSearchParams(context.querystring); next(); } @@ -332,7 +214,7 @@ class Router { * @private */ registerRoutes_() { - pageJs('*', this.parseQueryString_); + this.page.register('*', this.parseQueryString_); this.routeTree_.traverse((node) => { if (node === null) { @@ -347,14 +229,14 @@ class Router { return; } - pageJs(routeData.path, this.routeChangeCallback_.bind(this, node)); + this.page.register(routeData.path, this.routeChangeCallback_.bind(this, node)); }); } /** * @param {!RouteTreeNode} routeTreeNode - * @param {!pageJs.Context} context - * @param {function()} next + * @param {!Context} context + * @param {function():?} next * @private */ async routeChangeCallback_(routeTreeNode, context, next) { @@ -377,7 +259,7 @@ class Router { * Replace route path param values with their param name * for analytics tracking * - * @param {!pageJs.Context} context route enter context + * @param {!Context} context route enter context * @return {!string} */ getRouteUrlWithoutParams(context) { @@ -402,3 +284,4 @@ class Router { } export default Router; +export {animatedRoutingMixin, BasicRoutingInterface, Context, RouteData, RouteTreeNode, routingMixin}; diff --git a/test/fixtures/custom-fixture.js b/test/fixtures/custom-fixture.js index cfbd8bc..386c71b 100644 --- a/test/fixtures/custom-fixture.js +++ b/test/fixtures/custom-fixture.js @@ -1,6 +1,5 @@ import {PolymerElement, html} from '@polymer/polymer/polymer-element.js'; -import {default as routingMixin} from '../../routing-mixin.js'; -import BasicRoutingInterface from '../../routing-interface.js'; +import {BasicRoutingInterface, routingMixin} from '../../router.js'; /** * @constructor * @extends {PolymerElement} diff --git a/test/karma-init-pre.js b/test/karma-init-pre.js index 9d00d54..676f654 100644 --- a/test/karma-init-pre.js +++ b/test/karma-init-pre.js @@ -2,10 +2,6 @@ document.write(`