/web-component-router

Framework independent router for web-components based apps

Primary LanguageJavaScript

@jack-henry/web-component-router

Router for web-components based apps. The router creates a dom node tree and assigns attributes to the nodes based on path segments. When switching between routes, the router will re-use any element in common between the trees and simply update the attributes of existing elements. Elements should change/reset their state based solely off of attributes.

By default, the router places child elements as the sole light-dom child of the parent (all other nodes are removed). Elements can override the routeEnter method functionality to customize this behavior.

Installation

npm install @jack-henry/web-component-router

Defining Routes

The router uses Page.js internally for route path definitions and callbacks. You must start by defining your routes and route tree.

To create a tree you create RouteTreeNodes and add children.

import RouteTreeNode from '@jack-henry/web-component-router/lib/route-tree-node.js';
const routeNode = new RouteTreeNode(data);

Each node requires a RouteData object to describe it.

import RouteData from '@jack-henry/web-component-router/lib/route-data.js';
/**
 * @param {string} name of this route. Must be unique.
 * @param {string} tagName of the element. Case insensitive.
 * @param {string} path of this route (express style).
 *     Empty strings indicate abstract routes - they are not
 *     directly routable. Their callbacks are invoked and elements
 *     created when a child route is activated.
 * @param {!Array<string>=} namedParameters array in camelCase (optional).
 *     These should match to named path segments. Each camel case name
 *     is converted to a hyphenated name to be assigned to the element.
 * @param {boolean=} requiresAuthentication (optional - defaults true)
 */
const routeData = new RouteData(
    'Name of this route',
    'tag-name',
    '/path/:namedParameter',
    ['namedParameter'], // becomes attribute named-parameter="value"
    true);

It is recommended to use enums and module imports to define the paths and ids so the strings are maintainable.

Example Routing Configuration

/**
 * @fileoverview
 *
 * Route tree definition
 *
 *           ___APP-ELEMENT___
 *          /                 \
 *     LOGIN-PAGE     ___MAIN-LAYOUT_____
 *                   /                   \
 *           MAIN-DASHBOARD         DETAIL-VIEW
 */

import {RouteData, RouteTreeNode} from '@jack-henry/web-component-router';

const dashboard = new RouteTreeNode(
    new RouteData('MainDashboard', 'MAIN-DASHBOARD', '/'));

const detailView = new RouteTreeNode(
    new RouteData('DetailView', 'DETAIL-VIEW', '/detail/:viewId', ['viewId']));

// This is an abstract route - you can't visit it directly.
// However it is part of the dom tree
const mainLayout = new RouteTreeNode(
    new RouteData('MainLayout', 'MAIN-LAYOUT', ''));

mainLayout.addChild(dashboard);
mainLayout.addChild(detailView);

// This is an abstract route - you can't visit it directly.
// However it is part of the dom tree
// It also does not require authentication to view
const app = new RouteTreeNode(
    new RouteData('App', 'APP-ELEMENT', '', [], false));

app.addChild(mainLayout);

const loginPage = new RouteTreeNode(
    new RouteData('Login', 'LOGIN-PAGE', '/login', [], false));

app.addChild(loginPage);

export default app;

Defining a route configuration in the Router's constructor

Alternatively you can pass a routeConfig object when instantiating your router. This will use the RouteTreeNode and RouteData to create your applications routeTree.

Example RouteConfig object

const routeConfig = {
    id: 'app',
    tagName: 'APP-MAIN',
    path: '',
    subRoutes: [{
        id: 'app-user',
        tagName: 'APP-USER-PAGE',
        path: '/users/:userId([0-9]{1,6})',
        params: ['userId'],
    }, {
        id: 'app-user-account',
        tagName: 'APP-ACCOUNT-PAGE',
        path: '/users/:userId([0-9]{1,6})/accounts/:accountId([0-9]{1,6})',
        params: ['userId', 'accountId'],
    }, {
      id: 'app-about',
      tagName: 'APP-ABOUT',
      path: '/about',
      authenticated: false,
    }]
};

const router = New Router(routeConfig);

When using this method the default is that a route requires authentication, as shown above in the 'about' route, set authenticated to false to create a route which does not require authentication.

Redirecting

To programmatically redirect to a page, use router.go():

// Basic redirect; goes to the root page.
router.go('/');

// Specifies a value for named parameter in the path.
// NOTE: You must quote the properties so that Closure Compiler does not rename them!
router.go('/detail/:viewId', {'viewId': id});

// Adds a query parameter to the URL.
// NOTE: You must quote the properties so that Closure Compiler does not rename them!
router.go('/login', {'redirect': destAfterLogin});

Note: router.go usage can quickly become an anti pattern. Using proper HTML anchors with hrefs is preferable. router.go should only be used when programatic route changes are strictly required.

Creating Routing Enabled Components

Components used with the router are expected to define two methods which take the same arguments:

class MyElement extends HtmlElement {
  /**
   * Implementation for the callback on entering a route node.
   * routeEnter is called for EVERY route change. If the node
   * is shared between the old and new routes, the element
   * will be re-used but have attributes updated here.
   *
   * @param {!RouteTreeNode} currentNode
   * @param {!RouteTreeNode|undefined} nextNodeIfExists - the
   *     child node of this route.
   * @param {string} routeId - unique name of the route
   * @param {!Context} context - page.js Context object
   * @return {!Promise<boolean=>}
   */
  async routeEnter(currentNode, nextNodeIfExists, routeId, context) {
    // make sure to set this to indicate the route was recognized.
    context.handled = true;
    // do something with the node
    const currentElement = currentNode.getValue().element;
  }

  /**
   * Implementation for the callback on exiting a route node.
   * This method is ONLY called if this element is not being
   * used by the next route destination.
   *
   * @param {!RouteTreeNode} currentNode
   * @param {!RouteTreeNode|undefined} nextNode - parent node
   * @param {string} routeId - unique name of the route
   * @param {!Context} context - page.js Context object
   */
  async routeExit(currentNode, nextNode, routeId, context) {
    const currentElement = currentNode.getValue().element;

    // remove the element from the dom
    if (currentElement.parentNode) {
      currentElement.parentNode.removeChild(/** @type {!Element} */ (currentElement));
    }
    currentNode.getValue().element = undefined;
  }
}

Most elements will either use (or inherit) the default implementations. Two mixins are provided to make this easy. When using the mixin, routeEnter and routeExit methods are only need defined when the default behavior needs modified. In most cases any overridden method should do minimal work and call super.routeEnter or super.routeExit.

Standard Routing Mixin

import routeMixin from '@jack-henry/web-component-router/routing-mixin.js';
class MyElement extends routeMixin(HTMLElement) { }

Animated Routing Mixin

The animated mixin applies a class to animated a node tree on entrance. Exit animations are currently not supported.

import animatedRouteMixin from '@jack-henry/web-component-router/animated-routing-mixin.js';
class MyElement extends animatedRouteMixin(HTMLElement, 'className') { }

Root App Element

The routing configuration is typically defined inside the main app element which should be defined as the root node of the routing tree.

The root element typically has a slightly different configuration.

import myAppRouteTree from './route-tree.js';
import router, {Context, routingMixin} from '@jack-henry/web-component-router';

class AppElement extends routingMixin(Polymer.Element) {
  static get is() { return 'app-element'; }

  connectedCallback() {
    super.connectedCallback();

    router.routeTree = myAppRouteTree;
    // Define this instance as the root element
    router.routeTree.getValue().element = this;

    // Start routing
    router.start();
  }

  async routeEnter(currentNode, nextNodeIfExists, routeId, context) {
    context.handled = true;
    const destinationNode = router.routeTree.getNodeByKey(routeId);
    if (isAuthenticated || !destinationNode.requiresAuthentication()) {
      // carry on. user is authenticated or doesn't need to be.
      return super.routeEnter(currentNode, nextNodeIfExists, routeId, context);
    }

    // Redirect to the login page
    router.go('/login');

    // Don't continue routing - we have redirected to the
    // login page
    return false;
  }

  async routeExit(currentNode, nextNode, routeId, context) {
    // This method should never be called. The main app element
    // should never be on an exit path as it should always be in
    // common no matter what route is activated.
  }
}

Saving Scroll Position

When using the back button for navigation, the previous route scroll position should be preserved. To accomplish this, we use a global page.js exit callback. However, care must be taken as saving the scroll position should only occur on normal navigation. Back/Forward browser navigation should not save the scroll position as it causes a timing issue.

import myAppRouteTree from './route-tree.js';
import router, {routingMixin} from '@jack-henry/web-component-router';

class AppElement extends routingMixin(Polymer.Element) {
  static get is() { return 'app-element'; }

  connectedCallback() {
    super.connectedCallback();

    router.routeTree = myAppRouteTree;
    // Define this instance as the root element
    router.routeTree.getValue().element = this;

    // Save the scroll position for every route exit
    router.addGlobalExitHandler(this.saveScrollPosition_.bind(this));

    // Start routing
    router.start();
  }

  /**
   * @param {!Context} context
   * @param {function(boolean=)} next
   * @private
   */
  saveScrollPosition_(context, next) {
    if (!(router.nextStateWasPopped || 'scrollTop' in context.state)) {
      context.state['scrollTop'] = this.scrollTop;
      context.save();
    }
    next();
  }

  async routeEnter(currentNode, nextNodeIfExists, routeId, context) {
    // Restoring the scroll position needs to be async
    setTimeout(() => {
      this.scrollTop = context.state['scrollTop'] || 0;
    }, 0);
    return super.routeEnter(currentNode, nextNodeIfExists, routeId, context);
  }
}

Router Reference

/**
 * Get or define the routing tree
 * @type {!RouteTreeNode}
 */
router.routeTree;

/**
 * Get the current active route node
 * @type {string}
 */
router.currentNodeId;

/**
 * Get the previous active route node
 * @type {string}
 */
router.prevNodeId;

/**
 * Begin routing. Should only be called once. The routing tree
 * must first be defined.
 */
router.start();

/**
 * Navigate to the specified route path.
 * @param {string} path
 * @param {object=} params Values to use for named & query parameters
 */
router.go(path, params);

/**
 * Register an exit callback to be invoked on every route change
 * @param {function(!Context, function(boolean=))} callback
 */
router.addGlobalExitHandler(callback);

/**
 * Register a callback function which will be invoked when a route change
 * is initiated.
 * @param {!Function} callback
 */
router.addRouteChangeStartCallback(callback);

/**
 * Unregister a callback function
 * @param {!Function} callback
 */
router.removeRouteChangeStartCallback(callback);

/**
 * Register a callback function which will be invoked when a route change
 * is completed.
 * @param {!Function} callback
 */
router.addRouteChangeCompleteCallback(callback);

/**
 * Unregister a callback function
 * @param {!Function} callback
 */
router.removeRouteChangeCompleteCallback(callback);

/**
 * Anonymize the route path by replacing param values with their
 * param name. Used for analytics tracking
 *
 * @param {!Context} context route enter context
 * @return {!string}
 */
const urlPath = router.getRouteUrlWithoutParams(context);