/router-slot

A powerful web component router.

Primary LanguageTypeScriptMIT LicenseMIT

router-slot

Downloads per month NPM Version Dependencies Contributors Published on webcomponents.org

A powerful web component router
A router interprets the browser URL and navigates to a specific views based on the configuration. This router is optimized for routing between web components. If you want to play with it yourself, go to the playground. Go here to see a demo https://appnest-demo.firebaseapp.com/router-slot.


  • 😴 Lazy loading of routes
  • 🎁 Web component friendly
  • 📡 Easy to use API
  • 🛣 Specify params in the path
  • 👌 Zero dependencies
  • 📚 Uses the history API
  • 🎉 Supports routes for dialogs
  • 🛡 Supports guards for routes
  • ⚓️ Allows the anchor element for navigating
  • ⚙️ Very customizable
  • 🤐 2kb gzipped
📖 Table of Contents

-----------------------------------------------------

➤ Table of Contents

-----------------------------------------------------

➤ Installation

npm i router-slot

-----------------------------------------------------

➤ Usage

This section will introduce how to use the router. If you hate reading and love coding you can go to the playgroud to try it for yourself.

1. Add <base href="/">

To turn your app into a single-page-application you first need to add a <base> element to the index.html in the <head>. If your file is located in the root of your server, the href value should be the following:

<base href="/">

2. Import the router

To import the library you'll need to import the dependency in your application.

import "router-slot";

3. Add the <router-slot> element

The router-slot component acts as a placeholder that marks the spot in the template where the router should display the components for that route part.

<router-slot>
  <!-- Routed components will go here -->
</router-slot>

4. Configure the router

Routes are added to the router through the add function on a router-slot component. Specify the parts of the path you want it to match with or use the ** wildcard to catch all paths. The router has no routes until you configure it. The example below creates three routes. The first route path matches urls starting with login and will lazy load the login component. Remember to export the login component as default in the ./pages/login file like this export default LoginComponent extends HTMLElement { ... }. The second route matches all urls starting with home and will stamp the HomeComponent in the router-slot. The third route matches all paths that the two routes before didn't catch and redirects to home. This can also be useful for displaying "404 - Not Found" pages.

const routerSlot = document.querySelector("router-slot");
await routerSlot.add([
  {
    path: "login",
    component: () => import("./path/to/login/component") // Lazy loaded
  },
  {
    path: "home",
    component: HomeComponent // Not lazy loaded
  },
  {
    path: "**",
    redirectTo: "home"
  }
]);

You may want to wrap the above in a whenDefined callback to ensure the router-slot exists before using its logic.

customElements.whenDefined("router-slot").then(async () => {
  ...
});

5. Navigate using the history API, anchor tag or the <router-link> component

In order to change a route you can either use the history API, use an anchor element or use the router-link component.

History API

To push a new state into the history and change the URL you can use the .pushState(...) function on the history object.

history.pushState(null, "", "/login");

If you want to replace the current URL with another one you can use the .replaceState(...) function on the history object instead.

history.replaceState(null, "", "/login");

You can also go back and forth between the states by using the .back() and .forward() functions.

history.back();
history.forward();

Go here to read more about the history API.

Anchor element

Normally an anchor element reloads the page when clicked. This library however changes the default behavior of all anchor element to use the history API instead.

<a href="/home">Go to home!</a>

There are many advantages of using an anchor element, the main one being accessibility.

Alternatively, if you would still like to allow relative links to other parts of your site to navigate as normally, you can opt out of this behavior on a link-by-link basis:

<a href="/about" data-router-slot="disabled">Go to about!</a>

router-link

With the router-link component you add <router-link> to your markup and specify a path. Whenever the component is clicked it will navigate to the specified path. Whenever the path of the router link is active the active attribute is set.

<router-link path="login">
  <button>Go to login page!</button>
</router-link>

Paths can be specified either in relative or absolute terms. To specify an absolute path you simply pass /home/secret. The slash makes the URL absolute. To specify a relative path you first have to be aware of the router-slot context you are navigating within. The router-link component will navigate based on the nearest parent router-slot element. If you give the component a path (without the slash), the navigation will be done in relation to the parent router-slot. You can also specify ../login to traverse up the router tree.

6. Putting it all together

So to recap the above steps, here's how to use the router.

<html>
  <head>
    <base href="/" />
  </head>
  <body>
    <router-slot></router-slot>

    <a href="/home">Go to home</a>
    <a href="/login">Go to login</a>

    <script type="module">
        import "https://unpkg.com/router-slot?module";
        customElements.whenDefined("router-slot").then(async () => {
            const routerSlot = document.querySelector("router-slot");
            await routerSlot.add([
              {
                path: "login",
                component: () => import("./path/to/login-component") 
              },
              {
                path: "home",
                component: () => import("./path/to/home-component") 
              },
              {
                path: "**",
                redirectTo: "home"
              }
            ]);
        });
    </script>
  </body>
</html>

-----------------------------------------------------

lit

The router-slot works very well with lit. Check out the example below to get an idea on how you could use this router in your own lit based projects.

import { LitElement, html, PropertyValues } from "lit";
import { query, customElement } from "lit/decorators.js";
import { RouterSlot } from "router-slot";

const ROUTES = [
 {
   path: "login",
   component: () => import("./pages/login")
 },
 {
   path: "home",
   component: () => import("./pages/home")
 },
 {
   path: "**",
   redirectTo: "home"
 }
];

@customElement("app-component");
export class AppComponent extends LitElement {
  @query("router-slot") $routerSlot!: RouterSlot;

  firstUpdated (props: PropertyValues) {
    super.firstUpdated(props);
    this.$routerSlot.add(ROUTES);
  }

  render () {
    return html`<router-slot></router-slot>`;
  }
}

-----------------------------------------------------

➤ Advanced

You can customize a lot in this library. Continue reading to learn how to handle your new superpowers.

Guards

A guard is a function that determines whether the route can be activated or not. The example below checks whether the user has a session saved in the local storage and redirects the user to the login page if the access is not provided. If a guard returns false the routing is cancelled.

function sessionGuard () {

  if (localStorage.getItem("session") == null) {
    history.replaceState(null, "", "/login");
    return false;
  }

  return true;
}

Add this guard to the add function in the guards array.

...
await routerSlot.add([
  ...
  {
    path: "home",
    component: HomeComponent,
    guards: [sessionGuard]
  },
  ...
]);

Dialog routes

Sometimes you wish to change the url without triggering the route change. This could for example be when you want an url for your dialog. To change the route without triggering the route change you can use the functions on the native object on the history object. Below is an example on how to show a dialog without triggering the route change.

history.native.pushState(null, "", "dialog");
alert("This is a dialog");
history.native.back();

This allow dialogs to have a route which is especially awesome on mobile.

Params

If you want params in your URL you can do it by using the :name syntax. Below is an example on how to specify a path that matches params as well. This route would match urls such as user/123, user/@andreas, user/abc and so on. The preferred way of setting the value of the params is by setting it through the setup function.

await routerSlot.add([
  {
    path: "user/:userId",
    component: UserComponent,
    setup: (component: UserComponent, info: RoutingInfo) => {
      component.userId = info.match.params.userId;
    }
  }
]);

Alternatively you can get the params in the UserComponent by using the queryParentRouterSlot(...) function.

import { LitElement, html } from "lit";
import { Params, queryParentRouterSlot } from "router-slot";

export default class UserComponent extends LitElement {

  get params (): Params {
    return queryParentRouterSlot(this)!.match!.params;
  }

  render () {
    const {userId} = this.params;
    return html`
      <p>:userId = <b>${userId}</b></p>
    `;
  }
}

customElements.define("user-component", UserComponent);

Deep dive into the different route kinds

There exists three different kinds of routes. We are going to take a look at those different kinds in a bit, but first you should be familiar with what all routes have in common.

export interface IRouteBase<T = any> {

  // The path for the route fragment
  path: PathFragment;

  // Optional metadata
  data?: T;

  // If guard returns false, the navigation is not allowed
  guards?: Guard[];

  // The type of match.
  // - If "prefix" router-slot will try to match the first part of the path.
  // - If "suffix" router-slot will try to match the last part of the path.
  // - If "full" router-slot will try to match the entire path.
  // - If "fuzzy" router-slot will try to match an arbitrary part of the path.
  pathMatch?: PathMatch;
}

Component routes

Component routes resolves a specified component. You can provide the component property with either a class that extends HTMLElement, a module that exports the web component as default or a DOM element. These three different ways of doing it can be done lazily by returning it a function instead.

export interface IComponentRoute extends IRouteBase {

  // The component loader (should return a module with a default export if it is a module)
  component: Class | ModuleResolver | PageComponent | (() => Class) | (() => PageComponent) | (() => ModuleResolver);

  // A custom setup function for the instance of the component.
  setup?: Setup;
}

Here's an example on how that could look in practice.

routerSlot.add([
  {
    path: "home",
    component: HomeComponent
  },
  {
    path: "terms",
    component: () => import("/path/to/terms-module")
  },
  {
    path: "login",
    component: () => {
      const $div = document.createElement("div");
      $div.innerHTML = `🔑 This is the login page`;
      return $div;
    }
  },
  {
    path: "video",
    component: document.createElement("video")
  }
]);

Redirection routes

A redirection route is good to use to catch all of the paths that the routes before did not catch. This could for example be used to handle "404 - Page not found" cases.

export interface IRedirectRoute extends IRouteBase {

  // The paths the route should redirect to. Can either be relative or absolute.
  redirectTo: string;

  // Whether the query should be preserved when redirecting.
  preserveQuery?: boolean;
}

Here's an example on how that could look in practice.

routerSlot.add([
  ...
  {
    path: "404",
    component: document.createTextNode(`404 - The page you are looking for wasn't found.`)
  }
  {
    path: "**",
    redirectTo: "404",
    preserveQuery: true
  }
]);

Resolver routes

Use the resolver routes when you want to customize what should happen when the path matches the route. This is good to use if you for example want to show a dialog instead of navigating to a new component. If the custom resolver returns false the navigation will be cancelled.

export interface IResolverRoute extends IRouteBase {

  // A custom resolver that handles the route change
  resolve: CustomResolver;
}

Here's an example on how that could look in practice.

routerSlot.add([
  {
    path: "home",
    resolve: (info: RoutingInfo) => {
      const $page = document.createElement("div");
      $page.appendChild(document.createTextNode("This is a custom home page!"));

      // You can for example add the page to the body instead of the
      // default behavior where it is added to the router-slot.
      // If you want a router-slot inside the element you are adding here
      // you need to set the parent of that router-slot to info.slot.
      document.body.appendChild($page);
    })
  }
]);

Stop the user from navigating away

Let's say you have a page where the user has to enter some important data and suddenly he/she clicks on the back button! Luckily you can cancel the the navigation before it happens by listening for the willchangestate event on the window object and calling preventDefault() on the event.

window.addEventListener("willchangestate", e => {

  // Check if we should navigate away from this page
  if (!confirm("You have unsafed data. Do you wish to discard it?")) {
    e.preventDefault();
    return;
  }

}, {once: true});

Helper functions

The library comes with a set of helper functions. This includes:

  • path() - The current path of the location.
  • query() - The current query as an object.
  • queryString() - The current query as a string.
  • toQuery(queryString) - Turns a query string into a an object.
  • toQueryString(query) - Turns a query object into a string.
  • slashify({ startSlash?: boolean, endSlash?: boolean; }) - Makes sure that the start and end slashes are present or not depending on the options.
  • stripSlash() - Strips the slash from the start and/or end of a path.
  • ensureSlash() - Ensures the path starts and/or ends with a slash.
  • isPathActive (path: string | PathFragment, fullPath: string = getPath()) - Determines whether the path is active compared to the full path.

Global navigation events

You are able to listen to the navigation related events that are dispatched every time something important happens. They are dispatched on the window object.

// An event triggered when a new state is added to the history.
window.addEventListener("pushstate", (e: PushStateEvent) => {
  console.log("On push state", path());
});

// An event triggered when the current state is replaced in the history.
window.addEventListener("replacestate", (e: ReplaceStateEvent) => {
  console.log("On replace state", path());
});

// An event triggered when a state in the history is popped from the history.
window.addEventListener("popstate", (e: PopStateEvent) => {
  console.log("On pop state", path());
});

// An event triggered when the state changes (eg. pop, push and replace)
window.addEventListener("changestate", (e: ChangeStateEvent) => {
  console.log("On change state", path());
});

// A cancellable event triggered before the history state changes.
window.addEventListener("willchangestate", (e: WillChangeStateEvent) => {
  console.log("Before the state changes. Call 'e.preventDefault()' to prevent the state from changing.");
});

// An event triggered when navigation starts.
window.addEventListener("navigationstart", (e: NavigationStartEvent) => {
  console.log("Navigation start", e.detail);
});

// An event triggered when navigation is canceled. This is due to a Route Guard returning false during navigation.
window.addEventListener("navigationcancel", (e: NavigationCancelEvent) => {
  console.log("Navigation cancelled", e.detail);
});

// An event triggered when navigation ends.
window.addEventListener("navigationend", (e: NavigationEndEvent) => {
  console.log("Navigation end", e.detail);
});

// An event triggered when navigation fails due to an unexpected error.
window.addEventListener("navigationerror", (e: NavigationErrorEvent) => {
  console.log("Navigation failed", e.detail);
});

// An event triggered when navigation successfully completes.
window.addEventListener("navigationsuccess", (e: NavigationSuccessEvent) => {
  console.log("Navigation failed", e.detail);
});

Scroll to the top

If you want to scroll to the top on each page change to could consider doing the following.

window.addEventListener("navigationend", () => {
  requestAnimationFrame(() => {
    window.scrollTo(0, 0);
  });
});

Style the active link

If you want to style the active link you can do it by using the isPathActive(...) function along with listning to the changestate event.

import {isPathActive} from "router-slot";

const $links = Array.from(document.querySelectorAll("a"));
window.addEventListener("changestate", () => {
  for (const $link of $links) {

    // Check whether the path is active
    const isActive = isPathActive($link.getAttribute("href"));

    // Set the data active attribute if the path is active, otherwise remove it.
    if (isActive) {
      $link.setAttribute("data-active", "");

    } else {
      $link.removeAttribute("data-active");
    }
  }
});

-----------------------------------------------------

⚠️ Be careful when navigating to the root!

From my testing I found that Chrome and Safari, when navigating, treat an empty string as url differently. As an example history.pushState(null, null, "") will navigate to the root of the website in Chrome but in Safari the path won't change. The workaround I found was to simply pass "/" when navigating to the root of the website instead.

-----------------------------------------------------

➤ Contributors

Andreas Mehlsen You?
Andreas Mehlsen You?

-----------------------------------------------------

➤ License

Licensed under MIT.