├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── blueprint.json ├── blueprint.md ├── karma.conf.js ├── package-lock.json ├── package.json ├── pre-build.js ├── rollup.config.ts ├── src ├── demo │ ├── app.ts │ ├── dialog │ │ ├── dialog.ts │ │ ├── step-one │ │ │ └── step-one.ts │ │ └── step-two │ │ │ └── step-two.ts │ ├── index.html │ └── pages │ │ ├── home │ │ ├── home.ts │ │ ├── secret │ │ │ ├── code │ │ │ │ └── code.ts │ │ │ ├── data.ts │ │ │ ├── password │ │ │ │ └── password.ts │ │ │ └── secret.ts │ │ └── user │ │ │ ├── edit │ │ │ └── edit.ts │ │ │ └── user.ts │ │ ├── login │ │ └── login.ts │ │ └── styles.ts ├── lib │ ├── config.ts │ ├── index.ts │ ├── model.ts │ ├── router-link.ts │ ├── router-slot.ts │ └── util │ │ ├── anchor.ts │ │ ├── events.ts │ │ ├── history.ts │ │ ├── index.ts │ │ ├── router.ts │ │ ├── shadow.ts │ │ └── url.ts └── test │ ├── anchor.test.ts │ ├── history.test.ts │ ├── router-slot.test.ts │ ├── router.test.ts │ ├── test-helpers.ts │ └── url.test.ts ├── tsconfig.build.json ├── tsconfig.json ├── tslint.json └── typings.d.ts /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Main Workflow 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run: 7 | name: Run 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@master 14 | 15 | - name: Set Node.js 10.x 16 | uses: actions/setup-node@master 17 | with: 18 | node-version: 10.x 19 | 20 | - name: Cache 21 | uses: actions/cache@preview 22 | id: cache 23 | with: 24 | path: node_modules 25 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.os }}-node- 28 | 29 | - name: Install 30 | if: steps.cache.outputs.cache-hit != 'true' 31 | run: npm ci 32 | 33 | - name: Test 34 | run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | .DS_Store 4 | ec2-user-key-pair.pem 5 | /tmp 6 | env.json 7 | 8 | # compiled output 9 | /dist 10 | 11 | # dependencies 12 | /node_modules 13 | /functions/node_modules 14 | 15 | # IDEs and editors 16 | /.idea 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # IDE - VSCode 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | 31 | # misc 32 | /.sass-cache 33 | /connect.lock 34 | /coverage/* 35 | /libpeerconnection.log 36 | npm-debug.log 37 | testem.log 38 | logfile 39 | 40 | # e2e 41 | /e2e/*.js 42 | /e2e/*.map 43 | 44 | #System Files 45 | .DS_Store 46 | Thumbs.db 47 | dump.rdb 48 | 49 | /compiled/ 50 | /.idea/ 51 | /.cache/ 52 | /.vscode/ 53 | *.log 54 | /logs/ 55 | npm-debug.log* 56 | /lib-cov/ 57 | /coverage/ 58 | /.nyc_output/ 59 | /.grunt/ 60 | *.7z 61 | *.dmg 62 | *.gz 63 | *.iso 64 | *.jar 65 | *.rar 66 | *.tar 67 | *.zip 68 | .tgz 69 | .env 70 | .DS_Store? 71 | ._* 72 | .Spotlight-V100 73 | .Trashes 74 | ehthumbs.db 75 | *.pem 76 | *.p12 77 | *.crt 78 | *.csr 79 | /node_modules/ 80 | /dist/ 81 | /documentation/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting any of the code of conduct enforcers: [Andreas Mehlsen](mailto:andmehlsen@gmail.com). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | You are more than welcome to contribute to `router-slot` in any way you please, including: 2 | 3 | * Updating documentation. 4 | * Fixing spelling and grammar 5 | * Adding tests 6 | * Fixing issues and suggesting new features 7 | * Blogging, tweeting, and creating tutorials about `router-slot` 8 | * Reaching out to [@AndreasMehlsen](https://twitter.com/AndreasMehlsen) on Twitter 9 | * Submit an issue or a pull request -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2018 Andreas Mehlsen andmehlsen@gmail.com 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

router-slot

2 | 3 |

4 | Downloads per month 5 | NPM Version 6 | Dependencies 7 | Contributors 8 | Published on webcomponents.org 9 |

10 | 11 | 12 |

13 | A powerful web component router
14 | 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. 15 |

16 | 17 |
18 | 19 | 20 | * 😴 Lazy loading of routes 21 | * 🎁 Web component friendly 22 | * 📡 Easy to use API 23 | * 🛣 Specify params in the path 24 | * 👌 Zero dependencies 25 | * 📚 Uses the [history API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) 26 | * 🎉 Supports routes for dialogs 27 | * 🛡 Supports guards for routes 28 | * ⚓️ Allows the anchor element for navigating 29 | * ⚙️ Very customizable 30 | * 🤐 2kb gzipped 31 | 32 |
33 | 📖 Table of Contents 34 |
35 | 36 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#table-of-contents) 37 | 38 | ## ➤ Table of Contents 39 | 40 | * [➤ Installation](#-installation) 41 | * [➤ Usage](#-usage) 42 | * [1. Add ``](#1-add-base-href) 43 | * [2. Import the router](#2-import-the-router) 44 | * [3. Add the `` element](#3-add-the-router-slot-element) 45 | * [4. Configure the router](#4-configure-the-router) 46 | * [5. Navigate using the history API, anchor tag or the `` component](#5-navigate-using-the-history-api-anchor-tag-or-the-router-link-component) 47 | * [History API](#history-api) 48 | * [Anchor element](#anchor-element) 49 | * [`router-link`](#router-link) 50 | * [6. Putting it all together](#6-putting-it-all-together) 51 | * [➤ `lit`](#-lit) 52 | * [➤ Advanced](#-advanced) 53 | * [Guards](#guards) 54 | * [Dialog routes](#dialog-routes) 55 | * [Params](#params) 56 | * [Deep dive into the different route kinds](#deep-dive-into-the-different-route-kinds) 57 | * [Component routes](#component-routes) 58 | * [Redirection routes](#redirection-routes) 59 | * [Resolver routes](#resolver-routes) 60 | * [Stop the user from navigating away](#stop-the-user-from-navigating-away) 61 | * [Helper functions](#helper-functions) 62 | * [Global navigation events](#global-navigation-events) 63 | * [Scroll to the top](#scroll-to-the-top) 64 | * [Style the active link](#style-the-active-link) 65 | * [➤ ⚠️ Be careful when navigating to the root!](#--be-careful-when-navigating-to-the-root) 66 | * [➤ Contributors](#-contributors) 67 | * [➤ License](#-license) 68 |
69 | 70 | 71 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#installation) 72 | 73 | ## ➤ Installation 74 | 75 | ```node 76 | npm i router-slot 77 | ``` 78 | 79 | 80 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#usage) 81 | 82 | ## ➤ Usage 83 | 84 | This section will introduce how to use the router. If you hate reading and love coding you can go to the [playgroud](https://codepen.io/andreasbm/pen/XWWZpvM) to try it for yourself. 85 | 86 | ### 1. Add `` 87 | 88 | To turn your app into a single-page-application you first need to add a [`` element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base) to the `index.html` in the ``. If your file is located in the root of your server, the `href` value should be the following: 89 | 90 | ```html 91 | 92 | ``` 93 | 94 | ### 2. Import the router 95 | 96 | To import the library you'll need to import the dependency in your application. 97 | 98 | ```javascript 99 | import "router-slot"; 100 | ``` 101 | 102 | ### 3. Add the `` element 103 | 104 | 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. 105 | 106 | ```html 107 | 108 | 109 | 110 | ``` 111 | 112 | ### 4. Configure the router 113 | 114 | 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. 115 | 116 | ```javascript 117 | const routerSlot = document.querySelector("router-slot"); 118 | await routerSlot.add([ 119 | { 120 | path: "login", 121 | component: () => import("./path/to/login/component") // Lazy loaded 122 | }, 123 | { 124 | path: "home", 125 | component: HomeComponent // Not lazy loaded 126 | }, 127 | { 128 | path: "**", 129 | redirectTo: "home" 130 | } 131 | ]); 132 | ``` 133 | 134 | You may want to wrap the above in a `whenDefined` callback to ensure the `router-slot` exists before using its logic. 135 | 136 | ```javascript 137 | customElements.whenDefined("router-slot").then(async () => { 138 | ... 139 | }); 140 | ``` 141 | 142 | ### 5. Navigate using the history API, anchor tag or the `` component 143 | 144 | In order to change a route you can either use the [`history API`](https://developer.mozilla.org/en-US/docs/Web/API/History), use an anchor element or use the `router-link` component. 145 | 146 | #### History API 147 | 148 | To push a new state into the history and change the URL you can use the `.pushState(...)` function on the history object. 149 | 150 | ```javascript 151 | history.pushState(null, "", "/login"); 152 | ``` 153 | 154 | If you want to replace the current URL with another one you can use the `.replaceState(...)` function on the history object instead. 155 | 156 | ```javascript 157 | history.replaceState(null, "", "/login"); 158 | ``` 159 | 160 | You can also go back and forth between the states by using the `.back()` and `.forward()` functions. 161 | 162 | ```javascript 163 | history.back(); 164 | history.forward(); 165 | ``` 166 | 167 | Go [`here`](https://developer.mozilla.org/en-US/docs/Web/API/History) to read more about the history API. 168 | 169 | #### Anchor element 170 | 171 | Normally an [`anchor element`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) reloads the page when clicked. This library however changes the default behavior of all anchor element to use the history API instead. 172 | 173 | ```html 174 | Go to home! 175 | ``` 176 | 177 | There are many advantages of using an anchor element, the main one being accessibility. 178 | 179 | 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: 180 | 181 | ```html 182 | Go to about! 183 | ``` 184 | 185 | #### `router-link` 186 | 187 | With the `router-link` component you add `` 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. 188 | 189 | ```html 190 | 191 | 192 | 193 | ``` 194 | 195 | 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. 196 | 197 | ### 6. Putting it all together 198 | 199 | So to recap the above steps, here's how to use the router. 200 | 201 | ```html 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | Go to home 210 | Go to login 211 | 212 | 232 | 233 | 234 | ``` 235 | 236 | 237 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#lit) 238 | 239 | ## ➤ `lit` 240 | 241 | 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. 242 | 243 | ```typescript 244 | import { LitElement, html, PropertyValues } from "lit"; 245 | import { query, customElement } from "lit/decorators.js"; 246 | import { RouterSlot } from "router-slot"; 247 | 248 | const ROUTES = [ 249 | { 250 | path: "login", 251 | component: () => import("./pages/login") 252 | }, 253 | { 254 | path: "home", 255 | component: () => import("./pages/home") 256 | }, 257 | { 258 | path: "**", 259 | redirectTo: "home" 260 | } 261 | ]; 262 | 263 | @customElement("app-component"); 264 | export class AppComponent extends LitElement { 265 | @query("router-slot") $routerSlot!: RouterSlot; 266 | 267 | firstUpdated (props: PropertyValues) { 268 | super.firstUpdated(props); 269 | this.$routerSlot.add(ROUTES); 270 | } 271 | 272 | render () { 273 | return html``; 274 | } 275 | } 276 | ``` 277 | 278 | 279 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#advanced) 280 | 281 | ## ➤ Advanced 282 | 283 | You can customize a lot in this library. Continue reading to learn how to handle your new superpowers. 284 | 285 | ### Guards 286 | 287 | 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. 288 | 289 | ```typescript 290 | function sessionGuard () { 291 | 292 | if (localStorage.getItem("session") == null) { 293 | history.replaceState(null, "", "/login"); 294 | return false; 295 | } 296 | 297 | return true; 298 | } 299 | ``` 300 | 301 | Add this guard to the add function in the `guards` array. 302 | 303 | ```typescript 304 | ... 305 | await routerSlot.add([ 306 | ... 307 | { 308 | path: "home", 309 | component: HomeComponent, 310 | guards: [sessionGuard] 311 | }, 312 | ... 313 | ]); 314 | ``` 315 | 316 | ### Dialog routes 317 | 318 | 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. 319 | 320 | ```javascript 321 | history.native.pushState(null, "", "dialog"); 322 | alert("This is a dialog"); 323 | history.native.back(); 324 | ``` 325 | 326 | This allow dialogs to have a route which is especially awesome on mobile. 327 | 328 | ### Params 329 | 330 | 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. 331 | 332 | ```typescript 333 | await routerSlot.add([ 334 | { 335 | path: "user/:userId", 336 | component: UserComponent, 337 | setup: (component: UserComponent, info: RoutingInfo) => { 338 | component.userId = info.match.params.userId; 339 | } 340 | } 341 | ]); 342 | ``` 343 | 344 | Alternatively you can get the params in the `UserComponent` by using the `queryParentRouterSlot(...)` function. 345 | 346 | ```typescript 347 | import { LitElement, html } from "lit"; 348 | import { Params, queryParentRouterSlot } from "router-slot"; 349 | 350 | export default class UserComponent extends LitElement { 351 | 352 | get params (): Params { 353 | return queryParentRouterSlot(this)!.match!.params; 354 | } 355 | 356 | render () { 357 | const {userId} = this.params; 358 | return html` 359 |

:userId = ${userId}

360 | `; 361 | } 362 | } 363 | 364 | customElements.define("user-component", UserComponent); 365 | ``` 366 | 367 | ### Deep dive into the different route kinds 368 | 369 | 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. 370 | 371 | ```typescript 372 | export interface IRouteBase { 373 | 374 | // The path for the route fragment 375 | path: PathFragment; 376 | 377 | // Optional metadata 378 | data?: T; 379 | 380 | // If guard returns false, the navigation is not allowed 381 | guards?: Guard[]; 382 | 383 | // The type of match. 384 | // - If "prefix" router-slot will try to match the first part of the path. 385 | // - If "suffix" router-slot will try to match the last part of the path. 386 | // - If "full" router-slot will try to match the entire path. 387 | // - If "fuzzy" router-slot will try to match an arbitrary part of the path. 388 | pathMatch?: PathMatch; 389 | } 390 | ``` 391 | 392 | #### Component routes 393 | 394 | Component routes resolves a specified component. You can provide the `component` property with either a [class that extends HTMLElement](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements), 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. 395 | 396 | ```typescript 397 | export interface IComponentRoute extends IRouteBase { 398 | 399 | // The component loader (should return a module with a default export if it is a module) 400 | component: Class | ModuleResolver | PageComponent | (() => Class) | (() => PageComponent) | (() => ModuleResolver); 401 | 402 | // A custom setup function for the instance of the component. 403 | setup?: Setup; 404 | } 405 | ``` 406 | 407 | Here's an example on how that could look in practice. 408 | 409 | ```typescript 410 | routerSlot.add([ 411 | { 412 | path: "home", 413 | component: HomeComponent 414 | }, 415 | { 416 | path: "terms", 417 | component: () => import("/path/to/terms-module") 418 | }, 419 | { 420 | path: "login", 421 | component: () => { 422 | const $div = document.createElement("div"); 423 | $div.innerHTML = `🔑 This is the login page`; 424 | return $div; 425 | } 426 | }, 427 | { 428 | path: "video", 429 | component: document.createElement("video") 430 | } 431 | ]); 432 | ``` 433 | 434 | #### Redirection routes 435 | 436 | 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. 437 | 438 | ```typescript 439 | export interface IRedirectRoute extends IRouteBase { 440 | 441 | // The paths the route should redirect to. Can either be relative or absolute. 442 | redirectTo: string; 443 | 444 | // Whether the query should be preserved when redirecting. 445 | preserveQuery?: boolean; 446 | } 447 | ``` 448 | 449 | Here's an example on how that could look in practice. 450 | 451 | ```typescript 452 | routerSlot.add([ 453 | ... 454 | { 455 | path: "404", 456 | component: document.createTextNode(`404 - The page you are looking for wasn't found.`) 457 | } 458 | { 459 | path: "**", 460 | redirectTo: "404", 461 | preserveQuery: true 462 | } 463 | ]); 464 | ``` 465 | 466 | #### Resolver routes 467 | 468 | 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. 469 | 470 | ```typescript 471 | export interface IResolverRoute extends IRouteBase { 472 | 473 | // A custom resolver that handles the route change 474 | resolve: CustomResolver; 475 | } 476 | ``` 477 | 478 | Here's an example on how that could look in practice. 479 | 480 | ```typescript 481 | routerSlot.add([ 482 | { 483 | path: "home", 484 | resolve: (info: RoutingInfo) => { 485 | const $page = document.createElement("div"); 486 | $page.appendChild(document.createTextNode("This is a custom home page!")); 487 | 488 | // You can for example add the page to the body instead of the 489 | // default behavior where it is added to the router-slot. 490 | // If you want a router-slot inside the element you are adding here 491 | // you need to set the parent of that router-slot to info.slot. 492 | document.body.appendChild($page); 493 | }) 494 | } 495 | ]); 496 | ``` 497 | 498 | ### Stop the user from navigating away 499 | 500 | 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. 501 | 502 | ```javascript 503 | window.addEventListener("willchangestate", e => { 504 | 505 | // Check if we should navigate away from this page 506 | if (!confirm("You have unsafed data. Do you wish to discard it?")) { 507 | e.preventDefault(); 508 | return; 509 | } 510 | 511 | }, {once: true}); 512 | ``` 513 | 514 | ### Helper functions 515 | 516 | The library comes with a set of helper functions. This includes: 517 | 518 | * `path()` - The current path of the location. 519 | * `query()` - The current query as an object. 520 | * `queryString()` - The current query as a string. 521 | * `toQuery(queryString)` - Turns a query string into a an object. 522 | * `toQueryString(query)` - Turns a query object into a string. 523 | * `slashify({ startSlash?: boolean, endSlash?: boolean; })` - Makes sure that the start and end slashes are present or not depending on the options. 524 | * `stripSlash()` - Strips the slash from the start and/or end of a path. 525 | * `ensureSlash()` - Ensures the path starts and/or ends with a slash. 526 | * `isPathActive (path: string | PathFragment, fullPath: string = getPath())` - Determines whether the path is active compared to the full path. 527 | 528 | ### Global navigation events 529 | 530 | 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. 531 | 532 | ```typescript 533 | // An event triggered when a new state is added to the history. 534 | window.addEventListener("pushstate", (e: PushStateEvent) => { 535 | console.log("On push state", path()); 536 | }); 537 | 538 | // An event triggered when the current state is replaced in the history. 539 | window.addEventListener("replacestate", (e: ReplaceStateEvent) => { 540 | console.log("On replace state", path()); 541 | }); 542 | 543 | // An event triggered when a state in the history is popped from the history. 544 | window.addEventListener("popstate", (e: PopStateEvent) => { 545 | console.log("On pop state", path()); 546 | }); 547 | 548 | // An event triggered when the state changes (eg. pop, push and replace) 549 | window.addEventListener("changestate", (e: ChangeStateEvent) => { 550 | console.log("On change state", path()); 551 | }); 552 | 553 | // A cancellable event triggered before the history state changes. 554 | window.addEventListener("willchangestate", (e: WillChangeStateEvent) => { 555 | console.log("Before the state changes. Call 'e.preventDefault()' to prevent the state from changing."); 556 | }); 557 | 558 | // An event triggered when navigation starts. 559 | window.addEventListener("navigationstart", (e: NavigationStartEvent) => { 560 | console.log("Navigation start", e.detail); 561 | }); 562 | 563 | // An event triggered when navigation is canceled. This is due to a Route Guard returning false during navigation. 564 | window.addEventListener("navigationcancel", (e: NavigationCancelEvent) => { 565 | console.log("Navigation cancelled", e.detail); 566 | }); 567 | 568 | // An event triggered when navigation ends. 569 | window.addEventListener("navigationend", (e: NavigationEndEvent) => { 570 | console.log("Navigation end", e.detail); 571 | }); 572 | 573 | // An event triggered when navigation fails due to an unexpected error. 574 | window.addEventListener("navigationerror", (e: NavigationErrorEvent) => { 575 | console.log("Navigation failed", e.detail); 576 | }); 577 | 578 | // An event triggered when navigation successfully completes. 579 | window.addEventListener("navigationsuccess", (e: NavigationSuccessEvent) => { 580 | console.log("Navigation failed", e.detail); 581 | }); 582 | ``` 583 | 584 | #### Scroll to the top 585 | 586 | If you want to scroll to the top on each page change to could consider doing the following. 587 | 588 | ```typescript 589 | window.addEventListener("navigationend", () => { 590 | requestAnimationFrame(() => { 591 | window.scrollTo(0, 0); 592 | }); 593 | }); 594 | ``` 595 | 596 | #### Style the active link 597 | 598 | If you want to style the active link you can do it by using the `isPathActive(...)` function along with listning to the `changestate` event. 599 | 600 | ```javascript 601 | import {isPathActive} from "router-slot"; 602 | 603 | const $links = Array.from(document.querySelectorAll("a")); 604 | window.addEventListener("changestate", () => { 605 | for (const $link of $links) { 606 | 607 | // Check whether the path is active 608 | const isActive = isPathActive($link.getAttribute("href")); 609 | 610 | // Set the data active attribute if the path is active, otherwise remove it. 611 | if (isActive) { 612 | $link.setAttribute("data-active", ""); 613 | 614 | } else { 615 | $link.removeAttribute("data-active"); 616 | } 617 | } 618 | }); 619 | ``` 620 | 621 | 622 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#-be-careful-when-navigating-to-the-root) 623 | 624 | ## ➤ ⚠️ Be careful when navigating to the root! 625 | 626 | 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. 627 | 628 | 629 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#contributors) 630 | 631 | ## ➤ Contributors 632 | 633 | 634 | | [Andreas Mehlsen](https://twitter.com/andreasmehlsen) | [You?](https://github.com/andreasbm/router-slot/blob/master/CONTRIBUTING.md) | 635 | |:--------------------------------------------------:|:--------------------------------------------------:| 636 | | [Andreas Mehlsen](https://twitter.com/andreasmehlsen) | [You?](https://github.com/andreasbm/router-slot/blob/master/CONTRIBUTING.md) | 637 | 638 | 639 | [![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png)](#license) 640 | 641 | ## ➤ License 642 | 643 | Licensed under [MIT](https://opensource.org/licenses/MIT). 644 | -------------------------------------------------------------------------------- /blueprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "text": "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.", 3 | "demo": "https://appnest-demo.firebaseapp.com/router-slot", 4 | "ids": { 5 | "npm": "router-slot", 6 | "github": "andreasbm/router-slot", 7 | "webcomponents": "router-slot" 8 | }, 9 | "bullets": [ 10 | "😴 Lazy loading of routes", 11 | "🎁 Web component friendly", 12 | "📡 Easy to use API", 13 | "🛣 Specify params in the path", 14 | "👌 Zero dependencies", 15 | "📚 Uses the [history API](https://developer.mozilla.org/en-US/docs/Web/API/History_API)", 16 | "🎉 Supports routes for dialogs", 17 | "🛡 Supports guards for routes", 18 | "⚓️ Allows the anchor element for navigating", 19 | "⚙️ Very customizable", 20 | "🤐 2kb gzipped" 21 | ] 22 | } -------------------------------------------------------------------------------- /blueprint.md: -------------------------------------------------------------------------------- 1 | {{ template:title }} 2 | 3 | {{ template:badges }} 4 | 5 | {{ template:description }} 6 | 7 | {{ bullets }} 8 | 9 |
10 | 📖 Table of Contents 11 |
12 | {{ template:toc }} 13 |
14 | 15 | ## Installation 16 | 17 | ```node 18 | npm i router-slot 19 | ``` 20 | 21 | ## Usage 22 | 23 | This section will introduce how to use the router. If you hate reading and love coding you can go to the [playgroud](https://codepen.io/andreasbm/pen/XWWZpvM) to try it for yourself. 24 | 25 | ### 1. Add `` 26 | 27 | To turn your app into a single-page-application you first need to add a [`` element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base) to the `index.html` in the ``. If your file is located in the root of your server, the `href` value should be the following: 28 | 29 | ```html 30 | 31 | ``` 32 | 33 | ### 2. Import the router 34 | 35 | To import the library you'll need to import the dependency in your application. 36 | 37 | ```javascript 38 | import "router-slot"; 39 | ``` 40 | 41 | ### 3. Add the `` element 42 | 43 | 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. 44 | 45 | ```html 46 | 47 | 48 | 49 | ``` 50 | 51 | ### 4. Configure the router 52 | 53 | 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. 54 | 55 | ```javascript 56 | const routerSlot = document.querySelector("router-slot"); 57 | await routerSlot.add([ 58 | { 59 | path: "login", 60 | component: () => import("./path/to/login/component") // Lazy loaded 61 | }, 62 | { 63 | path: "home", 64 | component: HomeComponent // Not lazy loaded 65 | }, 66 | { 67 | path: "**", 68 | redirectTo: "home" 69 | } 70 | ]); 71 | ``` 72 | 73 | You may want to wrap the above in a `whenDefined` callback to ensure the `router-slot` exists before using its logic. 74 | 75 | ```javascript 76 | customElements.whenDefined("router-slot").then(async () => { 77 | ... 78 | }); 79 | ``` 80 | 81 | ### 5. Navigate using the history API, anchor tag or the `` component 82 | 83 | In order to change a route you can either use the [`history API`](https://developer.mozilla.org/en-US/docs/Web/API/History), use an anchor element or use the `router-link` component. 84 | 85 | #### History API 86 | 87 | To push a new state into the history and change the URL you can use the `.pushState(...)` function on the history object. 88 | 89 | ```javascript 90 | history.pushState(null, "", "/login"); 91 | ``` 92 | 93 | If you want to replace the current URL with another one you can use the `.replaceState(...)` function on the history object instead. 94 | 95 | ```javascript 96 | history.replaceState(null, "", "/login"); 97 | ``` 98 | 99 | You can also go back and forth between the states by using the `.back()` and `.forward()` functions. 100 | 101 | ```javascript 102 | history.back(); 103 | history.forward(); 104 | ``` 105 | 106 | Go [`here`](https://developer.mozilla.org/en-US/docs/Web/API/History) to read more about the history API. 107 | 108 | #### Anchor element 109 | 110 | Normally an [`anchor element`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) reloads the page when clicked. This library however changes the default behavior of all anchor element to use the history API instead. 111 | 112 | ```html 113 | Go to home! 114 | ``` 115 | 116 | There are many advantages of using an anchor element, the main one being accessibility. 117 | 118 | 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: 119 | 120 | ```html 121 | Go to about! 122 | ``` 123 | 124 | #### `router-link` 125 | 126 | With the `router-link` component you add `` 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. 127 | 128 | ```html 129 | 130 | 131 | 132 | ``` 133 | 134 | 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. 135 | 136 | ### 6. Putting it all together 137 | 138 | So to recap the above steps, here's how to use the router. 139 | 140 | ```html 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | Go to home 149 | Go to login 150 | 151 | 171 | 172 | 173 | ``` 174 | 175 | ## `lit` 176 | 177 | 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. 178 | 179 | ```typescript 180 | import { LitElement, html, PropertyValues } from "lit"; 181 | import { query, customElement } from "lit/decorators.js"; 182 | import { RouterSlot } from "router-slot"; 183 | 184 | const ROUTES = [ 185 | { 186 | path: "login", 187 | component: () => import("./pages/login") 188 | }, 189 | { 190 | path: "home", 191 | component: () => import("./pages/home") 192 | }, 193 | { 194 | path: "**", 195 | redirectTo: "home" 196 | } 197 | ]; 198 | 199 | @customElement("app-component"); 200 | export class AppComponent extends LitElement { 201 | @query("router-slot") $routerSlot!: RouterSlot; 202 | 203 | firstUpdated (props: PropertyValues) { 204 | super.firstUpdated(props); 205 | this.$routerSlot.add(ROUTES); 206 | } 207 | 208 | render () { 209 | return html``; 210 | } 211 | } 212 | ``` 213 | 214 | ## Advanced 215 | 216 | You can customize a lot in this library. Continue reading to learn how to handle your new superpowers. 217 | 218 | ### Guards 219 | 220 | 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. 221 | 222 | ```typescript 223 | function sessionGuard () { 224 | 225 | if (localStorage.getItem("session") == null) { 226 | history.replaceState(null, "", "/login"); 227 | return false; 228 | } 229 | 230 | return true; 231 | } 232 | ``` 233 | 234 | Add this guard to the add function in the `guards` array. 235 | 236 | ```typescript 237 | ... 238 | await routerSlot.add([ 239 | ... 240 | { 241 | path: "home", 242 | component: HomeComponent, 243 | guards: [sessionGuard] 244 | }, 245 | ... 246 | ]); 247 | ``` 248 | 249 | ### Dialog routes 250 | 251 | 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. 252 | 253 | ```javascript 254 | history.native.pushState(null, "", "dialog"); 255 | alert("This is a dialog"); 256 | history.native.back(); 257 | ``` 258 | 259 | This allow dialogs to have a route which is especially awesome on mobile. 260 | 261 | ### Params 262 | 263 | 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. 264 | 265 | ```typescript 266 | await routerSlot.add([ 267 | { 268 | path: "user/:userId", 269 | component: UserComponent, 270 | setup: (component: UserComponent, info: RoutingInfo) => { 271 | component.userId = info.match.params.userId; 272 | } 273 | } 274 | ]); 275 | ``` 276 | 277 | Alternatively you can get the params in the `UserComponent` by using the `queryParentRouterSlot(...)` function. 278 | 279 | ```typescript 280 | import { LitElement, html } from "lit"; 281 | import { Params, queryParentRouterSlot } from "router-slot"; 282 | 283 | export default class UserComponent extends LitElement { 284 | 285 | get params (): Params { 286 | return queryParentRouterSlot(this)!.match!.params; 287 | } 288 | 289 | render () { 290 | const {userId} = this.params; 291 | return html` 292 |

:userId = ${userId}

293 | `; 294 | } 295 | } 296 | 297 | customElements.define("user-component", UserComponent); 298 | ``` 299 | 300 | ### Deep dive into the different route kinds 301 | 302 | 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. 303 | 304 | ```typescript 305 | export interface IRouteBase { 306 | 307 | // The path for the route fragment 308 | path: PathFragment; 309 | 310 | // Optional metadata 311 | data?: T; 312 | 313 | // If guard returns false, the navigation is not allowed 314 | guards?: Guard[]; 315 | 316 | // The type of match. 317 | // - If "prefix" router-slot will try to match the first part of the path. 318 | // - If "suffix" router-slot will try to match the last part of the path. 319 | // - If "full" router-slot will try to match the entire path. 320 | // - If "fuzzy" router-slot will try to match an arbitrary part of the path. 321 | pathMatch?: PathMatch; 322 | } 323 | ``` 324 | 325 | #### Component routes 326 | 327 | Component routes resolves a specified component. You can provide the `component` property with either a [class that extends HTMLElement](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements), 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. 328 | 329 | ```typescript 330 | export interface IComponentRoute extends IRouteBase { 331 | 332 | // The component loader (should return a module with a default export if it is a module) 333 | component: Class | ModuleResolver | PageComponent | (() => Class) | (() => PageComponent) | (() => ModuleResolver); 334 | 335 | // A custom setup function for the instance of the component. 336 | setup?: Setup; 337 | } 338 | ``` 339 | 340 | Here's an example on how that could look in practice. 341 | 342 | ```typescript 343 | routerSlot.add([ 344 | { 345 | path: "home", 346 | component: HomeComponent 347 | }, 348 | { 349 | path: "terms", 350 | component: () => import("/path/to/terms-module") 351 | }, 352 | { 353 | path: "login", 354 | component: () => { 355 | const $div = document.createElement("div"); 356 | $div.innerHTML = `🔑 This is the login page`; 357 | return $div; 358 | } 359 | }, 360 | { 361 | path: "video", 362 | component: document.createElement("video") 363 | } 364 | ]); 365 | ``` 366 | 367 | #### Redirection routes 368 | 369 | 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. 370 | 371 | ```typescript 372 | export interface IRedirectRoute extends IRouteBase { 373 | 374 | // The paths the route should redirect to. Can either be relative or absolute. 375 | redirectTo: string; 376 | 377 | // Whether the query should be preserved when redirecting. 378 | preserveQuery?: boolean; 379 | } 380 | ``` 381 | 382 | Here's an example on how that could look in practice. 383 | 384 | ```typescript 385 | routerSlot.add([ 386 | ... 387 | { 388 | path: "404", 389 | component: document.createTextNode(`404 - The page you are looking for wasn't found.`) 390 | } 391 | { 392 | path: "**", 393 | redirectTo: "404", 394 | preserveQuery: true 395 | } 396 | ]); 397 | ``` 398 | 399 | #### Resolver routes 400 | 401 | 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. 402 | 403 | ```typescript 404 | export interface IResolverRoute extends IRouteBase { 405 | 406 | // A custom resolver that handles the route change 407 | resolve: CustomResolver; 408 | } 409 | ``` 410 | 411 | Here's an example on how that could look in practice. 412 | 413 | ```typescript 414 | routerSlot.add([ 415 | { 416 | path: "home", 417 | resolve: (info: RoutingInfo) => { 418 | const $page = document.createElement("div"); 419 | $page.appendChild(document.createTextNode("This is a custom home page!")); 420 | 421 | // You can for example add the page to the body instead of the 422 | // default behavior where it is added to the router-slot. 423 | // If you want a router-slot inside the element you are adding here 424 | // you need to set the parent of that router-slot to info.slot. 425 | document.body.appendChild($page); 426 | }) 427 | } 428 | ]); 429 | ``` 430 | 431 | ### Stop the user from navigating away 432 | 433 | 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. 434 | 435 | ```javascript 436 | window.addEventListener("willchangestate", e => { 437 | 438 | // Check if we should navigate away from this page 439 | if (!confirm("You have unsafed data. Do you wish to discard it?")) { 440 | e.preventDefault(); 441 | return; 442 | } 443 | 444 | }, {once: true}); 445 | ``` 446 | 447 | ### Helper functions 448 | 449 | The library comes with a set of helper functions. This includes: 450 | 451 | * `path()` - The current path of the location. 452 | * `query()` - The current query as an object. 453 | * `queryString()` - The current query as a string. 454 | * `toQuery(queryString)` - Turns a query string into a an object. 455 | * `toQueryString(query)` - Turns a query object into a string. 456 | * `slashify({ startSlash?: boolean, endSlash?: boolean; })` - Makes sure that the start and end slashes are present or not depending on the options. 457 | * `stripSlash()` - Strips the slash from the start and/or end of a path. 458 | * `ensureSlash()` - Ensures the path starts and/or ends with a slash. 459 | * `isPathActive (path: string | PathFragment, fullPath: string = getPath())` - Determines whether the path is active compared to the full path. 460 | 461 | ### Global navigation events 462 | 463 | 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. 464 | 465 | ```typescript 466 | // An event triggered when a new state is added to the history. 467 | window.addEventListener("pushstate", (e: PushStateEvent) => { 468 | console.log("On push state", path()); 469 | }); 470 | 471 | // An event triggered when the current state is replaced in the history. 472 | window.addEventListener("replacestate", (e: ReplaceStateEvent) => { 473 | console.log("On replace state", path()); 474 | }); 475 | 476 | // An event triggered when a state in the history is popped from the history. 477 | window.addEventListener("popstate", (e: PopStateEvent) => { 478 | console.log("On pop state", path()); 479 | }); 480 | 481 | // An event triggered when the state changes (eg. pop, push and replace) 482 | window.addEventListener("changestate", (e: ChangeStateEvent) => { 483 | console.log("On change state", path()); 484 | }); 485 | 486 | // A cancellable event triggered before the history state changes. 487 | window.addEventListener("willchangestate", (e: WillChangeStateEvent) => { 488 | console.log("Before the state changes. Call 'e.preventDefault()' to prevent the state from changing."); 489 | }); 490 | 491 | // An event triggered when navigation starts. 492 | window.addEventListener("navigationstart", (e: NavigationStartEvent) => { 493 | console.log("Navigation start", e.detail); 494 | }); 495 | 496 | // An event triggered when navigation is canceled. This is due to a Route Guard returning false during navigation. 497 | window.addEventListener("navigationcancel", (e: NavigationCancelEvent) => { 498 | console.log("Navigation cancelled", e.detail); 499 | }); 500 | 501 | // An event triggered when navigation ends. 502 | window.addEventListener("navigationend", (e: NavigationEndEvent) => { 503 | console.log("Navigation end", e.detail); 504 | }); 505 | 506 | // An event triggered when navigation fails due to an unexpected error. 507 | window.addEventListener("navigationerror", (e: NavigationErrorEvent) => { 508 | console.log("Navigation failed", e.detail); 509 | }); 510 | 511 | // An event triggered when navigation successfully completes. 512 | window.addEventListener("navigationsuccess", (e: NavigationSuccessEvent) => { 513 | console.log("Navigation failed", e.detail); 514 | }); 515 | ``` 516 | 517 | #### Scroll to the top 518 | 519 | If you want to scroll to the top on each page change to could consider doing the following. 520 | 521 | ```typescript 522 | window.addEventListener("navigationend", () => { 523 | requestAnimationFrame(() => { 524 | window.scrollTo(0, 0); 525 | }); 526 | }); 527 | ``` 528 | 529 | #### Style the active link 530 | 531 | If you want to style the active link you can do it by using the `isPathActive(...)` function along with listning to the `changestate` event. 532 | 533 | ```javascript 534 | import {isPathActive} from "router-slot"; 535 | 536 | const $links = Array.from(document.querySelectorAll("a")); 537 | window.addEventListener("changestate", () => { 538 | for (const $link of $links) { 539 | 540 | // Check whether the path is active 541 | const isActive = isPathActive($link.getAttribute("href")); 542 | 543 | // Set the data active attribute if the path is active, otherwise remove it. 544 | if (isActive) { 545 | $link.setAttribute("data-active", ""); 546 | 547 | } else { 548 | $link.removeAttribute("data-active"); 549 | } 550 | } 551 | }); 552 | ``` 553 | 554 | ## ⚠️ Be careful when navigating to the root! 555 | 556 | 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. 557 | 558 | {{ template:contributors }} 559 | {{ template:license }} 560 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const {defaultResolvePlugins, defaultKarmaConfig, clean} = require("@appnest/web-config"); 2 | 3 | module.exports = (config) => { 4 | config.set({ 5 | ...defaultKarmaConfig({ 6 | rollupPlugins: [ 7 | clean({targets: ["dist"]}), 8 | ...defaultResolvePlugins() 9 | ] 10 | }), 11 | basePath: "src", 12 | logLevel: config.LOG_INFO 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "router-slot", 3 | "version": "1.5.5", 4 | "description": "A powerful web component router", 5 | "license": "MIT", 6 | "author": "Andreas Mehlsen", 7 | "types": "index.d.ts", 8 | "module": "index.js", 9 | "main": "index.js", 10 | "bugs": { 11 | "url": "https://github.com/andreasbm/router-slot/issues" 12 | }, 13 | "homepage": "https://github.com/andreasbm/router-slot#readme", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/andreasbm/router-slot.git" 17 | }, 18 | "keywords": [ 19 | "webapp", 20 | "custom", 21 | "elements", 22 | "powerful", 23 | "routes", 24 | "routing", 25 | "route", 26 | "router", 27 | "slot", 28 | "fragments", 29 | "vanilla", 30 | "web", 31 | "component", 32 | "router", 33 | "navigation", 34 | "zero dependencies" 35 | ], 36 | "scripts": { 37 | "b:demo:dev": "rollup -c rollup.config.ts --environment NODE_ENV:dev", 38 | "b:demo:prod": "rollup -c rollup.config.ts --environment NODE_ENV:prod", 39 | "s:dev": "rollup -c rollup.config.ts --watch --environment NODE_ENV:dev", 40 | "s:prod": "rollup -c rollup.config.ts --watch --environment NODE_ENV:prod", 41 | "s": "npm run s:dev", 42 | "test": "karma start", 43 | "ncu": "ncu -u -a && npm update && npm install", 44 | "b:lib": "node pre-build.js && tsc -p tsconfig.build.json && npm run custom-elements-json", 45 | "readme": "node node_modules/.bin/readme generate", 46 | "postversion": "npm run readme && npm run b:lib", 47 | "publish:patch": "np patch --contents=dist --no-cleanup", 48 | "publish:minor": "np minor --contents=dist --no-cleanup", 49 | "publish:major": "np major --contents=dist --no-cleanup", 50 | "custom-elements-json": "npx wca analyze src/lib --format json --outFile dist/custom-elements.json" 51 | }, 52 | "devDependencies": { 53 | "@appnest/readme": "^1.2.7", 54 | "@appnest/web-config": "^0.5.4", 55 | "lit": "^2.2.3", 56 | "node-typescript-compiler": "^2.4.0", 57 | "weightless": "0.0.37" 58 | }, 59 | "contributors": [ 60 | { 61 | "name": "Andreas Mehlsen", 62 | "url": "https://twitter.com/andreasmehlsen", 63 | "img": "https://avatars1.githubusercontent.com/u/6267397?s=460&v=4" 64 | }, 65 | { 66 | "name": "You?", 67 | "img": "https://joeschmoe.io/api/v1/random", 68 | "url": "https://github.com/andreasbm/router-slot/blob/master/CONTRIBUTING.md" 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /pre-build.js: -------------------------------------------------------------------------------- 1 | const rimraf = require("rimraf"); 2 | const path = require("path"); 3 | const fs = require("fs-extra"); 4 | const outLib = "dist"; 5 | 6 | // TODO: Run "tsc -p tsconfig.build.json" from this script and rename it to "build". 7 | 8 | async function preBuild () { 9 | await cleanLib(); 10 | copySync("./package.json", `./${outLib}/package.json`); 11 | copySync("./README.md", `./${outLib}/README.md`); 12 | } 13 | 14 | function cleanLib () { 15 | return new Promise(res => rimraf(outLib, res)); 16 | } 17 | 18 | function copySync (src, dest) { 19 | fs.copySync(path.resolve(__dirname, src), path.resolve(__dirname, dest)); 20 | } 21 | 22 | preBuild().then(_ => { 23 | console.log(">> Prebuild completed"); 24 | }); 25 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import pkg from "./package.json"; 3 | import { 4 | defaultExternals, 5 | defaultOutputConfig, 6 | defaultPlugins, 7 | defaultProdPlugins, 8 | defaultServePlugins, 9 | isLibrary, 10 | isProd, 11 | isServe 12 | } from "@appnest/web-config"; 13 | 14 | const folders = { 15 | dist: path.resolve(__dirname, "dist"), 16 | src: path.resolve(__dirname, "src/demo") 17 | }; 18 | 19 | const files = { 20 | main: path.join(folders.src, "app.ts"), 21 | src_index: path.join(folders.src, "index.html"), 22 | dist_index: path.join(folders.dist, "index.html") 23 | }; 24 | 25 | export default { 26 | input: { 27 | main: files.main 28 | }, 29 | output: [ 30 | defaultOutputConfig({ 31 | format: "esm", 32 | dir: folders.dist 33 | }) 34 | ], 35 | plugins: [ 36 | ...defaultPlugins({ 37 | cleanConfig: { 38 | targets: [ 39 | folders.dist 40 | ] 41 | }, 42 | htmlTemplateConfig: { 43 | template: files.src_index, 44 | target: files.dist_index, 45 | include: /main(-.*)?\.js$/ 46 | } 47 | }), 48 | 49 | // Serve 50 | ...(isServe ? [ 51 | ...defaultServePlugins({ 52 | serveConfig: { 53 | port: 1338, 54 | contentBase: folders.dist 55 | }, 56 | livereloadConfig: { 57 | watch: folders.dist 58 | } 59 | }) 60 | ] : []), 61 | 62 | // Production 63 | ...(isProd ? [ 64 | ...defaultProdPlugins({ 65 | dist: folders.dist, 66 | minifyLitHtmlConfig: { 67 | verbose: false 68 | }, 69 | visualizerConfig: { 70 | filename: path.join(folders.dist, "stats.html") 71 | }, 72 | licenseConfig: { 73 | thirdParty: { 74 | output: path.join(folders.dist, "licenses.txt") 75 | } 76 | }, 77 | budgetConfig: { 78 | sizes: { 79 | ".js": 1024 * 200 80 | } 81 | } 82 | }) 83 | ] : []) 84 | ], 85 | external: [ 86 | ...(isLibrary ? [ 87 | ...defaultExternals(pkg) 88 | ] : []) 89 | ], 90 | treeshake: isProd, 91 | context: "window" 92 | } 93 | -------------------------------------------------------------------------------- /src/demo/app.ts: -------------------------------------------------------------------------------- 1 | import { ChangeStateEvent, GLOBAL_ROUTER_EVENTS_TARGET, IRouterSlot, matchRoute, NavigationCancelEvent, NavigationEndEvent, NavigationErrorEvent, NavigationStartEvent, NavigationSuccessEvent, path, PushStateEvent, ReplaceStateEvent, RouterSlot } from "../lib"; 2 | import { ROUTER_SLOT_TAG_NAME } from "../lib/config"; 3 | 4 | import "./../lib/router-link"; 5 | 6 | /** 7 | * Asserts that the user is authenticated. 8 | */ 9 | function sessionGuard () { 10 | 11 | if (localStorage.getItem("session") == null) { 12 | history.replaceState(null, "", "login"); 13 | return false; 14 | } 15 | 16 | return true; 17 | } 18 | 19 | 20 | // Setup the router 21 | customElements.whenDefined(ROUTER_SLOT_TAG_NAME).then(async () => { 22 | const routerSlot = document.querySelector(ROUTER_SLOT_TAG_NAME)!; 23 | 24 | let hasInitialized = false; 25 | routerSlot.addEventListener("changestate", () => { 26 | if (!hasInitialized) { 27 | document.body.classList.add("initialized"); 28 | hasInitialized = true; 29 | } 30 | }); 31 | 32 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("pushstate", (e: PushStateEvent) => { 33 | console.log("On push state", `'${path()}'`); 34 | }); 35 | 36 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("replacestate", (e: ReplaceStateEvent) => { 37 | console.log("On replace state", path()); 38 | }); 39 | 40 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("popstate", (e: PopStateEvent) => { 41 | console.log("On pop state", path(), e.state); 42 | }); 43 | 44 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("changestate", (e: ChangeStateEvent) => { 45 | console.log("On change state", path()); 46 | }); 47 | 48 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("navigationstart", (e: NavigationStartEvent) => { 49 | console.log("Navigation start", e.detail); 50 | }); 51 | 52 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("navigationend", (e: NavigationEndEvent) => { 53 | window.scrollTo({top: 0, left: 0, behavior: "smooth"}); 54 | console.log("Navigation end", e.detail); 55 | }); 56 | 57 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("navigationsuccess", (e: NavigationSuccessEvent) => { 58 | console.log("Navigation success", e.detail); 59 | }); 60 | 61 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("navigationcancel", (e: NavigationCancelEvent) => { 62 | console.log("Navigation cancelled", e.detail); 63 | }); 64 | 65 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("navigationerror", (e: NavigationErrorEvent) => { 66 | console.log("Navigation failed", e.detail); 67 | }); 68 | 69 | await routerSlot.add([ 70 | { 71 | path: `login`, 72 | component: () => import("./pages/login/login") 73 | }, 74 | { 75 | path: `home`, 76 | component: () => import("./pages/home/home"), 77 | guards: [sessionGuard] 78 | }, 79 | { 80 | // You can give the component as a HTML element if you want 81 | path: `div`, 82 | component: () => { 83 | const $div = document.createElement("div"); 84 | $div.innerText = `Heres a
tag!`; 85 | 86 | const $slot = new RouterSlot(); 87 | $slot.add([ 88 | { 89 | path: "route", 90 | pathMatch: "suffix", 91 | component: () => { 92 | const $div = document.createElement("div"); 93 | $div.innerText = `Here's another
tag!`; 94 | return $div; 95 | } 96 | }, 97 | { 98 | path: "**", 99 | redirectTo: "/div/route" 100 | } 101 | ]); 102 | $div.appendChild($slot); 103 | return $div; 104 | } 105 | }, 106 | { 107 | path: "**", 108 | redirectTo: `home`, 109 | preserveQuery: true 110 | } 111 | ]); 112 | }); 113 | 114 | //(window as any)["matchRoute"] = matchRoute; -------------------------------------------------------------------------------- /src/demo/dialog/dialog.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, PropertyValues, TemplateResult } from "lit"; 2 | import { property } from "lit/decorators.js"; 3 | import "weightless/dialog"; 4 | import "weightless/title"; 5 | import { ROUTER_SLOT_TAG_NAME } from "../../lib/config"; 6 | import { IRouterSlot } from "../../lib/model"; 7 | import { sharedStyles } from "../pages/styles"; 8 | 9 | export default class DialogComponent extends LitElement { 10 | static styles = [sharedStyles]; 11 | 12 | @property({type: Object}) parent: IRouterSlot | null = null; 13 | 14 | firstUpdated (changedProperties: PropertyValues) { 15 | super.firstUpdated(changedProperties); 16 | 17 | const $routerSlot = this.shadowRoot!.querySelector(ROUTER_SLOT_TAG_NAME)!; 18 | if (this.parent != null) { 19 | $routerSlot.parent = this.parent; 20 | } 21 | 22 | $routerSlot.add([ 23 | { 24 | path: "step-one", 25 | component: () => import("./step-one/step-one") 26 | }, 27 | { 28 | path: "step-two", 29 | component: () => import("./step-two/step-two") 30 | }, 31 | { 32 | path: "**", 33 | redirectTo: "step-one" 34 | } 35 | ]); 36 | } 37 | 38 | private close () { 39 | this.dispatchEvent(new CustomEvent("close")); 40 | } 41 | 42 | /** 43 | * Renders the component. 44 | * @returns {TemplateResult} 45 | */ 46 | render (): TemplateResult { 47 | return html` 48 | 49 | This is a dialog 50 |
51 | Go to StepOneComponent 52 |
53 | Go to StepTwoComponent 54 | 55 |
56 |
57 | Close dialog 58 |
59 |
60 | `; 61 | } 62 | 63 | } 64 | 65 | window.customElements.define("dialog-component", DialogComponent); 66 | -------------------------------------------------------------------------------- /src/demo/dialog/step-one/step-one.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, TemplateResult } from "lit"; 2 | import { sharedStyles } from "../../pages/styles"; 3 | 4 | export default class StepOneComponent extends LitElement { 5 | static styles = [sharedStyles]; 6 | render (): TemplateResult { 7 | return html` 8 |

Step 1

9 | `; 10 | } 11 | } 12 | 13 | window.customElements.define("step-one-component", StepOneComponent); 14 | -------------------------------------------------------------------------------- /src/demo/dialog/step-two/step-two.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, TemplateResult } from "lit"; 2 | import { sharedStyles } from "../../pages/styles"; 3 | 4 | export default class StepTwoComponent extends LitElement { 5 | static styles = [sharedStyles]; 6 | render (): TemplateResult { 7 | return html` 8 |

Step 2

9 | `; 10 | } 11 | } 12 | 13 | window.customElements.define("step-two-component", StepTwoComponent); 14 | -------------------------------------------------------------------------------- /src/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | router-slot 8 | 9 | 10 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Github 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/demo/pages/home/home.ts: -------------------------------------------------------------------------------- 1 | import { css, LitElement, PropertyValues, TemplateResult, html } from "lit"; 2 | import "weightless/nav"; 3 | import { basePath, GLOBAL_ROUTER_EVENTS_TARGET, IRoute, isPathActive, PageComponent, query, queryString, IRoutingInfo } from "../../../lib"; 4 | import { sharedStyles } from "../styles"; 5 | import "weightless/button"; 6 | 7 | const ROUTES: IRoute[] = [ 8 | { 9 | path: "secret", 10 | component: () => import("./secret/secret") 11 | }, 12 | { 13 | path: "user/:user/dashboard/:dashId", 14 | component: () => import("./user/user"), 15 | setup: (page: PageComponent, info: IRoutingInfo) => { 16 | //page.userId = info.match.params.userId; 17 | console.log({page, info}); 18 | } 19 | }, 20 | { 21 | path: "**", 22 | redirectTo: "secret", 23 | preserveQuery: true 24 | } 25 | ]; 26 | 27 | export default class HomeComponent extends LitElement { 28 | static styles = [sharedStyles, css` 29 | #child { 30 | margin: 70px 0 0 0; 31 | padding: 30px; 32 | } 33 | 34 | a, button, wl-button { 35 | margin: 0 12px 0 0; 36 | } 37 | `]; 38 | 39 | firstUpdated (changedProperties: PropertyValues) { 40 | super.firstUpdated(changedProperties); 41 | 42 | console.log({ 43 | query: query(), 44 | queryString: queryString() 45 | }); 46 | 47 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("changestate", () => this.requestUpdate()); 48 | } 49 | 50 | private logout () { 51 | localStorage.clear(); 52 | history.replaceState(null, "", "/login"); 53 | } 54 | 55 | render (): TemplateResult { 56 | return html` 57 | 58 |

router-slot

59 |
60 | Go to SecretComponent 61 | Go to UserComponent 62 | Logout 63 |
64 |
65 |
66 | 67 |
68 | `; 69 | } 70 | 71 | } 72 | 73 | window.customElements.define("home-component", HomeComponent); 74 | -------------------------------------------------------------------------------- /src/demo/pages/home/secret/code/code.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, TemplateResult } from "lit"; 2 | import { sharedStyles } from "../../../styles"; 3 | 4 | export default class CodeComponent extends LitElement { 5 | static styles = [sharedStyles]; 6 | render (): TemplateResult { 7 | return html` 8 |

CodeComponent

9 | `; 10 | } 11 | } 12 | 13 | window.customElements.define("code-component", CodeComponent); 14 | -------------------------------------------------------------------------------- /src/demo/pages/home/secret/data.ts: -------------------------------------------------------------------------------- 1 | export let data: {secretPassword: string | undefined} = { 2 | secretPassword: undefined 3 | }; 4 | -------------------------------------------------------------------------------- /src/demo/pages/home/secret/password/password.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, TemplateResult } from "lit"; 2 | import { showDialog } from "weightless"; 3 | import { GLOBAL_ROUTER_EVENTS_TARGET, ROUTER_SLOT_TAG_NAME } from "../../../../../lib/config"; 4 | import { Class, IRouterSlot, IRoutingInfo } from "../../../../../lib/model"; 5 | import { addListener } from "../../../../../lib/util/events"; 6 | import { basePath, path } from "../../../../../lib/util/url"; 7 | import { sharedStyles } from "../../../styles"; 8 | import { data } from "../data"; 9 | 10 | export default class PasswordComponent extends LitElement { 11 | static styles = [sharedStyles]; 12 | 13 | firstUpdated () { 14 | super.connectedCallback(); 15 | 16 | const $routerSlot = this.shadowRoot!.querySelector(ROUTER_SLOT_TAG_NAME)!; 17 | $routerSlot.add([ 18 | { 19 | path: "dialog", 20 | resolve: (async ({slot, match}: IRoutingInfo) => { 21 | const DialogComponent: Class = (await import("../../../../dialog/dialog")).default; 22 | const $dialog = new DialogComponent() as {parent: IRouterSlot | null} & HTMLElement; 23 | $dialog.parent = slot; 24 | 25 | function cleanup () { 26 | if (document.body.contains($dialog)) { 27 | document.body.removeChild($dialog); 28 | } 29 | } 30 | 31 | $dialog.addEventListener("close", () => { 32 | history.pushState(null, "", `${basePath()}home/secret/password`); 33 | cleanup(); 34 | }); 35 | 36 | const unsub = addListener(GLOBAL_ROUTER_EVENTS_TARGET, "popstate", () => { 37 | if (!path().includes("dialog")) { 38 | cleanup(); 39 | unsub(); 40 | } 41 | }); 42 | 43 | document.body.appendChild($dialog); 44 | }) 45 | } 46 | ]); 47 | } 48 | 49 | /** 50 | * Opens a dialog without routing inside it. 51 | */ 52 | private async openDialogWithoutRouting () { 53 | history.native.pushState(null, "", `item/1234`); 54 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("popstate", close, {once: true}); 55 | 56 | const {result} = await showDialog({ 57 | fixed: true, 58 | backdrop: true, 59 | blockScrolling: true, 60 | container: document.body, 61 | duration: 200, 62 | template: html`

This is a dialog with a special path!

` as any 63 | }); 64 | 65 | await result; 66 | 67 | GLOBAL_ROUTER_EVENTS_TARGET.removeEventListener("popstate", close); 68 | history.native.back(); 69 | } 70 | 71 | render (): TemplateResult { 72 | return html` 73 |

PasswordComponent

74 | Resolved password: ${data.secretPassword} 75 | 76 | 77 | Open dialog with routes 78 | 79 | `; 80 | } 81 | } 82 | 83 | window.customElements.define("password-component", PasswordComponent); 84 | -------------------------------------------------------------------------------- /src/demo/pages/home/secret/secret.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, PropertyValues, TemplateResult } from "lit"; 2 | import { IRouterSlot, PageComponent, query, IRoutingInfo } from "../../../../lib"; 3 | import { ROUTER_SLOT_TAG_NAME } from "../../../../lib/config"; 4 | import { sharedStyles } from "../../styles"; 5 | import { data } from "./data"; 6 | 7 | function resolveSecretPasswordGuard (): Promise { 8 | return new Promise(res => { 9 | if (data.secretPassword != null) res(true); 10 | setTimeout(() => { 11 | data.secretPassword = `1234`; 12 | res(true); 13 | }, 1000); 14 | }); 15 | } 16 | 17 | export default class SecretComponent extends LitElement { 18 | static styles = [sharedStyles]; 19 | 20 | firstUpdated (changedProperties: PropertyValues) { 21 | super.firstUpdated(changedProperties); 22 | 23 | const $routerSlot = this.shadowRoot!.querySelector(ROUTER_SLOT_TAG_NAME)!; 24 | $routerSlot.add([ 25 | { 26 | path: "code", 27 | component: () => import("./code/code"), 28 | setup: (component: PageComponent, info: IRoutingInfo) => { 29 | component.style.color = query()["yellow"] != null ? `yellow` : `blue`; 30 | } 31 | }, 32 | { 33 | path: "password", 34 | component: () => import("./password/password"), 35 | guards: [resolveSecretPasswordGuard] 36 | }, 37 | { 38 | path: "**", 39 | redirectTo: "code", 40 | preserveQuery: true 41 | } 42 | ]); 43 | } 44 | 45 | /** 46 | * Renders the component. 47 | * @returns {TemplateResult} 48 | */ 49 | render (): TemplateResult { 50 | return html` 51 |

SecretComponent

52 | Go to CodeComponent 53 | Go to PasswordComponent (1sec delay) 54 |
55 | 56 |
57 | `; 58 | } 59 | 60 | } 61 | 62 | window.customElements.define("secret-component", SecretComponent); 63 | -------------------------------------------------------------------------------- /src/demo/pages/home/user/edit/edit.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, TemplateResult } from "lit"; 2 | import { GLOBAL_ROUTER_EVENTS_TARGET } from "../../../../../lib/config"; 3 | import { WillChangeStateEvent } from "../../../../../lib/model"; 4 | import { sharedStyles } from "../../../styles"; 5 | 6 | export default class EditComponent extends LitElement { 7 | static styles = [sharedStyles]; 8 | 9 | connectedCallback () { 10 | super.connectedCallback(); 11 | const confirmNavigation = (e: WillChangeStateEvent) => { 12 | console.log(e); 13 | 14 | // Check if we should navigate away from this page 15 | if (!confirm("You have unsafed data. Do you wish to discard it?")) { 16 | e.preventDefault(); 17 | return; 18 | } 19 | 20 | GLOBAL_ROUTER_EVENTS_TARGET.removeEventListener("willchangestate", confirmNavigation); 21 | }; 22 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener("willchangestate", confirmNavigation); 23 | } 24 | 25 | render (): TemplateResult { 26 | return html` 27 |

EditComponent

28 | `; 29 | } 30 | } 31 | 32 | window.customElements.define("edit-component", EditComponent); 33 | -------------------------------------------------------------------------------- /src/demo/pages/home/user/user.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, PropertyValues, TemplateResult } from "lit"; 2 | import { ROUTER_SLOT_TAG_NAME } from "../../../../lib/config"; 3 | import { IRouterSlot, Params } from "../../../../lib/model"; 4 | import { queryParentRouterSlot } from "../../../../lib/util/shadow"; 5 | import { sharedStyles } from "../../styles"; 6 | 7 | export default class UserComponent extends LitElement { 8 | static styles = [sharedStyles]; 9 | 10 | get params (): Params { 11 | return queryParentRouterSlot(this)!.match!.params; 12 | } 13 | 14 | connectedCallback (): void { 15 | super.connectedCallback(); 16 | const parent = queryParentRouterSlot(this); 17 | if (parent != null) { 18 | console.log("PARENT!!!!!!!!", {param: parent.params}); 19 | } 20 | } 21 | 22 | firstUpdated (changedProperties: PropertyValues) { 23 | super.firstUpdated(changedProperties); 24 | 25 | const $routerSlot = this.shadowRoot!.querySelector(ROUTER_SLOT_TAG_NAME)!; 26 | $routerSlot.add([ 27 | { 28 | path: "edit", 29 | component: () => import("./edit/edit") 30 | } 31 | ]); 32 | } 33 | 34 | /** 35 | * Renders the element. 36 | * @returns {TemplateResult} 37 | */ 38 | render (): TemplateResult { 39 | const {user, dashId} = this.params; 40 | return html` 41 |

UserComponent

42 |

:user = ${user}

43 |

:dashId = ${dashId}

44 | Go to EditComponent 45 | 46 | `; 47 | } 48 | 49 | } 50 | 51 | window.customElements.define("user-component", UserComponent); 52 | -------------------------------------------------------------------------------- /src/demo/pages/login/login.ts: -------------------------------------------------------------------------------- 1 | import { css, html, LitElement, TemplateResult } from "lit"; 2 | import { sharedStyles } from "../styles"; 3 | import "weightless/card"; 4 | import "weightless/button"; 5 | 6 | export default class LoginComponent extends LitElement { 7 | 8 | static styles = [sharedStyles, css` 9 | #container { 10 | margin: 70px auto; 11 | max-width: 700px; 12 | width: 100%; 13 | padding: 30px; 14 | } 15 | 16 | h2 { 17 | margin: 0; 18 | } 19 | `]; 20 | 21 | private login () { 22 | localStorage.setItem("session", "secretToken"); 23 | history.replaceState(null, "", "/"); 24 | } 25 | 26 | /** 27 | * Renders the component. 28 | * @returns {TemplateResult} 29 | */ 30 | render (): TemplateResult { 31 | return html` 32 | 33 |

Login to continue

34 |

The routes are guarded behind a login. In order to get to the app you need to have a session.

35 | Login 36 |
37 | `; 38 | } 39 | 40 | } 41 | 42 | window.customElements.define("login-component", LoginComponent); 43 | -------------------------------------------------------------------------------- /src/demo/pages/styles.ts: -------------------------------------------------------------------------------- 1 | import { unsafeCSS } from "lit"; 2 | 3 | export const sharedStyles = unsafeCSS` 4 | :host { 5 | 6 | } 7 | 8 | router-link { 9 | border-bottom: 2px solid currentColor; 10 | outline: none; 11 | } 12 | 13 | router-link, a { 14 | color: grey; 15 | cursor: pointer; 16 | } 17 | 18 | router-link:focus, router-link:hover, a:hover, a:focus { 19 | color: black; 20 | } 21 | 22 | router-link[active] { 23 | color: red; 24 | } 25 | 26 | a[data-active] { 27 | color: red; 28 | } 29 | 30 | `; -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { PathMatch } from "./model"; 2 | 3 | export const CATCH_ALL_WILDCARD: string = "**"; 4 | export const TRAVERSE_FLAG: string = "\\.\\.\\/"; 5 | export const PARAM_IDENTIFIER: RegExp = /:([^\\/]+)/g; 6 | export const ROUTER_SLOT_TAG_NAME: string = "router-slot"; 7 | export const GLOBAL_ROUTER_EVENTS_TARGET = window; 8 | export const HISTORY_PATCH_NATIVE_KEY: string = `native`; 9 | export const DEFAULT_PATH_MATCH: PathMatch = "prefix"; 10 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./router-slot"; 2 | export * from "./router-link"; 3 | export * from "./model"; 4 | export * from "./util"; 5 | export * from "./config"; 6 | -------------------------------------------------------------------------------- /src/lib/model.ts: -------------------------------------------------------------------------------- 1 | export interface IRouterSlot extends HTMLElement { 2 | readonly route: IRoute | null; 3 | readonly isRoot: boolean; 4 | readonly fragments: IPathFragments | null; 5 | readonly params: Params | null; 6 | readonly match: IRouteMatch | null; 7 | routes: IRoute[]; 8 | add: ((routes: IRoute[], navigate?: boolean) => void); 9 | clear: (() => void); 10 | render: (() => Promise); 11 | constructAbsolutePath: ((path: PathFragment) => string); 12 | parent: IRouterSlot

| null | undefined; 13 | queryParentRouterSlot: (() => IRouterSlot

| null); 14 | } 15 | 16 | export type IRoutingInfo = { 17 | slot: IRouterSlot, 18 | match: IRouteMatch 19 | }; 20 | 21 | export type CustomResolver = ((info: IRoutingInfo) => boolean | void | Promise | Promise); 22 | export type Guard = ((info: IRoutingInfo) => boolean | Promise); 23 | export type Cancel = (() => boolean); 24 | 25 | export type PageComponent = HTMLElement; 26 | export type ModuleResolver = Promise<{default: any; /*PageComponent*/}>; 27 | export type Class = {new (...args: any[]): T;}; 28 | export type Setup = ((component: PageComponent, info: IRoutingInfo) => void); 29 | 30 | export type RouterTree = {slot: IRouterSlot} & {child?: RouterTree} | null | undefined; 31 | export type PathMatch = "prefix" | "suffix" | "full" | "fuzzy"; 32 | 33 | /** 34 | * The base route interface. 35 | * D = the data type of the data 36 | */ 37 | export interface IRouteBase { 38 | 39 | // The path for the route fragment 40 | path: PathFragment; 41 | 42 | // Optional metadata 43 | data?: D; 44 | 45 | // If guard returns false, the navigation is not allowed 46 | guards?: Guard[]; 47 | 48 | // The type of match. 49 | // - If "prefix" router-slot will try to match the first part of the path. 50 | // - If "suffix" router-slot will try to match the last part of the path. 51 | // - If "full" router-slot will try to match the entire path. 52 | // - If "fuzzy" router-slot will try to match an arbitrary part of the path. 53 | pathMatch?: PathMatch; 54 | } 55 | 56 | /** 57 | * Route type used for redirection. 58 | */ 59 | export interface IRedirectRoute extends IRouteBase { 60 | 61 | // The paths the route should redirect to. Can either be relative or absolute. 62 | redirectTo: string; 63 | 64 | // Whether the query should be preserved when redirecting. 65 | preserveQuery?: boolean; 66 | } 67 | 68 | /** 69 | * Route type used to resolve and stamp components. 70 | */ 71 | export interface IComponentRoute extends IRouteBase { 72 | 73 | // The component loader (should return a module with a default export) 74 | component: Class | ModuleResolver | PageComponent | (() => Class) | (() => PageComponent) | (() => ModuleResolver); 75 | 76 | // A custom setup function for the instance of the component. 77 | setup?: Setup; 78 | } 79 | 80 | /** 81 | * Route type used to take control of how the route should resolve. 82 | */ 83 | export interface IResolverRoute extends IRouteBase { 84 | 85 | // A custom resolver that handles the route change 86 | resolve: CustomResolver; 87 | } 88 | 89 | export type IRoute = IRedirectRoute | IComponentRoute | IResolverRoute; 90 | export type PathFragment = string; 91 | export type IPathFragments = { 92 | consumed: PathFragment, 93 | rest: PathFragment 94 | } 95 | 96 | export interface IRouteMatch { 97 | route: IRoute; 98 | params: Params, 99 | fragments: IPathFragments; 100 | match: RegExpMatchArray; 101 | } 102 | 103 | export type PushStateEvent = CustomEvent; 104 | export type ReplaceStateEvent = CustomEvent; 105 | export type ChangeStateEvent = CustomEvent; 106 | export type WillChangeStateEvent = CustomEvent<{ url?: string | null, eventName: GlobalRouterEvent}>; 107 | export type NavigationStartEvent = CustomEvent>; 108 | export type NavigationSuccessEvent = CustomEvent>; 109 | export type NavigationCancelEvent = CustomEvent>; 110 | export type NavigationErrorEvent = CustomEvent>; 111 | export type NavigationEndEvent = CustomEvent>; 112 | 113 | export type Params = {[key: string]: string}; 114 | export type Query = {[key: string]: string}; 115 | 116 | export type EventListenerSubscription = (() => void); 117 | 118 | /** 119 | * RouterSlot related events. 120 | */ 121 | export type RouterSlotEvent = "changestate"; 122 | 123 | /** 124 | * History related events. 125 | */ 126 | export type GlobalRouterEvent = 127 | 128 | // An event triggered when a new state is added to the history. 129 | "pushstate" 130 | 131 | // An event triggered when the current state is replaced in the history. 132 | | "replacestate" 133 | 134 | // An event triggered when a state in the history is popped from the history. 135 | | "popstate" 136 | 137 | // An event triggered when the state changes (eg. pop, push and replace) 138 | | "changestate" 139 | 140 | // A cancellable event triggered before the history state changes. 141 | | "willchangestate" 142 | 143 | // An event triggered when navigation starts. 144 | | "navigationstart" 145 | 146 | // An event triggered when navigation is canceled. This is due to a route guard returning false during navigation. 147 | | "navigationcancel" 148 | 149 | // An event triggered when navigation fails due to an unexpected error. 150 | | "navigationerror" 151 | 152 | // An event triggered when navigation successfully completes. 153 | | "navigationsuccess" 154 | 155 | // An event triggered when navigation ends. 156 | | "navigationend"; 157 | 158 | export interface ISlashOptions { 159 | start: boolean; 160 | end: boolean; 161 | } 162 | 163 | /* Extend the global event handlers map with the router related events */ 164 | declare global { 165 | interface GlobalEventHandlersEventMap { 166 | "pushstate": PushStateEvent, 167 | "replacestate": ReplaceStateEvent, 168 | "popstate": PopStateEvent, 169 | "changestate": ChangeStateEvent, 170 | "navigationstart": NavigationStartEvent, 171 | "navigationend": NavigationEndEvent, 172 | "navigationsuccess": NavigationSuccessEvent, 173 | "navigationcancel": NavigationCancelEvent, 174 | "navigationerror": NavigationErrorEvent, 175 | "willchangestate": WillChangeStateEvent 176 | } 177 | } -------------------------------------------------------------------------------- /src/lib/router-link.ts: -------------------------------------------------------------------------------- 1 | import { GLOBAL_ROUTER_EVENTS_TARGET } from "./config"; 2 | import { EventListenerSubscription, GlobalRouterEvent, IRouterSlot, PathFragment } from "./model"; 3 | import { addListener, isPathActive, queryParentRouterSlot, queryString, removeListeners, slashify } from "./util"; 4 | 5 | const template = document.createElement("template"); 6 | template.innerHTML = ``; 7 | 8 | /** 9 | * Router link. 10 | * @slot - Default content. 11 | */ 12 | export class RouterLink extends HTMLElement { 13 | 14 | private listeners: EventListenerSubscription[] = []; 15 | private _context: IRouterSlot | null = null; 16 | 17 | static get observedAttributes () { 18 | return [ 19 | "disabled" 20 | ]; 21 | } 22 | 23 | /** 24 | * The path of the navigation. 25 | * @attr 26 | */ 27 | set path (value: string | PathFragment) { 28 | this.setAttribute("path", value); 29 | } 30 | 31 | get path (): string | PathFragment { 32 | return this.getAttribute("path") || "/"; 33 | } 34 | 35 | /** 36 | * Whether the element is disabled or not. 37 | * @attr 38 | */ 39 | get disabled (): boolean { 40 | return this.hasAttribute("disabled"); 41 | } 42 | 43 | set disabled (value: boolean) { 44 | value ? this.setAttribute("disabled", "") : this.removeAttribute("disabled"); 45 | } 46 | 47 | /** 48 | * Whether the element is active or not. 49 | * @attr 50 | */ 51 | get active (): boolean { 52 | return this.hasAttribute("active"); 53 | } 54 | 55 | set active (value: boolean) { 56 | value ? this.setAttribute("active", "") : this.removeAttribute("active"); 57 | } 58 | 59 | /** 60 | * Whether the focus should be delegated. 61 | * @attr 62 | */ 63 | get delegateFocus (): boolean { 64 | return this.hasAttribute("delegateFocus"); 65 | } 66 | 67 | set delegateFocus (value: boolean) { 68 | value ? this.setAttribute("delegateFocus", "") : this.removeAttribute("delegateFocus"); 69 | } 70 | 71 | /** 72 | * Whether the query should be preserved or not. 73 | * @attr 74 | */ 75 | get preserveQuery (): boolean { 76 | return this.hasAttribute("preservequery"); 77 | } 78 | 79 | set preserveQuery (value: boolean) { 80 | value ? this.setAttribute("preservequery", "") : this.removeAttribute("preservequery"); 81 | } 82 | 83 | /** 84 | * The current router slot context. 85 | */ 86 | get context (): IRouterSlot | null { 87 | return this._context; 88 | } 89 | 90 | set context (value: IRouterSlot | null) { 91 | this._context = value; 92 | } 93 | 94 | /** 95 | * Returns the absolute path. 96 | */ 97 | get absolutePath (): string { 98 | return this.constructAbsolutePath(this.path); 99 | } 100 | 101 | constructor () { 102 | super(); 103 | 104 | this.navigate = this.navigate.bind(this); 105 | this.updateActive = this.updateActive.bind(this); 106 | 107 | // Attach the template 108 | const shadow = this.attachShadow({mode: "open", delegatesFocus: this.delegateFocus}); 109 | shadow.appendChild(template.content.cloneNode(true)); 110 | } 111 | 112 | /** 113 | * Hooks up the element. 114 | */ 115 | connectedCallback () { 116 | this.listeners.push( 117 | addListener(this, "click", e => this.navigate(this.path, e)), 118 | addListener(this, "keydown", (e: KeyboardEvent) => e.code === "Enter" || e.code === "Space" ? this.navigate(this.path, e) : undefined), 119 | addListener(GLOBAL_ROUTER_EVENTS_TARGET, "navigationend", this.updateActive), 120 | addListener(GLOBAL_ROUTER_EVENTS_TARGET, "changestate", this.updateActive) 121 | ); 122 | 123 | // Query the nearest router 124 | this.context = queryParentRouterSlot(this); 125 | 126 | // Set the role to tell the rest of the world that this is a link 127 | this.setAttribute("role", "link"); 128 | 129 | // Updates the tab index if none has been set by the library user 130 | if (!this.hasAttribute("tabindex")) { 131 | this.updateTabIndex(); 132 | } 133 | } 134 | 135 | /** 136 | * Tear down listeners. 137 | */ 138 | disconnectedCallback () { 139 | removeListeners(this.listeners); 140 | } 141 | 142 | /** 143 | * Reacts to attribute changed callback. 144 | * @param name 145 | * @param oldValue 146 | * @param newValue 147 | */ 148 | attributeChangedCallback (name: string, oldValue: unknown, newValue: unknown) { 149 | 150 | // Updates the tab index when disabled changes 151 | if (name === "disabled") { 152 | this.updateTabIndex(); 153 | } 154 | } 155 | 156 | private updateTabIndex () { 157 | this.tabIndex = this.disabled ? -1 : 0; 158 | } 159 | 160 | /** 161 | * Returns the absolute path constructed relative to the context. 162 | * If no router parent was found the path property is the absolute one. 163 | */ 164 | constructAbsolutePath (path: string) { 165 | 166 | // If a router context is present, navigate relative to that one 167 | if (this.context != null) { 168 | return this.context.constructAbsolutePath(path); 169 | } 170 | 171 | return slashify(path, {end: false}); 172 | } 173 | 174 | 175 | /** 176 | * Updates whether the route is active or not. 177 | */ 178 | protected updateActive () { 179 | const active = isPathActive(this.absolutePath); 180 | if (active !== this.active) { 181 | this.active = active; 182 | } 183 | } 184 | 185 | /** 186 | * Navigates to the specified path. 187 | */ 188 | navigate (path: string, e?: Event) { 189 | 190 | // If disabled, we just prevent the navigation already now. 191 | if (e != null && this.disabled) { 192 | e.preventDefault(); 193 | e.stopPropagation(); 194 | return; 195 | } 196 | 197 | history.pushState(null, "", `${this.absolutePath}${this.preserveQuery ? queryString() : ""}`); 198 | } 199 | } 200 | 201 | window.customElements.define("router-link", RouterLink); 202 | 203 | declare global { 204 | interface HTMLElementTagNameMap { 205 | "router-link": RouterLink; 206 | } 207 | } -------------------------------------------------------------------------------- /src/lib/router-slot.ts: -------------------------------------------------------------------------------- 1 | import { GLOBAL_ROUTER_EVENTS_TARGET, ROUTER_SLOT_TAG_NAME } from "./config"; 2 | import { Cancel, EventListenerSubscription, GlobalRouterEvent, IPathFragments, IRoute, IRouteMatch, IRouterSlot, IRoutingInfo, Params, PathFragment, RouterSlotEvent } from "./model"; 3 | import { addListener, constructAbsolutePath, dispatchGlobalRouterEvent, dispatchRouteChangeEvent, ensureAnchorHistory, ensureHistoryEvents, handleRedirect, isRedirectRoute, isResolverRoute, matchRoutes, pathWithoutBasePath, queryParentRouterSlot, removeListeners, resolvePageComponent, shouldNavigate } from "./util"; 4 | 5 | const template = document.createElement("template"); 6 | template.innerHTML = ``; 7 | 8 | // Patches the history object and ensures the correct events. 9 | ensureHistoryEvents(); 10 | 11 | // Ensure the anchor tags uses the history API 12 | ensureAnchorHistory(); 13 | 14 | /** 15 | * Slot for a node in the router tree. 16 | * @slot - Default content. 17 | * @event changestate - Dispatched when the router slot state changes. 18 | */ 19 | export class RouterSlot extends HTMLElement implements IRouterSlot { 20 | 21 | /** 22 | * Listeners on the router. 23 | */ 24 | private listeners: EventListenerSubscription[] = []; 25 | 26 | /** 27 | * The available routes. 28 | */ 29 | private _routes: IRoute[] = []; 30 | get routes (): IRoute[] { 31 | return this._routes; 32 | } 33 | 34 | set routes (routes: IRoute[]) { 35 | this.clear(); 36 | this.add(routes); 37 | } 38 | 39 | /** 40 | * The parent router. 41 | * Is REQUIRED if this router is a child. 42 | * When set, the relevant listeners are added or teared down because they depend on the parent. 43 | */ 44 | _parent: IRouterSlot

| null | undefined; 45 | get parent (): IRouterSlot

| null | undefined { 46 | return this._parent; 47 | } 48 | 49 | set parent (router: IRouterSlot

| null | undefined) { 50 | this.detachListeners(); 51 | this._parent = router; 52 | this.attachListeners(); 53 | } 54 | 55 | /** 56 | * Whether the router is a root router. 57 | */ 58 | get isRoot (): boolean { 59 | return this.parent == null; 60 | } 61 | 62 | /** 63 | * The current route match. 64 | */ 65 | private _routeMatch: IRouteMatch | null = null; 66 | 67 | get match (): IRouteMatch | null { 68 | return this._routeMatch; 69 | } 70 | 71 | /** 72 | * The current route of the match. 73 | */ 74 | get route (): IRoute | null { 75 | return this.match != null ? this.match.route : null; 76 | } 77 | 78 | /** 79 | * The current path fragment of the match 80 | */ 81 | get fragments (): IPathFragments | null { 82 | return this.match != null ? this.match.fragments : null; 83 | } 84 | 85 | /** 86 | * The current params of the match. 87 | */ 88 | get params (): Params | null { 89 | return this.match != null ? this.match.params : null; 90 | } 91 | 92 | /** 93 | * Hooks up the element. 94 | */ 95 | constructor () { 96 | super(); 97 | 98 | this.render = this.render.bind(this); 99 | 100 | // Attach the template 101 | const shadow = this.attachShadow({mode: "open"}); 102 | shadow.appendChild(template.content.cloneNode(true)); 103 | } 104 | 105 | /** 106 | * Query the parent router slot when the router slot is connected. 107 | */ 108 | connectedCallback () { 109 | this.parent = this.queryParentRouterSlot(); 110 | } 111 | 112 | /** 113 | * Tears down the element. 114 | */ 115 | disconnectedCallback () { 116 | this.detachListeners(); 117 | } 118 | 119 | /** 120 | * Queries the parent router. 121 | */ 122 | queryParentRouterSlot (): IRouterSlot

| null { 123 | return queryParentRouterSlot

(this); 124 | } 125 | 126 | /** 127 | * Returns an absolute path relative to the router slot. 128 | * @param path 129 | */ 130 | constructAbsolutePath (path: PathFragment): string { 131 | return constructAbsolutePath(this, path); 132 | } 133 | 134 | /** 135 | * Adds routes to the router. 136 | * Navigates automatically if the router slot is the root and is connected. 137 | * @param routes 138 | * @param navigate 139 | */ 140 | add (routes: IRoute[], navigate: boolean = this.isRoot && this.isConnected): void { 141 | 142 | // Add the routes to the array 143 | this._routes.push(...routes); 144 | 145 | // Register that the path has changed so the correct route can be loaded. 146 | if (navigate) { 147 | this.render().then(); 148 | } 149 | } 150 | 151 | /** 152 | * Removes all routes. 153 | */ 154 | clear (): void { 155 | this._routes.length = 0; 156 | } 157 | 158 | /** 159 | * Each time the path changes, load the new path. 160 | */ 161 | async render (): Promise { 162 | 163 | // When using ShadyDOM the disconnectedCallback in the child router slot is called async 164 | // in a microtask. This means that when using the ShadyDOM polyfill, sometimes child router slots 165 | // would not clear event listeners from the parent router slots and therefore route even though 166 | // it was no longer in the DOM. The solution is to check whether the isConnected flag is false 167 | // before rendering the path. 168 | if (!this.isConnected) { 169 | return; 170 | } 171 | 172 | // Either choose the parent fragment or the current path if no parent exists. 173 | // The root router slot will always use the entire path. 174 | const pathFragment = this.parent != null && this.parent.fragments != null 175 | ? this.parent.fragments.rest 176 | : pathWithoutBasePath(); 177 | 178 | // Route to the path 179 | await this.renderPath(pathFragment); 180 | } 181 | 182 | /** 183 | * Attaches listeners, either globally or on the parent router. 184 | */ 185 | protected attachListeners (): void { 186 | 187 | // Add listeners that updates the route 188 | this.listeners.push( 189 | this.parent != null 190 | 191 | // Attach child router listeners 192 | ? addListener(this.parent, "changestate", this.render) 193 | 194 | // Add global listeners. 195 | : addListener(GLOBAL_ROUTER_EVENTS_TARGET, "changestate", this.render) 196 | ); 197 | } 198 | 199 | /** 200 | * Clears the children in the DOM. 201 | */ 202 | protected clearChildren () { 203 | while (this.firstChild != null) { 204 | this.firstChild.parentNode!.removeChild(this.firstChild); 205 | } 206 | } 207 | 208 | /** 209 | * Detaches the listeners. 210 | */ 211 | protected detachListeners (): void { 212 | removeListeners(this.listeners); 213 | } 214 | 215 | /** 216 | * Loads a new path based on the routes. 217 | * Returns true if a navigation was made to a new page. 218 | */ 219 | protected async renderPath (path: string | PathFragment): Promise { 220 | 221 | // Find the corresponding route. 222 | const match = matchRoutes(this._routes, path); 223 | 224 | // Ensure that a route was found, otherwise we just clear the current state of the route. 225 | if (match == null) { 226 | this._routeMatch = null; 227 | return false; 228 | } 229 | 230 | const {route} = match; 231 | const info: IRoutingInfo = {match, slot: this}; 232 | 233 | try { 234 | 235 | // Only change route if its a new route. 236 | const navigate = shouldNavigate(this.match, match); 237 | if (navigate) { 238 | 239 | // Listen for another push state event. If another push state event happens 240 | // while we are about to navigate we have to cancel. 241 | let navigationInvalidated = false; 242 | const cancelNavigation = () => navigationInvalidated = true; 243 | const removeChangeListener: EventListenerSubscription = addListener(GLOBAL_ROUTER_EVENTS_TARGET, "changestate", cancelNavigation, {once: true}); 244 | 245 | // Cleans up the routing by removing listeners and restoring the match from before 246 | const cleanup = () => { 247 | removeChangeListener(); 248 | }; 249 | 250 | // Cleans up and dispatches a global event that a navigation was cancelled. 251 | const cancel: Cancel = () => { 252 | cleanup(); 253 | dispatchGlobalRouterEvent("navigationcancel", info); 254 | dispatchGlobalRouterEvent("navigationend", info); 255 | return false; 256 | }; 257 | 258 | // Dispatch globally that a navigation has started 259 | dispatchGlobalRouterEvent("navigationstart", info); 260 | 261 | // Check whether the guards allow us to go to the new route. 262 | if (route.guards != null) { 263 | for (const guard of route.guards) { 264 | if (!(await guard(info))) { 265 | return cancel(); 266 | } 267 | } 268 | } 269 | 270 | // Redirect if necessary 271 | if (isRedirectRoute(route)) { 272 | cleanup(); 273 | handleRedirect(this, route); 274 | return false; 275 | } 276 | 277 | // Handle custom resolving if necessary 278 | else if (isResolverRoute(route)) { 279 | 280 | // The resolve will handle the rest of the navigation. This includes whether or not the navigation 281 | // should be cancelled. If the resolve function returns false we cancel the navigation. 282 | if ((await route.resolve(info)) === false) { 283 | return cancel(); 284 | } 285 | } 286 | 287 | // If the component provided is a function (and not a class) call the function to get the promise. 288 | else { 289 | const page = await resolvePageComponent(route, info); 290 | 291 | // Cancel the navigation if another navigation event was sent while this one was loading 292 | if (navigationInvalidated) { 293 | return cancel(); 294 | } 295 | 296 | // Remove the old page by clearing the slot 297 | this.clearChildren(); 298 | 299 | // Store the new route match before we append the new page to the DOM. 300 | // We do this to ensure that we can find the match in the connectedCallback of the page. 301 | this._routeMatch = match; 302 | 303 | // Append the new page 304 | this.appendChild(page); 305 | } 306 | 307 | // Remember to cleanup after the navigation 308 | cleanup(); 309 | } 310 | 311 | // Store the new route match 312 | this._routeMatch = match; 313 | 314 | // Always dispatch the route change event to notify the children that something happened. 315 | // This is because the child routes might have to change routes further down the tree. 316 | // The event is dispatched in an animation frame to allow route children to make the initial render first 317 | // and hook up the new router slot. 318 | requestAnimationFrame(() => { 319 | dispatchRouteChangeEvent(this, info); 320 | }); 321 | 322 | // Dispatch globally that a navigation has ended. 323 | if (navigate) { 324 | dispatchGlobalRouterEvent("navigationsuccess", info); 325 | dispatchGlobalRouterEvent("navigationend", info); 326 | } 327 | 328 | return navigate; 329 | 330 | } catch (e) { 331 | dispatchGlobalRouterEvent("navigationerror", info); 332 | dispatchGlobalRouterEvent("navigationend", info); 333 | throw e; 334 | } 335 | } 336 | } 337 | 338 | window.customElements.define(ROUTER_SLOT_TAG_NAME, RouterSlot); 339 | 340 | declare global { 341 | interface HTMLElementTagNameMap { 342 | "router-slot": RouterSlot; 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/lib/util/anchor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Hook up a click listener to the window that, for all anchor tags 3 | * that has a relative HREF, uses the history API instead. 4 | */ 5 | export function ensureAnchorHistory () { 6 | window.addEventListener("click", (e: MouseEvent) => { 7 | 8 | // Find the target by using the composed path to get the element through the shadow boundaries. 9 | const $anchor = ("composedPath" in e as any) ? e.composedPath().find($elem => $elem instanceof HTMLAnchorElement) : e.target; 10 | 11 | // Abort if the event is not about the anchor tag 12 | if ($anchor == null || !($anchor instanceof HTMLAnchorElement)) { 13 | return; 14 | } 15 | 16 | // Get the HREF value from the anchor tag 17 | const href = $anchor.href; 18 | 19 | // Only handle the anchor tag if the follow holds true: 20 | // - The HREF is relative to the origin of the current location. 21 | // - The target is targeting the current frame. 22 | // - The anchor doesn't have the attribute [data-router-slot]="disabled" 23 | if (!href.startsWith(location.origin) || 24 | ($anchor.target !== "" && $anchor.target !== "_self") || 25 | $anchor.dataset["routerSlot"] === "disabled") { 26 | return; 27 | } 28 | 29 | // Remove the origin from the start of the HREF to get the path 30 | const path = $anchor.pathname; 31 | 32 | // Prevent the default behavior 33 | e.preventDefault(); 34 | 35 | // Change the history! 36 | history.pushState(null, "", path); 37 | }); 38 | } -------------------------------------------------------------------------------- /src/lib/util/events.ts: -------------------------------------------------------------------------------- 1 | import { GLOBAL_ROUTER_EVENTS_TARGET } from "../config"; 2 | import { EventListenerSubscription, GlobalRouterEvent, IRoute, IRoutingInfo } from "../model"; 3 | 4 | /** 5 | * Dispatches a did change route event. 6 | * @param $elem 7 | * @param {IRoute} detail 8 | */ 9 | export function dispatchRouteChangeEvent ($elem: HTMLElement, detail: IRoutingInfo) { 10 | $elem.dispatchEvent(new CustomEvent("changestate", {detail})); 11 | } 12 | 13 | /** 14 | * Dispatches an event on the window object. 15 | * @param name 16 | * @param detail 17 | */ 18 | export function dispatchGlobalRouterEvent (name: GlobalRouterEvent, detail?: IRoutingInfo) { 19 | GLOBAL_ROUTER_EVENTS_TARGET.dispatchEvent(new CustomEvent(name, {detail})); 20 | // if ("debugRouterSlot" in window) { 21 | // console.log(`%c [router-slot]: ${name}`, `color: #286ee0`, detail); 22 | // } 23 | } 24 | 25 | /** 26 | * Adds an event listener (or more) to an element and returns a function to unsubscribe. 27 | * @param $elem 28 | * @param type 29 | * @param listener 30 | * @param options 31 | */ 32 | export function addListener ($elem: EventTarget, 33 | type: eventType[] | eventType, 34 | listener: ((e: T) => void), 35 | options?: boolean | AddEventListenerOptions): EventListenerSubscription { 36 | const types = Array.isArray(type) ? type : [type]; 37 | types.forEach(t => $elem.addEventListener(t, listener as EventListenerOrEventListenerObject, options)); 38 | return () => types.forEach( 39 | t => $elem.removeEventListener(t, listener as EventListenerOrEventListenerObject, options)); 40 | } 41 | 42 | 43 | /** 44 | * Removes the event listeners in the array. 45 | * @param listeners 46 | */ 47 | export function removeListeners (listeners: EventListenerSubscription[]) { 48 | listeners.forEach(unsub => unsub()); 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/util/history.ts: -------------------------------------------------------------------------------- 1 | import { GLOBAL_ROUTER_EVENTS_TARGET, HISTORY_PATCH_NATIVE_KEY } from "../config"; 2 | import { GlobalRouterEvent } from "../model"; 3 | import { dispatchGlobalRouterEvent } from "./events"; 4 | 5 | // Mapping a history functions to the events they are going to dispatch. 6 | export const historyPatches: [string, GlobalRouterEvent[]][] = [ 7 | ["pushState", ["pushstate", "changestate"]], 8 | ["replaceState", ["replacestate", "changestate"]], 9 | ["forward", ["pushstate", "changestate"]], 10 | ["go", ["pushstate", "changestate"]], 11 | 12 | // We need to handle the popstate a little differently when it comes to the change state event. 13 | ["back", ["popstate"]], 14 | ]; 15 | 16 | 17 | /** 18 | * Patches the history object by ensuring correct events are dispatches when the history changes. 19 | */ 20 | export function ensureHistoryEvents() { 21 | for (const [name, events] of historyPatches) { 22 | for (const event of events) { 23 | attachCallback(history, name, event); 24 | } 25 | } 26 | 27 | // The popstate is the only event natively dispatched when using the hardware buttons. 28 | // Therefore we need to handle this case a little different. To ensure the changestate event 29 | // is fired also when the hardware back button is used, we make sure to listen for the popstate 30 | // event and dispatch a change state event right after. The reason for the setTimeout is because we 31 | // want the popstate event to bubble up before the changestate event is dispatched. 32 | window.addEventListener("popstate", (e: PopStateEvent) => { 33 | 34 | // Check if the state should be allowed to change 35 | if (shouldCancelChangeState({eventName: "popstate"})) { 36 | e.preventDefault(); 37 | e.stopPropagation(); 38 | return; 39 | } 40 | 41 | // Dispatch the global router event to change the routes after the popstate has bubbled up 42 | setTimeout(() => dispatchGlobalRouterEvent("changestate"), 0); 43 | } 44 | ); 45 | } 46 | 47 | /** 48 | * Attaches a global router event after the native function on the object has been invoked. 49 | * Stores the original function at the _name. 50 | * @param obj 51 | * @param functionName 52 | * @param eventName 53 | */ 54 | export function attachCallback(obj: any, functionName: string, eventName: GlobalRouterEvent) { 55 | const func = obj[functionName]; 56 | saveNativeFunction(obj, functionName, func); 57 | obj[functionName] = (...args: any[]) => { 58 | 59 | // If its push/replace state we want to send the url to the should cancel change state event 60 | const url = args.length > 2 ? args[2] : null; 61 | 62 | // Check if the state should be allowed to change 63 | if (shouldCancelChangeState({url, eventName})) return; 64 | 65 | // Navigate 66 | func.apply(obj, args); 67 | dispatchGlobalRouterEvent(eventName) 68 | }; 69 | } 70 | 71 | /** 72 | * Saves the native function on the history object. 73 | * @param obj 74 | * @param name 75 | * @param func 76 | */ 77 | export function saveNativeFunction(obj: any, name: string, func: (() => void)) { 78 | 79 | // Ensure that the native object exists. 80 | if (obj[HISTORY_PATCH_NATIVE_KEY] == null) { 81 | obj[HISTORY_PATCH_NATIVE_KEY] = {}; 82 | } 83 | 84 | // Save the native function. 85 | obj[HISTORY_PATCH_NATIVE_KEY][`${name}`] = func.bind(obj); 86 | } 87 | 88 | /** 89 | * Dispatches and event and returns whether the state change should be cancelled. 90 | * The state will be considered as cancelled if the "willChangeState" event was cancelled. 91 | */ 92 | function shouldCancelChangeState(data: { url?: string | null, eventName: GlobalRouterEvent }): boolean { 93 | return !GLOBAL_ROUTER_EVENTS_TARGET.dispatchEvent(new CustomEvent("willchangestate", { 94 | cancelable: true, 95 | detail: data 96 | })); 97 | } 98 | 99 | // Expose the native history functions. 100 | declare global { 101 | interface History { 102 | "native": { 103 | "back": ((distance?: any) => void); 104 | "forward": ((distance?: any) => void); 105 | "go": ((delta?: any) => void); 106 | "pushState": ((data: any, title?: string, url?: string | null) => void); 107 | "replaceState": ((data: any, title?: string, url?: string | null) => void); 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /src/lib/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./events"; 2 | export * from "./history"; 3 | export * from "./router"; 4 | export * from "./shadow"; 5 | export * from "./url"; 6 | export * from "./anchor"; -------------------------------------------------------------------------------- /src/lib/util/router.ts: -------------------------------------------------------------------------------- 1 | import { CATCH_ALL_WILDCARD, DEFAULT_PATH_MATCH, PARAM_IDENTIFIER, TRAVERSE_FLAG } from "../config"; 2 | import { IComponentRoute, IRedirectRoute, IResolverRoute, IRoute, IRouteMatch, IRouterSlot, ModuleResolver, PageComponent, Params, PathFragment, RouterTree, IRoutingInfo } from "../model"; 3 | import { constructPathWithBasePath, path as getPath, queryString, stripSlash } from "./url"; 4 | 5 | /** 6 | * Determines whether the path is active. 7 | * If the full path starts with the path and is followed by the end of the string or a "/" the path is considered active. 8 | * @param path 9 | * @param fullPath 10 | */ 11 | export function isPathActive (path: string | PathFragment, fullPath: string = getPath()): boolean { 12 | return new RegExp(`^${stripSlash(path)}(\/|$)`, "gm").test(stripSlash(fullPath)); 13 | } 14 | 15 | /** 16 | * Matches a route. 17 | * @param route 18 | * @param path 19 | */ 20 | export function matchRoute (route: IRoute, path: string | PathFragment): IRouteMatch | null { 21 | 22 | // We start by preparing the route path by replacing the param names with a regex that matches everything 23 | // until either the end of the path or the next "/". While replacing the param placeholders we make sure 24 | // to store the names of the param placeholders. 25 | const paramNames: string[] = []; 26 | const routePath = stripSlash(route.path.replace(PARAM_IDENTIFIER, (substring: string, ...args: string[]) => { 27 | paramNames.push(args[0]); 28 | return `([^\/]+)`; 29 | })); 30 | 31 | // Construct the regex to match with the path or fragment 32 | // If path is wildcard: 33 | // - We match with /^/ to not consume any characters. 34 | // If path is empty and pathmatch is not full 35 | // - We match with /^/ to not consume any characters. 36 | // If pathmatch is prefix 37 | // - We start the match with [/]? to allow a slash in front of the path. 38 | // - We end the match with (?:/|$) to make sure the match ends at either the end of the fragment or end of the path. 39 | // If pathmatch is suffix: 40 | // - We start the match with .*? to allow anything to be in front of what we are trying to match. 41 | // - We end the match with $ to make sure the match ends at the end of the path. 42 | // If pathmatch is full: 43 | // - We end the match with $ to make sure the match ends at the end of the path. 44 | // If pathmatch is fuzzy 45 | // - We start the match with .*? to allow anything to be in front of what we are trying to match. 46 | // - We end the match with .*? to allow anything to be after what we are trying to match. 47 | // All matches starts with ^ to make sure the match is done from the beginning of the path. 48 | const regex = route.path === CATCH_ALL_WILDCARD || (route.path.length === 0 && route.pathMatch != "full" ) ? /^/ : (() => { 49 | switch (route.pathMatch || DEFAULT_PATH_MATCH) { 50 | case "full": return new RegExp(`^${routePath}\/?$`); 51 | case "suffix": return new RegExp(`^.*?${routePath}\/?$`); 52 | case "fuzzy": return new RegExp(`^.*?${routePath}.*?$`); 53 | case "prefix": default: return new RegExp(`^[\/]?${routePath}(?:\/|$)`); 54 | } 55 | })(); 56 | 57 | // Check if there's a match 58 | const match = path.match(regex); 59 | if (match != null) { 60 | 61 | // Match the param names with the matches. The matches starts from index 1 which is the 62 | // reason why we add 1. match[0] is the entire string. 63 | const params = paramNames.reduce((acc: Params, name: string, i: number) => { 64 | acc[name] = match[i + 1]; 65 | return acc; 66 | }, {}); 67 | 68 | // Split up the path into two fragments: the one consumed and the rest. 69 | const consumed = stripSlash(path.slice(0, match[0].length)); 70 | const rest = stripSlash(path.slice(match[0].length, path.length)); 71 | 72 | return { 73 | route, 74 | match, 75 | params, 76 | fragments: { 77 | consumed, 78 | rest 79 | } 80 | }; 81 | } 82 | 83 | 84 | return null; 85 | } 86 | 87 | /** 88 | * Matches the first route that matches the given path. 89 | * @param routes 90 | * @param path 91 | */ 92 | export function matchRoutes (routes: IRoute[], path: string | PathFragment): IRouteMatch | null { 93 | for (const route of routes) { 94 | const match = matchRoute(route, path); 95 | if (match != null) { 96 | return match; 97 | } 98 | } 99 | 100 | return null; 101 | } 102 | 103 | /** 104 | * Returns the page from the route. 105 | * If the component provided is a function (and not a class) call the function to get the promise. 106 | * @param route 107 | * @param info 108 | */ 109 | export async function resolvePageComponent (route: IComponentRoute, info: IRoutingInfo): Promise { 110 | 111 | // Figure out if the component were given as an import or class. 112 | let cmp = route.component; 113 | if (cmp instanceof Function) { 114 | try { 115 | cmp = (cmp as Function)(); 116 | } catch (err) { 117 | 118 | // The invocation most likely failed because the function is a class. 119 | // If it failed due to the "new" keyword not being used, the error will be of type "TypeError". 120 | // This is the most reliable way to check whether the provided function is a class or a function. 121 | if (!(err instanceof TypeError)) { 122 | throw err; 123 | } 124 | } 125 | } 126 | 127 | // Load the module or component. 128 | const moduleClassOrPage = await Promise.resolve(cmp); 129 | 130 | // Instantiate the component 131 | let component!: PageComponent; 132 | if (!(moduleClassOrPage instanceof HTMLElement)) { 133 | component = new (moduleClassOrPage.default ? moduleClassOrPage.default : moduleClassOrPage)() as PageComponent; 134 | } else { 135 | component = cmp as PageComponent; 136 | } 137 | 138 | // Setup the component using the callback. 139 | if (route.setup != null) { 140 | route.setup(component, info); 141 | } 142 | 143 | return component; 144 | } 145 | 146 | /** 147 | * Determines if a route is a redirect route. 148 | * @param route 149 | */ 150 | export function isRedirectRoute (route: IRoute): route is IRedirectRoute { 151 | return "redirectTo" in route; 152 | } 153 | 154 | /** 155 | * Determines if a route is a resolver route. 156 | * @param route 157 | */ 158 | export function isResolverRoute (route: IRoute): route is IResolverRoute { 159 | return "resolve" in route; 160 | } 161 | 162 | /** 163 | * Traverses the router tree up to the root route. 164 | * @param slot 165 | */ 166 | export function traverseRouterTree (slot: IRouterSlot): {tree: RouterTree, depth: number} { 167 | 168 | // Find the nodes from the route up to the root route 169 | let routes: IRouterSlot[] = [slot]; 170 | while (slot.parent != null) { 171 | slot = slot.parent; 172 | routes.push(slot); 173 | } 174 | 175 | // Create the tree 176 | const tree: RouterTree = routes.reduce((acc: RouterTree, slot: IRouterSlot) => { 177 | return {slot, child: acc}; 178 | }, undefined); 179 | 180 | const depth = routes.length; 181 | 182 | return {tree, depth}; 183 | } 184 | 185 | /** 186 | * Generates a path based on the router tree. 187 | * @param tree 188 | * @param depth 189 | */ 190 | export function getFragments (tree: RouterTree, depth: number): PathFragment[] { 191 | let child = tree; 192 | const fragments: PathFragment[] = []; 193 | 194 | // Look through all of the path fragments 195 | while (child != null && child.slot.match != null && depth > 0) { 196 | fragments.push(child.slot.match.fragments.consumed); 197 | child = child.child; 198 | depth--; 199 | } 200 | 201 | return fragments; 202 | } 203 | 204 | /** 205 | * Constructs the correct absolute path based on a router. 206 | * - Handles relative paths: "mypath" 207 | * - Handles absolute paths: "/mypath" 208 | * - Handles traversing paths: "../../mypath" 209 | * @param slot 210 | * @param path 211 | */ 212 | export function constructAbsolutePath (slot: IRouterSlot, 213 | path: string | PathFragment = ""): string { 214 | 215 | // Grab the router tree 216 | const {tree, depth} = traverseRouterTree(slot); 217 | 218 | // If the path starts with "/" we treat it as an absolute path 219 | // and therefore don't continue because it is already absolute. 220 | if (!path.startsWith("/")) { 221 | let traverseDepth = 0; 222 | 223 | // If the path starts with "./" we can remove that part 224 | // because we know the path is relative to its route. 225 | if (path.startsWith("./")) { 226 | path = path.slice(2); 227 | } 228 | 229 | // Match with the traverse flag. 230 | const match = path.match(new RegExp(TRAVERSE_FLAG, "g")); 231 | if (match != null) { 232 | 233 | // If the path matched with the traverse flag we know that we have to construct 234 | // a route until a certain depth. The traverse depth is the amount of "../" in the path 235 | // and the depth is the part of the path we a slicing away. 236 | traverseDepth = match.length; 237 | 238 | // Count the amount of characters that the matches add up to and remove it from the path. 239 | const length = match.reduce((acc: number, m: string) => acc + m.length, 0); 240 | path = path.slice(length); 241 | } 242 | 243 | // Grab the fragments and construct the new path, taking the traverse depth into account. 244 | // Always subtract at least 1 because we the path is relative to its parent. 245 | // Filter away the empty fragments from the path. 246 | const fragments = getFragments(tree, depth - 1 - traverseDepth).filter(fragment => fragment.length > 0); 247 | path = `${fragments.join("/")}${fragments.length > 0 ? "/" : ""}${path}`; 248 | } 249 | 250 | // Add the base path in front of the path. If the path is already absolute, the path wont get the base path added. 251 | return constructPathWithBasePath(path, {end: false}); 252 | } 253 | 254 | /** 255 | * Handles a redirect. 256 | * @param slot 257 | * @param route 258 | */ 259 | export function handleRedirect (slot: IRouterSlot, route: IRedirectRoute) { 260 | history.replaceState(history.state, "", `${constructAbsolutePath(slot, route.redirectTo)}${route.preserveQuery ? queryString() : ""}`); 261 | } 262 | 263 | /** 264 | * Determines whether the navigation should start based on the current match and the new match. 265 | * @param currentMatch 266 | * @param newMatch 267 | */ 268 | export function shouldNavigate (currentMatch: IRouteMatch | null, newMatch: IRouteMatch) { 269 | 270 | // If the current match is not defined we should always route. 271 | if (currentMatch == null) { 272 | return true; 273 | } 274 | 275 | // Extract information about the matches 276 | const {route: currentRoute, fragments: currentFragments} = currentMatch; 277 | const {route: newRoute, fragments: newFragments} = newMatch; 278 | 279 | const isSameRoute = currentRoute == newRoute; 280 | const isSameFragments = currentFragments.consumed == newFragments.consumed; 281 | 282 | // Only navigate if the URL consumption is new or if the two routes are no longer the same. 283 | return !isSameFragments || !isSameRoute; 284 | } -------------------------------------------------------------------------------- /src/lib/util/shadow.ts: -------------------------------------------------------------------------------- 1 | import { ROUTER_SLOT_TAG_NAME } from "../config"; 2 | import { IRouterSlot } from "../model"; 3 | 4 | /** 5 | * Queries the parent router. 6 | * @param $elem 7 | */ 8 | export function queryParentRouterSlot ($elem: Element): IRouterSlot | null { 9 | return queryParentRoots>($elem, ROUTER_SLOT_TAG_NAME); 10 | } 11 | 12 | /** 13 | * Traverses the roots and returns the first match. 14 | * The minRoots parameter indicates how many roots should be traversed before we started matching with the query. 15 | * @param $elem 16 | * @param query 17 | * @param minRoots 18 | * @param roots 19 | */ 20 | export function queryParentRoots ($elem: Element, query: string, minRoots: number = 0, roots: number = 0): T | null { 21 | 22 | // Grab the rood node and query it 23 | const $root = ($elem).getRootNode(); 24 | 25 | // If we are at the right level or above we can query! 26 | if (roots >= minRoots) { 27 | 28 | // See if there's a match 29 | const match = $root.querySelector(query); 30 | if (match != null && match != $elem) { 31 | return match; 32 | } 33 | } 34 | 35 | // If a parent root with a host doesn't exist we don't continue the traversal 36 | const $rootRootNode = $root.getRootNode(); 37 | if ($rootRootNode.host == null) { 38 | return null; 39 | } 40 | 41 | // We continue the traversal if there was not matches 42 | return queryParentRoots($rootRootNode.host, query, minRoots, ++roots); 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/util/url.ts: -------------------------------------------------------------------------------- 1 | import { ISlashOptions, Params, Query } from "../model"; 2 | 3 | const $anchor = document.createElement("a"); 4 | 5 | /** 6 | * The current path of the location. 7 | * As default slashes are included at the start and end. 8 | * @param options 9 | */ 10 | export function path (options: Partial = {}): string { 11 | return slashify(window.location.pathname, options); 12 | } 13 | 14 | /** 15 | * Returns the path without the base path. 16 | * @param options 17 | */ 18 | export function pathWithoutBasePath (options: Partial = {}): string { 19 | return slashify(stripStart(path(), basePath()), options); 20 | } 21 | 22 | /** 23 | * Returns the base path as defined in the tag in the head in a reliable way. 24 | * If eg. is defined this function will return "/router-slot/". 25 | * 26 | * An alternative would be to use regex on document.baseURI, 27 | * but that will be unreliable in some cases because it 28 | * doesn't use the built in HTMLHyperlinkElementUtils. 29 | * 30 | * To make this method more performant we could cache the anchor element. 31 | * As default it will return the base path with slashes in front and at the end. 32 | */ 33 | export function basePath (options: Partial = {}): string { 34 | return constructPathWithBasePath(".", options); 35 | } 36 | 37 | /** 38 | * Creates an URL using the built in HTMLHyperlinkElementUtils. 39 | * An alternative would be to use regex on document.baseURI, 40 | * but that will be unreliable in some cases because it 41 | * doesn't use the built in HTMLHyperlinkElementUtils. 42 | * 43 | * As default it will return the base path with slashes in front and at the end. 44 | * @param path 45 | * @param options 46 | */ 47 | export function constructPathWithBasePath (path: string, options: Partial = {}) { 48 | $anchor.href = path; 49 | return slashify($anchor.pathname, options); 50 | } 51 | 52 | /** 53 | * Removes the start of the path that matches the part. 54 | * @param path 55 | * @param part 56 | */ 57 | export function stripStart (path: string, part: string) { 58 | return path.replace(new RegExp(`^${part}`), ""); 59 | } 60 | 61 | /** 62 | * Returns the query string. 63 | */ 64 | export function queryString (): string { 65 | return window.location.search; 66 | } 67 | 68 | /** 69 | * Returns the params for the current path. 70 | * @returns Params 71 | */ 72 | export function query (): Query { 73 | return toQuery(queryString().substr(1)); 74 | } 75 | 76 | /** 77 | * Strips the slash from the start and end of a path. 78 | * @param path 79 | */ 80 | export function stripSlash (path: string): string { 81 | return slashify(path, {start: false, end: false}); 82 | } 83 | 84 | /** 85 | * Ensures the path starts and ends with a slash 86 | * @param path 87 | */ 88 | export function ensureSlash (path: string): string { 89 | return slashify(path, {start: true, end: true}); 90 | } 91 | 92 | /** 93 | * Makes sure that the start and end slashes are present or not depending on the options. 94 | * @param path 95 | * @param start 96 | * @param end 97 | */ 98 | export function slashify (path: string, {start = true, end = true}: Partial = {}): string { 99 | path = start && !path.startsWith("/") ? `/${path}` : (!start && path.startsWith("/") ? path.slice(1) : path); 100 | return end && !path.endsWith("/") ? `${path}/` : (!end && path.endsWith("/") ? path.slice(0, path.length - 1) : path); 101 | } 102 | 103 | /** 104 | * Turns a query string into an object. 105 | * @param {string} queryString (example: ("test=123&hejsa=LOL&wuhuu")) 106 | * @returns {Query} 107 | */ 108 | export function toQuery (queryString: string): Query { 109 | 110 | // If the query does not contain anything, return an empty object. 111 | if (queryString.length === 0) { 112 | return {}; 113 | } 114 | 115 | // Grab the atoms (["test=123", "hejsa=LOL", "wuhuu"]) 116 | const atoms = queryString.split("&"); 117 | 118 | // Split by the values ([["test", "123"], ["hejsa", "LOL"], ["wuhuu"]]) 119 | const arrayMap = atoms.map(atom => atom.split("=")); 120 | 121 | // Assign the values to an object ({ test: "123", hejsa: "LOL", wuhuu: "" }) 122 | return Object.assign({}, ...arrayMap.map(arr => ({ 123 | [decodeURIComponent(arr[0])]: (arr.length > 1 ? decodeURIComponent(arr[1]) : "") 124 | }))); 125 | } 126 | 127 | /** 128 | * Turns a query object into a string query. 129 | * @param query 130 | */ 131 | export function toQueryString (query: Query): string { 132 | return Object.entries(query) 133 | .map(([key, value]) => `${key}${value != "" ? `=${encodeURIComponent(value)}` : ""}`) 134 | .join("&"); 135 | } 136 | -------------------------------------------------------------------------------- /src/test/anchor.test.ts: -------------------------------------------------------------------------------- 1 | import { ensureAnchorHistory } from "../lib/util/anchor"; 2 | import { ensureHistoryEvents } from "../lib/util/history"; 3 | import { path } from "../lib/util/url"; 4 | import { addBaseTag, clearHistory } from "./test-helpers"; 5 | 6 | const testPath = `/about`; 7 | 8 | describe("anchor", () => { 9 | const {expect} = chai; 10 | let $anchor!: HTMLAnchorElement; 11 | 12 | before(() => { 13 | ensureHistoryEvents(); 14 | ensureAnchorHistory(); 15 | addBaseTag(); 16 | }); 17 | beforeEach(() => { 18 | document.body.innerHTML = ` 19 | Anchor 20 | `; 21 | 22 | $anchor = document.body.querySelector("#anchor")!; 23 | }); 24 | after(() => { 25 | clearHistory(); 26 | }); 27 | 28 | it("[ensureAnchorHistory] should change anchors to use history API", done => { 29 | window.addEventListener("pushstate", () => { 30 | expect(path({end: false})).to.equal(testPath); 31 | done(); 32 | }); 33 | 34 | $anchor.click(); 35 | }); 36 | 37 | it("[ensureAnchorHistory] should not change anchors with target _blank", done => { 38 | window.addEventListener("pushstate", () => { 39 | expect(true).to.equal(false); 40 | }); 41 | 42 | $anchor.target = "_blank"; 43 | $anchor.click(); 44 | done(); 45 | }); 46 | 47 | it("[ensureAnchorHistory] should not change anchors with [data-router-slot]='disabled'", done => { 48 | window.addEventListener("pushstate", () => { 49 | expect(true).to.equal(false); 50 | }); 51 | 52 | $anchor.setAttribute("data-router-slot", "disabled"); 53 | $anchor.click(); 54 | done(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/test/history.test.ts: -------------------------------------------------------------------------------- 1 | import { GLOBAL_ROUTER_EVENTS_TARGET } from "../lib/config"; 2 | import { ensureHistoryEvents, historyPatches } from "../lib/util/history"; 3 | import { clearHistory } from "./test-helpers"; 4 | 5 | describe("history", () => { 6 | before(() => { 7 | ensureHistoryEvents(); 8 | }); 9 | beforeEach(() => { 10 | }); 11 | after(() => { 12 | clearHistory(); 13 | }); 14 | 15 | it("[ensureHistoryEvents] should patch history object", (done) => { 16 | const expectedEventCount = historyPatches.reduce((acc, patch) => acc + patch[1].length, 0); 17 | let eventCount = 0; 18 | 19 | // Checks whether the amount of events that have been called is correct. 20 | const testExpectedEventCount = () => { 21 | if (eventCount >= expectedEventCount) { 22 | done(); 23 | } 24 | }; 25 | 26 | // Hook up expected events 27 | for (const [name, events] of historyPatches) { 28 | for (const event of events) { 29 | GLOBAL_ROUTER_EVENTS_TARGET.addEventListener(event, () => { 30 | eventCount += 1; 31 | testExpectedEventCount(); 32 | }, {once: true}); 33 | } 34 | } 35 | 36 | // Dispatch events with garbage data (the data doesn't matter) 37 | for (const [name] of historyPatches) { 38 | (history)[name](...["", "", ""]); 39 | } 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/test/router-slot.test.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, PropertyValues } from "lit"; 2 | import { customElement, query } from "lit/decorators.js"; 3 | import { IRoute } from "../lib/model"; 4 | import { RouterSlot } from "../lib/router-slot"; 5 | import "../lib/router-slot"; 6 | import { ensureHistoryEvents } from "../lib/util/history"; 7 | import { traverseRouterTree } from "../lib/util/router"; 8 | import { queryParentRouterSlot } from "../lib/util/shadow"; 9 | import { path } from "../lib/util/url"; 10 | import { clearHistory } from "./test-helpers"; 11 | 12 | class RouterElement extends LitElement { 13 | @query("#slot") $slot!: RouterSlot; 14 | 15 | protected routes!: IRoute[]; 16 | 17 | firstUpdated(props: PropertyValues) { 18 | super.firstUpdated(props); 19 | this.$slot.add(this.routes); 20 | } 21 | 22 | render() { 23 | return html` 24 | 25 | `; 26 | } 27 | } 28 | 29 | @customElement("leaf-element") 30 | class LeafElement extends LitElement { 31 | render() { 32 | return html` 33 | Leaf 34 | `; 35 | } 36 | } 37 | 38 | const pageOneRoutes: IRoute[] = [ 39 | { 40 | path: "leaf-one", 41 | component: LeafElement 42 | }, 43 | { 44 | path: "**", 45 | redirectTo: "leaf-one" 46 | } 47 | ]; 48 | 49 | @customElement("page-one") 50 | class PageOne extends RouterElement { 51 | routes = pageOneRoutes; 52 | } 53 | 54 | const pageTwoRoutes: IRoute[] = [ 55 | { 56 | path: "leaf-two", 57 | component: LeafElement 58 | }, 59 | { 60 | path: "**", 61 | redirectTo: "leaf-two" 62 | } 63 | ]; 64 | 65 | @customElement("page-two") 66 | class PageTwo extends RouterElement { 67 | routes = pageTwoRoutes; 68 | } 69 | 70 | // Main routes 71 | const mainRoutes: IRoute[] = [ 72 | { 73 | path: "one", 74 | component: PageOne 75 | }, 76 | { 77 | path: "two/:id", 78 | component: PageTwo 79 | }, 80 | { 81 | path: "**", 82 | redirectTo: "one" 83 | } 84 | ]; 85 | 86 | @customElement("root-element") 87 | class RootElement extends RouterElement { 88 | routes = mainRoutes; 89 | } 90 | 91 | describe("router-slot", () => { 92 | const {expect} = chai; 93 | let $root!: RootElement; 94 | 95 | before(() => { 96 | ensureHistoryEvents(); 97 | 98 | const $base = document.createElement("base"); 99 | $base.href = `/`; 100 | document.head.appendChild($base); 101 | }); 102 | beforeEach(() => { 103 | document.body.innerHTML = ` 104 | 105 | `; 106 | 107 | $root = document.body.querySelector("root-element")!; 108 | }); 109 | after(() => { 110 | clearHistory(); 111 | }); 112 | 113 | // TODO: Listen for events and do this more elegant 114 | function waitForNavigation(cb: (() => void)) { 115 | setTimeout(cb, 100); 116 | } 117 | 118 | it("should redirect properly down the router tree", () => { 119 | waitForNavigation(() => { 120 | expect(path()).to.equal(`/one/leaf-one/`); 121 | }); 122 | }); 123 | 124 | it("should have correct isRoot value", (done) => { 125 | waitForNavigation(() => { 126 | const $pageOne = $root.$slot.querySelector("page-one")!; 127 | 128 | expect($root.$slot.isRoot).to.be.true; 129 | expect($pageOne.$slot.isRoot).to.be.false; 130 | done(); 131 | }); 132 | }); 133 | 134 | it("should find correct parent router slots", (done) => { 135 | waitForNavigation(() => { 136 | const $pageOne = $root.$slot.querySelector("page-one")!; 137 | const $leafElement = $pageOne.$slot.querySelector("leaf-element")!; 138 | 139 | expect(queryParentRouterSlot($leafElement)).to.equal($pageOne.$slot); 140 | expect(queryParentRouterSlot($pageOne)).to.equal($root.$slot); 141 | done(); 142 | }); 143 | }); 144 | 145 | it("should construct correct router tree", (done) => { 146 | waitForNavigation(() => { 147 | const $pageOne = $root.$slot.querySelector("page-one")!; 148 | 149 | expect(traverseRouterTree($pageOne.$slot).depth).to.equal(2); 150 | expect(traverseRouterTree($root.$slot).depth).to.equal(1); 151 | done(); 152 | }); 153 | }); 154 | 155 | it("should pick up params", (done) => { 156 | waitForNavigation(() => { 157 | const param = "1234"; 158 | history.pushState(null, "", `two/${param}`); 159 | 160 | waitForNavigation(() => { 161 | expect(path()).to.equal(`/two/${param}/leaf-two/`); 162 | expect(JSON.stringify($root.$slot.params)).to.equal(JSON.stringify({id: param})); 163 | done(); 164 | }); 165 | }); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /src/test/router.test.ts: -------------------------------------------------------------------------------- 1 | import { IRoute, IRouteMatch } from "../lib/model"; 2 | import { matchRoute } from "../lib/util/router"; 3 | 4 | const component = document.createElement("div"); 5 | const TEST_CASES: {route: IRoute, path: string, expectedMatch: IRouteMatch | null | any}[] = [ 6 | { 7 | route: { 8 | path: "**", 9 | redirectTo: "404" 10 | }, 11 | path: "wrong/path", 12 | expectedMatch: { 13 | "route": { 14 | "path": "**", 15 | "redirectTo": "404" 16 | }, 17 | "match": [ 18 | "" 19 | ], 20 | "params": {}, 21 | "fragments": { 22 | "consumed": "", 23 | "rest": "wrong/path" 24 | } 25 | } 26 | }, 27 | { 28 | route: { 29 | path: "home", 30 | component 31 | }, 32 | path: "home", 33 | expectedMatch: { 34 | "route": { 35 | "path": "home", 36 | "component": {} 37 | }, 38 | "match": [ 39 | "home" 40 | ], 41 | "params": {}, 42 | "fragments": { 43 | "consumed": "home", 44 | "rest": "" 45 | } 46 | } 47 | }, 48 | { 49 | route: { 50 | path: "user/:id/edit", 51 | component 52 | }, 53 | path: "user/1234/edit", 54 | expectedMatch: { 55 | "route": { 56 | "path": "user/:id/edit", 57 | "component": {} 58 | }, 59 | "match": [ 60 | "user/1234/edit", 61 | "1234" 62 | ], 63 | "params": { 64 | "id": "1234" 65 | }, 66 | "fragments": { 67 | "consumed": "user/1234/edit", 68 | "rest": "" 69 | } 70 | } 71 | }, 72 | { 73 | route: { 74 | path: "", 75 | component 76 | }, 77 | path: "test", 78 | expectedMatch: { 79 | "route": { 80 | "path": "", 81 | "component": {} 82 | }, 83 | "match": [ 84 | "" 85 | ], 86 | "params": {}, 87 | "fragments": { 88 | "consumed": "", 89 | "rest": "test" 90 | } 91 | } 92 | }, 93 | { 94 | route: { 95 | path: "", 96 | component 97 | }, 98 | path: "/test", 99 | expectedMatch: { 100 | "route": { 101 | "path": "", 102 | "component": {} 103 | }, 104 | "match": [ 105 | "" 106 | ], 107 | "params": {}, 108 | "fragments": { 109 | "consumed": "", 110 | "rest": "test" 111 | } 112 | } 113 | }, 114 | { 115 | route: { 116 | path: "", 117 | component 118 | }, 119 | path: "test", 120 | expectedMatch: { 121 | "route": { 122 | "path": "", 123 | "component": {} 124 | }, 125 | "match": [ 126 | "" 127 | ], 128 | "params": {}, 129 | "fragments": { 130 | "consumed": "", 131 | "rest": "test" 132 | } 133 | } 134 | }, 135 | { 136 | route: { 137 | path: "", 138 | pathMatch: "full", 139 | component 140 | }, 141 | path: "test", 142 | expectedMatch: null 143 | }, 144 | { 145 | route: { 146 | path: "overview", 147 | pathMatch: "suffix", 148 | component 149 | }, 150 | path: "home/overview", 151 | expectedMatch: { 152 | "route": { 153 | "path": "overview", 154 | "pathMatch": "suffix", 155 | "component": {}, 156 | }, 157 | "match": [ 158 | "home/overview" 159 | ], 160 | "params": {}, 161 | "fragments": { 162 | "consumed": "home/overview", 163 | "rest": "" 164 | } 165 | } 166 | }, 167 | { 168 | route: { 169 | path: "manage", 170 | pathMatch: "fuzzy", 171 | component 172 | }, 173 | path: "users/manage/invite", 174 | expectedMatch: { 175 | "route": { 176 | "path": "manage", 177 | "pathMatch": "fuzzy", 178 | "component": {} 179 | }, 180 | "match": [ 181 | "users/manage/invite" 182 | ], 183 | "params": {}, 184 | "fragments": { 185 | "consumed": "users/manage/invite", 186 | "rest": "" 187 | } 188 | } 189 | }, 190 | ]; 191 | 192 | describe("router", () => { 193 | const {expect} = chai; 194 | 195 | it("[matchRoute] should match the correct route", () => { 196 | for (const {route, path, expectedMatch} of TEST_CASES) { 197 | const match = matchRoute(route, path); 198 | expect(JSON.stringify(match)).to.equal(JSON.stringify(expectedMatch)); 199 | } 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /src/test/test-helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Clears the entire history. 3 | */ 4 | export function clearHistory () { 5 | const length = history.length; 6 | for (let i = 0; i < length; i++) { 7 | history.back(); 8 | } 9 | } 10 | 11 | /** 12 | * Add base element to head. 13 | * @param path 14 | */ 15 | export function addBaseTag (path: string = "/") { 16 | const $base = document.createElement("base"); 17 | $base.href = `/`; 18 | document.head.appendChild($base); 19 | return $base; 20 | } 21 | 22 | /** 23 | * Wait X ms. 24 | * @param ms 25 | */ 26 | export function wait (ms: number) { 27 | return new Promise(res => setTimeout(res, ms)); 28 | } -------------------------------------------------------------------------------- /src/test/url.test.ts: -------------------------------------------------------------------------------- 1 | import { basePath, path, query, toQuery, toQueryString } from "../lib/util/url"; 2 | import { addBaseTag, clearHistory } from "./test-helpers"; 3 | 4 | describe("url", () => { 5 | const {expect} = chai; 6 | let $base: HTMLBaseElement; 7 | 8 | before(() => { 9 | $base = addBaseTag(); 10 | }); 11 | beforeEach(async () => { 12 | $base.href = `/`; 13 | }); 14 | after(() => { 15 | clearHistory(); 16 | }); 17 | 18 | it("[currentPath] should return the correct current path", () => { 19 | history.pushState(null, "", ""); 20 | expect(path()).to.equal(`/`); 21 | 22 | history.pushState(null, "", "/"); 23 | expect(path()).to.equal(`/`); 24 | 25 | history.pushState(null, "", "cool"); 26 | expect(path()).to.equal(`/cool/`); 27 | 28 | history.pushState(null, "", "login/forgot-password"); 29 | expect(path()).to.equal(`/login/forgot-password/`); 30 | }); 31 | 32 | it("[basepath] should return correct base path", () => { 33 | const basePaths = [ 34 | [`/my/path/`, `/my/path/`], 35 | [`/my-other-path/index.html`, `/my-other-path/`], 36 | [`https://cdpn.io/boomboom/v2/index.html?key=iFrameKey-ca757c8e-dad1-d965-1aed-7cabdaa22462`, `/boomboom/v2/`], 37 | ]; 38 | 39 | for (const [path, expected] of basePaths) { 40 | $base.href = path; 41 | expect(basePath()).to.equal(expected); 42 | } 43 | }); 44 | 45 | it("[query] should return the correct query", () => { 46 | history.pushState(null, "", "?key1=value1&key2=value2"); 47 | expect(JSON.stringify(query())).to.equal(`{"key1":"value1","key2":"value2"}`); 48 | }); 49 | 50 | it("[toQuery] should return the correct query object", () => { 51 | expect(JSON.stringify(toQuery("test=1234&redirect"))).to.equal(JSON.stringify({test: "1234", redirect: ""})) 52 | }); 53 | 54 | it("[toQueryString] should return the correct query string", () => { 55 | expect(toQueryString({test: "1234", redirect: ""})).to.equal("test=1234&redirect"); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@appnest/web-config/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true, 6 | "target": "es2017", 7 | "importHelpers": true, 8 | "lib": [ 9 | "es2015.promise", 10 | "dom", 11 | "es7", 12 | "es6", 13 | "es2017", 14 | "es2017.object", 15 | "es2015.proxy", 16 | "esnext" 17 | ] 18 | }, 19 | "include": [ 20 | "src/lib/**/*" 21 | ] 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@appnest/web-config/tsconfig.json" 3 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@appnest/web-config/tslint.json" 3 | } -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | --------------------------------------------------------------------------------