StructureJS
0.15.2A class based utility library for building modular and scalable web platform applications. Features opt-in classes and utilities which provide a solid foundation and toolset to build your next project.
import StringUtil from '../util/StringUtil'; import RouterEvent from '../event/RouterEvent'; import Route from '../model/Route'; /** * The **Router** class is a static class allows you to add different route patterns that can be matched to help control your application. Look at the Router.{{#crossLink "Router/add:method"}}{{/crossLink}} method for more details and examples. * * @class Router * @module StructureJS * @submodule controller * @requires Route * @requires RouterEvent * @requires StringUtil * @static * @author Robert S. (www.codeBelt.com) */ class Router { /** * A reference to the browser Window Object. * * @property _window * @type {Window} * @private * @static */ private static _window:any = window; /** * A list of the added Route objects. * * @property _routes * @type {Array<Route>} * @private * @static */ private static _routes:Array<Route> = []; /** * TODO: YUIDoc_comment * * @property _validators * @type {Array<Function>} * @private * @static */ private static _validators:Array<Function> = []; /** * TODO: YUIDoc_comment * * @property _validatorFunc * @type {Function} * @private * @static */ private static _validatorFunc:Function = null; /** * A reference to default route object. * * @property _defaultRoute * @type {Route} * @private * @static */ private static _defaultRoute:Route = null; /** * A reference to the hash change event that was sent from the Window Object. * * @property _hashChangeEvent * @type {any} * @private * @static */ private static _hashChangeEvent:any = null; /** * A reference to the current {{#crossLink "RouterEvent"}}{{/crossLink}} that was triggered. * * @property _currentRoute * @type {RouterEvent} * @private * @static */ private static _currentRoute:RouterEvent = null; /** * A reference to the last state object this {{#crossLink "Router"}}{{/crossLink}} creates when this * using the HTML5 History API. * * @property _lastHistoryState * @type {RouterEvent} * @private * @static */ private static _lastHistoryState:{ route:string } = null; /** * Determines if the {{#crossLink "Router"}}{{/crossLink}} should use hash or history routing. * * @property forceHashRouting * @type {boolean} * @default false * @public * @static * @example * Router.forceHashRouting = true; */ public static forceHashRouting:boolean = false; /** * Determines if the Router class is enabled or disabled. * * @property isEnabled * @type {boolean} * @readOnly * @public * @static * @example * // Read only. * console.log(Router.isEnabled); */ public static isEnabled:boolean = false; /** * The **Router.useDeepLinking** property tells the Router class weather it should change the hash url or not. * By **default** this property is set to **true**. If you set the property to **false** and using the **Router.navigateTo** * method the hash url will not change. This can be useful if you are making an application or game and you don't want the user * to know how to jump to other sections directly. See the **Router.{{#crossLink "Router/allowManualDeepLinking:property"}}{{/crossLink}}** to fully change the Router class * from relying on the hash url to an internal state service. * * @property useDeepLinking * @type {boolean} * @default true * @public * @static * @example * Router.useDeepLinking = true; */ public static useDeepLinking:boolean = true; /** * The **Router.allowManualDeepLinking** property tells the Router class weather it should check for route matches if the * hash url changes in the browser. This property only works if the **Router. {{#crossLink "Router/useDeepLinking:property"}}{{/crossLink}}** is set to **false**. * This is useful if want to use your added routes but don't want any external forces trigger your routes. * * Typically what I do for games is during development/testing I allow the hash url to change the states so testers can jump * to sections or levels easily but then when it is ready for production I set the property to **false** so users cannot jump * around if they figure out the url schema. * * @property allowManualDeepLinking * @type {boolean} * @default true * @public * @static * @example * Router.useDeepLinking = false; * Router.allowManualDeepLinking = false; */ public static allowManualDeepLinking:boolean = true; /** * The **Router.forceSlash** property tells the Router class if the **Router.{{#crossLink "Router/navigateTo:method"}}{{/crossLink}}** method is called to * make sure the hash url has a forward slash after the **#** character like this **#/**. * * @property forceSlash * @type {boolean} * @default false * @public * @static * @example * // To turn on forcing the forward slash * Router.forceSlash = true; * * // If forceSlash is set to true it will change the url from #contact/bob/ to #/contact/bob/ * // when using the navigateTo method. */ public static forceSlash:boolean = false; /** * The **Router.allowMultipleMatches** property tells the Router class if it should trigger one or all routes that match a route pattern. * * @property allowMultipleMatches * @type {boolean} * @default true * @public * @static * @example * // Only allow the first route matched to be triggered. * Router.allowMultipleMatches = false; */ public static allowMultipleMatches:boolean = true; constructor() { throw new Error('[Router] Do not instantiate the Router class because it is a static class.'); } /** * The **Router.add** method allows you to listen for route patterns to be matched. When a match is found the callback will be executed passing a {{#crossLink "RouterEvent"}}{{/crossLink}}. * * @method add * @param routePattern {string} The string pattern you want to have match, which can be any of the following combinations {}, ::, *, ?, ''. See the examples below for more details. * @param callback {Function} The function that should be executed when a request matches the routePattern. It will receive a {{#crossLink "RouterEvent"}}{{/crossLink}} object. * @param callbackScope {any} The scope of the callback function that should be executed. * @public * @static * @example * // Example of adding a route listener and the function callback below. * Router.add('/games/{gameName}/:level:/', this._method, this); * * // The above route listener would match the below url: * // www.site.com/#/games/asteroids/2/ * * // The Call back receives a RouterEvent object. * _onRouteHandler(routerEvent) { * console.log(routerEvent.params); * } * * Route Pattern Options: * ---------------------- * **:optional:** The two colons **::** means a part of the hash url is optional for the match. The text between can be anything you want it to be. * * Router.add('/contact/:name:/', this._method, this); * * // Will match one of the following: * // www.site.com/#/contact/ * // www.site.com/#/contact/heather/ * // www.site.com/#/contact/john/ * * * **{required}** The two curly brackets **{}** means a part of the hash url is required for the match. The text between can be anything you want it to be. * * Router.add('/product/{productName}/', this._method, this); * * // Will match one of the following: * // www.site.com/#/product/shoes/ * // www.site.com/#/product/jackets/ * * * **\*** The asterisk character means it will match all or part of part the hash url. * * Router.add('*', this._method, this); * * // Will match one of the following: * // www.site.com/#/anything/ * // www.site.com/#/matches/any/hash/url/ * // www.site.com/#/really/it/matches/any/and/all/hash/urls/ * * * **?** The question mark character means it will match a query string for the hash url. * * Router.add('?', this._method, this); * * // Will match one of the following: * // www.site.com/#/?one=1&two=2&three=3 * // www.site.com/#?one=1&two=2&three=3 * * * **''** The empty string means it will match when there are no hash url. * * Router.add('', this._method, this); * Router.add('/', this._method, this); * * // Will match one of the following: * // www.site.com/ * // www.site.com/#/ * * * Other possible combinations but not limited too: * * Router.add('/games/{gameName}/:level:/', this._method1, this); * Router.add('/{category}/blog/', this._method2, this); * Router.add('/home/?', this._method3, this); * Router.add('/about/*', this._method4, this); * */ public static add(routePattern:string, callback:Function, callbackScope:any):void { Router.enable(); const route:Route = new Route(routePattern, callback, callbackScope); Router._routes.push(route); } /** * The **Router.remove** method will remove one of the added routes. * * @method remove * @param routePattern {string} Must be the same string pattern you pasted into the {{#crossLink "Router/add:method"}}{{/crossLink}} method. * @param callback {Function} Must be the same function you pasted into the {{#crossLink "Router/add:method"}}{{/crossLink}} method. * @param callbackScope {any} Must be the same scope off the callback pattern you pasted into the {{#crossLink "Router/add:method"}}{{/crossLink}} method. * @public * @static * @example * // Example of adding a route listener. * Router.add('/games/{gameName}/:level:/', this._method, this); * * // Example of removing the same added route listener above. * Router.remove('/games/{gameName}/:level:/', this._method, this); */ public static remove(routePattern:string, callback:Function, callbackScope:any):void { let route:Route; // Since we are removing (splice) from routes we need to check the length every iteration. for (let i = Router._routes.length - 1; i >= 0; i--) { route = Router._routes[i]; if (route.routePattern === routePattern && route.callback === callback && route.callbackScope === callbackScope) { Router._routes.splice(i, 1); } } } /** * The **Router.addDefault** method is meant to trigger a callback function if there are no route matches are found. * * @method addDefault * @param callback {Function} * @param callbackScope {any} * @public * @static * @example * Router.addDefault(this._noRoutesFoundHandler, this); */ public static addDefault(callback:Function, callbackScope:any):void { Router._defaultRoute = new Route('', callback, callbackScope); } /** * The **Router.removeDefault** method will remove the default callback that was added by the **Router.addDefault** method. * * @method removeDefault * @public * @static * @example * Router.removeDefault(); */ public static removeDefault():void { Router._defaultRoute = null; } /** * Gets the current hash url minus the # or #! symbol(s). * * @method getHash * @public * @static * @return {string} Returns current hash url minus the # or #! symbol(s). * @example * let str = Router.getHash(); */ public static getHash():string { const hash:string = Router._window.location.hash; const strIndex:number = (hash.substr(0, 2) === '#!') ? 2 : 1; return hash.substring(strIndex); // Return everything after # or #! } /** * The **Router.enable** method will allow the Router class to listen for the hashchange event. By default the Router class is enabled. * * @method enable * @public * @static * @example * Router.enable(); */ public static enable():void { if (Router.isEnabled === true) { return; } if (Router._window.addEventListener) { Router._window.addEventListener('hashchange', Router._onHashChange, false); Router._window.addEventListener('popstate', Router._onHistoryChange, false); } else { Router._window.attachEvent('onhashchange', Router._onHashChange); Router._window.attachEvent('onpopstate', Router._onHistoryChange); } Router.isEnabled = true; } /** * The **Router.disable** method will stop the Router class from listening for the hashchange event. * * @method disable * @public * @static * @example * Router.disable(); */ public static disable():void { if (Router.isEnabled === false) { return; } if (Router._window.removeEventListener) { Router._window.removeEventListener('hashchange', Router._onHashChange); Router._window.removeEventListener('popstate', Router._onHistoryChange); } else { Router._window.detachEvent('onhashchange', Router._onHashChange); Router._window.detachEvent('onpopstate', Router._onHistoryChange); } Router.isEnabled = false; } /** * The **Router.start** method is meant to trigger or check the hash url on page load. * Either you can call this method after you add all your routers or after all data is loaded. * It is recommend you only call this once per page or application instantiation. * * @method start * @public * @static * @example * // Example of adding routes and calling the start method. * Router.add('/games/{gameName}/:level:/', this._method1, this); * Router.add('/{category}/blog/', this._method2, this); * * Router.start(); */ public static start():void { Router.forceHashRouting = (window.history && window.history.pushState) ? Router.forceHashRouting : true; if (Router.forceHashRouting === true) { setTimeout(Router._onHashChange, 1); } else { setTimeout(Router._onHistoryChange, 1); } } /** * The **Router.navigateTo** method allows you to change the hash url and to trigger a route * that matches the string value. The second parameter is **silent** and is **false** by * default. This allows you to update the hash url without causing a route callback to be * executed. * * @method navigateTo * @param route {String} * @param [silent=false] {Boolean} * @param [disableHistory=false] {Boolean} * @public * @static * @example * // This will update the hash url and trigger the matching route. * Router.navigateTo('/games/asteroids/2/'); * * // This will update the hash url but will not trigger the matching route. * Router.navigateTo('/games/asteroids/2/', true); * * // This will not update the hash url but will trigger the matching route. * Router.navigateTo('/games/asteroids/2/', true, true); */ public static navigateTo(route, silent:boolean = false, disableHistory:boolean = false):void { if (Router.isEnabled === false) { return; } if (route.charAt(0) === '#') { const strIndex = (route.substr(0, 2) === '#!') ? 2 : 1; route = route.substring(strIndex); } if (Router.forceHashRouting === true) { // Enforce starting slash if (route.charAt(0) !== '/' && Router.forceSlash === true) { route = '/' + route; } if (disableHistory === true) { Router._changeRoute(route); return; } if (Router.useDeepLinking === true) { if (silent === true) { Router.disable(); setTimeout(function () { window.location.hash = route; setTimeout(Router.enable, 1); }, 1); } else { setTimeout(function () { window.location.hash = route; }, 1); } } else { Router._changeRoute(route); } } else { Router._lastHistoryState = window.history.state; if (Router.useDeepLinking === true) { window.history.pushState({ route: route }, null, route); } Router._changeRoute(route); } } /** * The **Router.clear** will remove all route's and the default route from the Router class. * * @method clear * @public * @static * @example * Router.clear(); */ public clear():void { Router._routes = []; Router._defaultRoute = null; Router._hashChangeEvent = null; } /** * The **Router.destroy** method will null out all references to other objects in the Router class. * * @method destroy * @public * @static * @example * Router.destroy(); */ public destroy():void { Router._window = null; Router._routes = null; Router._defaultRoute = null; Router._hashChangeEvent = null; } /** * A simple helper method to create a url route from an unlimited number of arguments. * * @method buildRoute * @param ...rest {...rest} * @return {string} * @public * @static * @example * const someProperty = 'api/endpoint'; * const queryObject = {type: 'car', name: encodeURIComponent('Telsa Motors')}; * * Router.buildRoute(someProperty, 'path', 7, queryObject); * * //Creates 'api/endpoint/path/7?type=car&name=Telsa%20Motors' */ public static buildRoute(...rest):string { let clone = rest.slice(0); clone.forEach((value, index) => { if (typeof value === 'object') { clone[index] = `?${StringUtil.toQueryString(value)}`; } }); // Remove any empty strings from the array clone = clone.filter(Boolean); let route = clone.join('/'); // Remove extra back slashes route = route.replace(/\/\//g, '/'); // Add back slash since we remove it from the "http://" route = route.replace(':/', '://'); // Remove the back slash in front of a question mark route = route.replace('/?', '?'); return route } /** * Returns the current router event that was last triggered. * * @method getCurrentRoute * @public * @static * @example * Router.getCurrentRoute(); */ public static getCurrentRoute():RouterEvent { return this._currentRoute; } /** * TODO: YUIDoc_comment * * @method validate * @param func {Function} The function you wanted called if the validation failed. * @public * @static * @example * Router.validate((routerEvent, next) => { * const allowRouteChange = this._someMethodCheck(); * * if (allowRouteChange == false) { * next(() => { * // Do something here. * // For example you can call Router.navigateTo to change the route. * }); * } else { * next(); * } * }); */ public static validate(func:Function):void { Router._validators.push(func); } /** * This method will be called if the Window object dispatches a HashChangeEvent. * This method will not be called if the Router is disabled. * * @method _onHashChange * @param event {HashChangeEvent} * @private * @static */ private static _onHashChange(event):void { if (Router.allowManualDeepLinking !== false && Router.useDeepLinking !== false) { Router._hashChangeEvent = event; var hash = Router.getHash(); Router._changeRoute(hash); } else { Router._changeRoute(''); } } /** * This method will be called if the Window object dispatches a popstate event. * This method will not be called if the Router is disabled. * * @method _onHistoryChange * @param event {HashChangeEvent} * @private * @static */ private static _onHistoryChange(event) { if (Router.forceHashRouting === true) { return; } if (Router.allowManualDeepLinking !== false && Router.useDeepLinking !== false) { if (event != null) { const state:any = event.state; Router._changeRoute(state.route); } else { const route = location.pathname + location.search + location.hash; if (Router.useDeepLinking === true) { window.history.replaceState({ route: route }, null, null); } Router._changeRoute(route); } } else { Router._changeRoute(''); } } /** * The method is responsible for check if one of the routes matches the string value passed in. * * @method _changeRoute * @param hash {string} * @private * @static */ private static _changeRoute(hash:string):void { let route:Route; let match:any; let routerEvent:RouterEvent = null; // Loop through all the route's. Note: we need to check the length every loop in case one was removed. for (let i = 0; i < Router._routes.length; i++) { route = Router._routes[i]; match = route.match(hash); // If there is a match. if (match !== null) { routerEvent = new RouterEvent(); routerEvent.route = match.shift(); routerEvent.params = match.slice(0, match.length); routerEvent.routePattern = route.routePattern; routerEvent.query = (hash.indexOf('?') > -1) ? StringUtil.queryStringToObject(hash) : null; routerEvent.target = Router; routerEvent.currentTarget = Router; // Remove any empty strings in the array due to the :optional: route pattern. // Since we are removing (splice) from params we need to check the length every iteration. for (let j = routerEvent.params.length - 1; j >= 0; j--) { if (routerEvent.params[j] === '') { routerEvent.params.splice(j, 1); } } // If there was a hash change event then set the info we want to send. if (Router._hashChangeEvent != null) { routerEvent.newURL = Router._hashChangeEvent.newURL; routerEvent.oldURL = Router._hashChangeEvent.oldURL; } else if (window.history && window.history.state) { routerEvent.newURL = hash; routerEvent.oldURL = (Router._lastHistoryState === null) ? null : Router._lastHistoryState.route; Router._lastHistoryState = { route: routerEvent.newURL } } else { routerEvent.newURL = window.location.href; } const allowRouteChange:boolean = Router._allowRouteChange(routerEvent); if (allowRouteChange === true) { Router._currentRoute = routerEvent; // Execute the callback function and pass the route event. route.callback.call(route.callbackScope, routerEvent); // Only trigger the first route and stop checking. if (Router.allowMultipleMatches === false) { break; } } else { break; } } } // If there are no route's matched and there is a default route. Then call that default route. if (routerEvent === null) { routerEvent = new RouterEvent(); routerEvent.route = hash; routerEvent.query = (hash.indexOf('?') > -1) ? StringUtil.queryStringToObject(hash) : null; routerEvent.target = Router; routerEvent.currentTarget = Router; if (Router._hashChangeEvent != null) { routerEvent.newURL = Router._hashChangeEvent.newURL; routerEvent.oldURL = Router._hashChangeEvent.oldURL; } else { routerEvent.newURL = window.location.href; } const allowRouteChange:boolean = Router._allowRouteChange(routerEvent); if (allowRouteChange === true) { Router._currentRoute = routerEvent; if (Router._defaultRoute !== null) { Router._defaultRoute.callback.call(Router._defaultRoute.callbackScope, routerEvent); } } } Router._hashChangeEvent = null; if (Router._validatorFunc != null) { Router._validatorFunc(); } } /** * TODO: YUIDoc_comment * * @method _allowRouteChange * @private * @static */ private static _allowRouteChange(routerEvent:RouterEvent):boolean { Router._validatorFunc = null; for (let i:number = 0; i < Router._validators.length; i++) { const func:Function = Router._validators[i]; if (Router._validatorFunc != null) { break; } const callback:Function = (back:Function = null) => { Router._validatorFunc = back; }; func(routerEvent, callback); } return Router._validatorFunc == null; } } export default Router;