├── .all-contributorsrc.json ├── .commitlintrc.json ├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ ├── dependabot.yml │ ├── main.yml │ └── reports.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .lintstagedrc.json ├── .prettierignore ├── .prettierrc.json ├── .size-limit.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── custom-elements.json ├── demo ├── app.ts ├── contact.ts ├── docs.ts ├── index.html ├── loading.ts ├── product.ts └── store.ts ├── eslint.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── lib │ ├── actions.test.ts │ ├── actions.ts │ ├── reducer.test.ts │ ├── reducer.ts │ ├── route.test.ts │ ├── route.ts │ ├── selectors.test.ts │ ├── selectors.ts │ ├── service.test.ts │ └── service.ts ├── lit-redux-router.test.ts └── lit-redux-router.ts ├── tsconfig.build.json └── tsconfig.json /.all-contributorsrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "lit-redux-router", 3 | "projectOwner": "fernandopasik", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": ["README.md"], 7 | "imageSize": 100, 8 | "commit": true, 9 | "commitConvention": "angular", 10 | "contributors": [ 11 | { 12 | "login": "fernandopasik", 13 | "name": "Fernando Pasik", 14 | "avatar_url": "https://avatars1.githubusercontent.com/u/1301335?v=4", 15 | "profile": "https://fernandopasik.com", 16 | "contributions": ["bug", "code", "doc", "ideas"] 17 | }, 18 | { 19 | "login": "hutchgrant", 20 | "name": "Grant Hutchinson", 21 | "avatar_url": "https://avatars3.githubusercontent.com/u/1429612?v=4", 22 | "profile": "https://github.com/hutchgrant", 23 | "contributions": ["bug", "code", "doc", "ideas"] 24 | }, 25 | { 26 | "login": "anoblet", 27 | "name": "Andrew Noblet", 28 | "avatar_url": "https://avatars0.githubusercontent.com/u/7674171?v=4", 29 | "profile": "https://github.com/anoblet", 30 | "contributions": ["bug", "code"] 31 | }, 32 | { 33 | "login": "zainafzal08", 34 | "name": "Zain Afzal", 35 | "avatar_url": "https://avatars3.githubusercontent.com/u/12245732?v=4", 36 | "profile": "http://www.zainafzal.com", 37 | "contributions": ["doc"] 38 | }, 39 | { 40 | "login": "Waxolunist", 41 | "name": "Christian Sterzl", 42 | "avatar_url": "https://avatars2.githubusercontent.com/u/689270?v=4", 43 | "profile": "http://christian.sterzl.info", 44 | "contributions": ["bug", "code"] 45 | }, 46 | { 47 | "login": "bnf", 48 | "name": "Benjamin Franzke", 49 | "avatar_url": "https://avatars.githubusercontent.com/u/473155?v=4", 50 | "profile": "https://bnf.dev", 51 | "contributions": ["bug", "code"] 52 | }, 53 | { 54 | "login": "ramykl", 55 | "name": "Ramy", 56 | "avatar_url": "https://avatars.githubusercontent.com/u/5079228?v=4", 57 | "profile": "https://github.com/ramykl", 58 | "contributions": ["bug", "code"] 59 | } 60 | ], 61 | "contributorsPerLine": 7 62 | } 63 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # http://editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 2 11 | indent_style = space 12 | insert_final_newline = true 13 | max_line_length = 100 14 | trim_trailing_whitespace = true 15 | 16 | [*.html] 17 | indent_style = tab 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: npm 8 | directory: / 9 | schedule: 10 | interval: daily 11 | groups: 12 | commitlint: 13 | patterns: 14 | - '@commitlint*' 15 | typescript-eslint: 16 | patterns: 17 | - '@typescript-eslint*' 18 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | closeComment: false 2 | daysUntilClose: 7 3 | daysUntilStale: 15 4 | markComment: > 5 | This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. 6 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot 2 | on: pull_request_target 3 | permissions: 4 | contents: write 5 | pull-requests: write 6 | 7 | jobs: 8 | auto-review: 9 | name: Auto review 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - name: Auto Review 15 | uses: fernandopasik/actions/auto-review@main 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: {} 7 | 8 | jobs: 9 | install: 10 | name: Install Dependencies 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Setup Node 16 | uses: fernandopasik/actions/setup-node@main 17 | check-format: 18 | name: Check Format 19 | runs-on: ubuntu-latest 20 | needs: install 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | - name: Setup Node 25 | uses: fernandopasik/actions/setup-node@main 26 | - name: Check Format 27 | run: npm run format:check 28 | lint: 29 | name: Lint 30 | runs-on: ubuntu-latest 31 | needs: install 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | - name: Setup Node 36 | uses: fernandopasik/actions/setup-node@main 37 | - name: Lint 38 | run: npm run lint 39 | check-types: 40 | name: Check Typescript Types 41 | runs-on: ubuntu-latest 42 | needs: install 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v4 46 | - name: Setup Node 47 | uses: fernandopasik/actions/setup-node@main 48 | - name: Check Typescript Types 49 | run: npm run check-types 50 | unit-test: 51 | name: Unit Tests 52 | runs-on: ubuntu-latest 53 | needs: install 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | - name: Setup Node 58 | uses: fernandopasik/actions/setup-node@main 59 | - name: Unit Tests 60 | run: npm run test:coverage -- --ci --reporters=default --reporters=jest-junit 61 | env: 62 | JEST_JUNIT_OUTPUT_FILE: 'reports/unit-test-results.xml' 63 | - name: Upload Report to Codecov 64 | uses: codecov/codecov-action@v5 65 | if: always() 66 | - name: Upload Report 67 | uses: actions/upload-artifact@v4 68 | if: always() 69 | with: 70 | name: unit-test-results 71 | path: reports/unit-test-results.xml 72 | build: 73 | name: Build 74 | runs-on: ubuntu-latest 75 | needs: 76 | - check-format 77 | - lint 78 | - check-types 79 | - unit-test 80 | steps: 81 | - name: Checkout 82 | uses: actions/checkout@v4 83 | - name: Setup Node 84 | uses: fernandopasik/actions/setup-node@main 85 | - name: Build 86 | run: npm run build 87 | - name: Upload Build Artifact 88 | uses: actions/upload-artifact@v4 89 | with: 90 | name: bundle 91 | path: lit-redux-router.min.* 92 | size: 93 | name: Check Size 94 | runs-on: ubuntu-latest 95 | needs: build 96 | steps: 97 | - name: Checkout 98 | uses: actions/checkout@v4 99 | - name: Setup Node 100 | uses: fernandopasik/actions/setup-node@main 101 | - name: Download Build Artifact 102 | uses: actions/download-artifact@v4 103 | with: 104 | name: bundle 105 | - name: Check Size 106 | run: npm run size 107 | -------------------------------------------------------------------------------- /.github/workflows/reports.yml: -------------------------------------------------------------------------------- 1 | name: reports 2 | on: 3 | workflow_run: 4 | workflows: ['main'] 5 | types: 6 | - completed 7 | 8 | jobs: 9 | report-unit-test: 10 | name: Unit Tests Report 11 | runs-on: ubuntu-latest 12 | permissions: 13 | actions: read 14 | contents: read 15 | checks: write 16 | steps: 17 | - name: Publish Report 18 | uses: dorny/test-reporter@v2 19 | with: 20 | artifact: unit-test-results 21 | name: Unit Tests Report 22 | path: '*.xml' 23 | reporter: jest-junit 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Folder view configuration files 2 | .DS_Store 3 | Desktop.ini 4 | 5 | # Thumbnail cache files 6 | ._* 7 | Thumbs.db 8 | 9 | # Files that might appear on external disks 10 | .Spotlight-V100 11 | .Trashes 12 | 13 | # local vscode config 14 | .vscode 15 | 16 | # npm 17 | node_modules 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | lerna-debug.log* 22 | .pnpm-debug.log* 23 | pnpm-lock.yaml 24 | yarn.lock 25 | 26 | # test files 27 | /coverage 28 | 29 | # build and temp folders 30 | /docs 31 | /lib 32 | /lit-redux-router.* 33 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npm exec -c 'commitlint --edit $1' 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm exec lint-staged 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run verify 2 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": ["prettier --check", "eslint"], 3 | "*.{js,ts}": ["jest --bail --findRelatedTests --passWithNoTests"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .husky/_ 2 | /LICENSE 3 | /coverage 4 | /lib 5 | /lit-redux-router.* 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-pkg", "prettier-plugin-sh"], 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "gzip": true, 4 | "limit": "2 kB", 5 | "path": "lit-redux-router.min.js" 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Reporting Bugs or suggesting features 4 | 5 | Ensure the bug or feature has not been reported before searching by [searching](https://github.com/fernandopasik/lit-redux-router/issues) first. If no similar issue is found please submit a [new](https://github.com/fernandopasik/lit-redux-router/issues/new/choose) one. 6 | 7 | ## Submitting changes 8 | 9 | 1. Fork the project 10 | 2. Create a new branch 11 | 3. Commit your proposed changes 12 | 4. Consider adding test cases for any new functionality 13 | 5. Submit a pull request 14 | 6. Please add a clear description of the problem and solution 15 | 7. Include any related issue number 16 | 8. Please ensure the PR passes the automated checks 17 | - [Codecov](https://codecov.io/gh/fernandopasik/lit-redux-router) 18 | 19 | ## Styleguides 20 | 21 | - [Prettier](https://prettier.io) will catch most styling issues that may exist in your code. 22 | - Git commit messages are checked with [commitlint](https://github.com/marionebl/commitlint) and follow the [conventional commits rules](https://github.com/marionebl/commitlint/tree/master/@commitlint/config-conventional#rules). 23 | - JavaScript styles are checked with [eslint](https://eslint.org/) and follow the [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript). 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Fernando Pasik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lit Redux Router 2 | 3 | 4 | 5 | [![Gzip Bundle Size](https://img.badgesize.io/https://unpkg.com/lit-redux-router/lit-redux-router.min.js?compression=gzip)](https://unpkg.com/lit-redux-router/lit-redux-router.min.js 'Gzip Bundle Size') 6 | [![Build Status](https://github.com/fernandopasik/lit-redux-router/actions/workflows/main.yml/badge.svg)](https://github.com/fernandopasik/lit-redux-router/actions/workflows/main.yml 'Build Status') 7 | [![Coverage Status](https://codecov.io/gh/fernandopasik/lit-redux-router/branch/main/graph/badge.svg)](https://codecov.io/gh/fernandopasik/lit-redux-router 'Coverage Status') 8 | [![Known Vulnerabilities](https://snyk.io/test/github/fernandopasik/lit-redux-router/badge.svg?targetFile=package.json)](https://snyk.io/test/github/fernandopasik/lit-redux-router?targetFile=package.json 'Known Vulnerabilities') 9 | 10 | [![All Contributors](https://img.shields.io/badge/all_contributors-6-orange.svg?style=flat-square)](#contributors) 11 | [![npm version](https://img.shields.io/npm/v/lit-redux-router.svg?logo=npm)](https://www.npmjs.com/package/lit-redux-router 'npm version') 12 | [![npm downloads](https://img.shields.io/npm/dm/lit-redux-router.svg)](https://www.npmjs.com/package/lit-redux-router 'npm downloads') 13 | [![Published on webcomponents.org](https://img.shields.io/badge/webcomponents.org-published-blue.svg)](https://webcomponents.org/element/lit-redux-router 'Webcomponents url') 14 | 15 | 16 | 17 | Declarative way of routing for [lit](https://github.com/lit/lit) powered by [pwa-helpers](https://github.com/Polymer/pwa-helpers) and [redux](https://redux.js.org/). 18 | 19 | A minimal router solution (~1.7 kb Gzipped) that consist in using `lit-route` elements and **connecting** them to the store. 20 | 21 | The routing approach is based on the [PWA Starter Kit](https://github.com/polymer/pwa-starter-kit). 22 | 23 | ## Install 24 | 25 | Install this library and its peer dependencies 26 | 27 | ```sh 28 | npm i lit-redux-router lit pwa-helpers redux 29 | ``` 30 | 31 | ## Usage 32 | 33 | Firstly ensure that the redux store has been created with the `lazyReducerEnhancer`, which allows for reducers to be installed lazily after initial store setup. 34 | 35 | ```js 36 | import { createStore, compose, combineReducers } from 'redux'; 37 | import { reducer } from './reducer'; 38 | import { lazyReducerEnhancer } from 'pwa-helpers'; 39 | 40 | export const store = createStore(reducer, compose(lazyReducerEnhancer(combineReducers))); 41 | ``` 42 | 43 | Then the router needs to **connect to a redux store**. 44 | 45 | ```js 46 | import { LitElement, html } from 'lit'; 47 | import { connectRouter } from 'lit-redux-router'; 48 | import store from './store.js'; 49 | 50 | connectRouter(store); 51 | ``` 52 | 53 | `lit-route` component can render the components when the **path attribute** matches. The corresponding active `lit-route` element will reflect the **active attribute**. 54 | 55 | ```js 56 | class MyApp extends LitElement { 57 | render() { 58 | return html` 59 |
60 |

Home

61 |

About

62 |
63 | `; 64 | } 65 | } 66 | customElements.define('my-app', MyApp); 67 | ``` 68 | 69 | Ideally all content would be in a component and can be passed to `lit-route` through a **component attribute**. 70 | 71 | ```js 72 | class AppHome extends LitElement { 73 | render() { 74 | return html`

Home

`; 75 | } 76 | } 77 | customElements.define('app-home', AppHome); 78 | 79 | class AppAbout extends LitElement { 80 | render() { 81 | return html`

About

`; 82 | } 83 | } 84 | customElements.define('app-about', AppAbout); 85 | 86 | class MyApp extends LitElement { 87 | render() { 88 | return html` 89 |
90 | 91 | 92 |
93 | `; 94 | } 95 | } 96 | customElements.define('my-app', MyApp); 97 | ``` 98 | 99 | `lit-route` can **map path variables** and inject them in the provided component. 100 | 101 | ```js 102 | class AppProduct extends LitElement { 103 | static get properties() { 104 | return { 105 | id: String, 106 | }; 107 | } 108 | 109 | render() { 110 | return html`

Product with id: ${this.id}

`; 111 | } 112 | } 113 | customElements.define('app-product', AppProduct); 114 | 115 | class MyApp extends LitElement { 116 | render() { 117 | return html` 118 |
119 | 120 |
121 | `; 122 | } 123 | } 124 | customElements.define('my-app', MyApp); 125 | ``` 126 | 127 | When no path attribute is provided to `lit-route`, it will render when no route matches (404) 128 | 129 | ```js 130 | class MyApp extends LitElement { 131 | render() { 132 | return html` 133 |
134 |

Home

135 |

404 Not found

136 |
137 | `; 138 | } 139 | } 140 | customElements.define('my-app', MyApp); 141 | ``` 142 | 143 | To trigger navigation without using a link element, the action `navigate` can be imported and triggered with the wanted path 144 | 145 | ```js 146 | import { navigate } from 'lit-redux-router'; 147 | import store from './store.js'; 148 | 149 | class MyApp extends LitElement { 150 | goTo(path) { 151 | store.dispatch(navigate(path)); 152 | } 153 | 154 | render() { 155 | return html` 156 |
157 | 158 |
159 | `; 160 | } 161 | } 162 | customElements.define('my-app', MyApp); 163 | ``` 164 | 165 | To lazy load a component on route change and optionally show a loading component while waiting for the import to resolve 166 | 167 | ```js 168 | import { navigate } from 'lit-redux-router'; 169 | import store from './store.js'; 170 | 171 | class MyApp extends LitElement { 172 | render() { 173 | return html` 174 |
175 | 181 |
182 | `; 183 | } 184 | } 185 | customElements.define('my-app', MyApp); 186 | 187 | class MyLoading extends LitElement { 188 | render() { 189 | return html` 190 | 196 |

Loading...

197 | `; 198 | } 199 | } 200 | 201 | customElements.define('my-loading', MyLoading); 202 | ``` 203 | 204 | The window will scroll to top by default, to disable add the attribute `scrollDisable` 205 | 206 | ```html 207 | 208 | ``` 209 | 210 | To scroll to the route element on load, you can set the [scrollIntoViewOptions](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#Example) object in the attribute `.scrollOpt` 211 | 212 | ```html 213 | 218 | ``` 219 | 220 | Check a more comprehensive example in https://github.com/fernandopasik/lit-redux-router/blob/main/demo/ 221 | 222 | ## Development 223 | 224 | Start server with example and watch mode for building the library 225 | 226 | ```sh 227 | npm run start 228 | ``` 229 | 230 | Run lint and test tasks 231 | 232 | ```sh 233 | npm run test 234 | npm run lint 235 | ``` 236 | 237 | Build the library 238 | 239 | ```sh 240 | npm run build 241 | ``` 242 | 243 | Check the full size of the library 244 | 245 | ```sh 246 | npm run size 247 | ``` 248 | 249 | ## Built with 250 | 251 | - [regexparam](https://github.com/lukeed/regexparam) - A tiny utility that converts route patterns into RegExp 252 | - [lit](https://github.com/lit/lit) - Lit is a simple library for building fast, lightweight web components 253 | - [pwa-helpers](https://github.com/Polymer/pwa-helpers) - Small helper methods or mixins to help you build web apps 254 | - [Redux](https://redux.js.org/) - Predictable state container for JavaScript apps 255 | 256 | ## Contributors ✨ 257 | 258 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 |

Fernando Pasik

🐛 💻 📖 🤔

Grant Hutchinson

🐛 💻 📖 🤔

Andrew Noblet

🐛 💻

Zain Afzal

📖

Christian Sterzl

🐛 💻

Benjamin Franzke

🐛 💻

Ramy

🐛 💻
274 | 275 | 276 | 277 | 278 | 279 | 280 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 281 | 282 | ## License 283 | 284 | MIT (c) 2018 [Fernando Pasik](https://fernandopasik.com) 285 | -------------------------------------------------------------------------------- /custom-elements.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "experimental", 3 | "tags": [ 4 | { 5 | "name": "lit-route", 6 | "path": "./src/lib/route.ts", 7 | "description": "Element that renders its content or a component\nwhen browser route matches", 8 | "attributes": [ 9 | { 10 | "name": "active", 11 | "type": "boolean", 12 | "default": "false" 13 | }, 14 | { 15 | "name": "component", 16 | "type": "string | undefined" 17 | }, 18 | { 19 | "name": "path", 20 | "type": "string | undefined" 21 | }, 22 | { 23 | "name": "resolve", 24 | "type": "(() => Promise) | undefined" 25 | }, 26 | { 27 | "name": "loading", 28 | "type": "string | undefined" 29 | }, 30 | { 31 | "name": "scrollOpt", 32 | "type": "boolean | ScrollIntoViewOptions | undefined" 33 | }, 34 | { 35 | "name": "scrollDisable", 36 | "type": "boolean", 37 | "default": "false" 38 | } 39 | ], 40 | "properties": [ 41 | { 42 | "name": "active", 43 | "attribute": "active", 44 | "type": "boolean", 45 | "default": "false" 46 | }, 47 | { 48 | "name": "component", 49 | "attribute": "component", 50 | "type": "string | undefined" 51 | }, 52 | { 53 | "name": "path", 54 | "attribute": "path", 55 | "type": "string | undefined" 56 | }, 57 | { 58 | "name": "resolve", 59 | "attribute": "resolve", 60 | "type": "(() => Promise) | undefined" 61 | }, 62 | { 63 | "name": "loading", 64 | "attribute": "loading", 65 | "type": "string | undefined" 66 | }, 67 | { 68 | "name": "scrollOpt", 69 | "attribute": "scrollOpt", 70 | "type": "boolean | ScrollIntoViewOptions | undefined" 71 | }, 72 | { 73 | "name": "scrollDisable", 74 | "attribute": "scrollDisable", 75 | "type": "boolean", 76 | "default": "false" 77 | } 78 | ] 79 | } 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /demo/app.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import { css, html, LitElement, type TemplateResult } from 'lit'; 3 | import type { Action } from 'redux'; 4 | import { connectRouter, navigate } from '../src/lit-redux-router.ts'; 5 | import './contact.ts'; 6 | import './loading.ts'; 7 | import './product.ts'; 8 | import store from './store.ts'; 9 | 10 | connectRouter(store); 11 | 12 | interface TestState { 13 | test: boolean; 14 | } 15 | 16 | const defaultState = { 17 | test: true, 18 | }; 19 | 20 | const testReducer = ( 21 | state: TestState = defaultState, 22 | { type }: Action = { type: '' }, 23 | ): TestState => { 24 | switch (type) { 25 | case 'TEST_FALSE': 26 | return { test: false }; 27 | case 'TEST_TRUE': 28 | return { test: true }; 29 | default: 30 | return state; 31 | } 32 | }; 33 | 34 | store.addReducers({ test: testReducer }); 35 | 36 | class MyApp extends LitElement { 37 | public static override styles = css` 38 | :host { 39 | font-family: sans-serif; 40 | font-weight: 300; 41 | } 42 | 43 | a { 44 | text-decoration: none; 45 | color: inherit; 46 | } 47 | 48 | a:hover { 49 | text-decoration: underline; 50 | } 51 | 52 | h1 { 53 | margin-top: 0; 54 | margin-bottom: 16px; 55 | } 56 | 57 | .app-bar { 58 | color: white; 59 | background-color: #2196f3; 60 | font-size: 20px; 61 | padding: 16px; 62 | text-align: center; 63 | } 64 | 65 | .app-content { 66 | padding: 16px; 67 | } 68 | 69 | .nav-bar { 70 | background-color: white; 71 | text-align: center; 72 | } 73 | 74 | .nav-bar a { 75 | display: inline-block; 76 | padding: 16px; 77 | } 78 | .spacer { 79 | height: 1600px; 80 | } 81 | 82 | .scrollLink { 83 | color: blue; 84 | } 85 | 86 | .scrollLink:hover { 87 | color: red; 88 | } 89 | `; 90 | 91 | public goToAbout(): void { 92 | store.dispatch(navigate('/about')); 93 | } 94 | 95 | public triggerStateChange(): void { 96 | store.dispatch({ type: 'TEST_FALSE' }); 97 | } 98 | 99 | public async importDocs(): Promise { 100 | await import('./docs.js'); 101 | } 102 | 103 | // eslint-disable-next-line max-lines-per-function 104 | public override render(): TemplateResult { 105 | return html` 106 |
Example App
107 | 115 | 116 |
117 |

404

118 | 119 |

Home

120 | 121 |
122 | 123 | 124 |

About

125 | Me 126 | 127 |

About Me

128 |
129 |
130 | 131 | 138 |
139 | 140 |
141 | Scroll disabled
142 | Scroll to top smoothly and switch to docs component 143 |
144 | `; 145 | } 146 | } 147 | 148 | customElements.define('my-app', MyApp); 149 | -------------------------------------------------------------------------------- /demo/contact.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import { html, LitElement, type TemplateResult } from 'lit'; 3 | import { property } from 'lit/decorators.js'; 4 | import { navigate } from '../src/lit-redux-router.ts'; 5 | import store from './store.ts'; 6 | 7 | class MyContact extends LitElement { 8 | @property({ type: String }) 9 | protected name = ''; 10 | 11 | @property({ type: String }) 12 | protected email = ''; 13 | 14 | public save(prop: 'email' | 'name'): (event: InputEvent) => void { 15 | return (event: InputEvent): void => { 16 | this[prop] = event.data ?? ''; 17 | }; 18 | } 19 | 20 | public submit(): void { 21 | const query: string[] = []; 22 | let queryString = ''; 23 | 24 | if (this.name) { 25 | query.push(`name=${this.name}`); 26 | } 27 | 28 | if (this.email) { 29 | query.push(`email=${this.email}`); 30 | } 31 | 32 | if (query.length) { 33 | queryString = `?${query.join('&')}`; 34 | } 35 | 36 | store.dispatch(navigate(`/contact${queryString}`)); 37 | } 38 | 39 | public override render(): TemplateResult { 40 | return html` 41 |

Contact

42 |

43 | 44 | 45 |

46 |

47 | 48 | 49 |

50 | 51 | `; 52 | } 53 | } 54 | 55 | customElements.define('my-contact', MyContact); 56 | -------------------------------------------------------------------------------- /demo/docs.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, type TemplateResult } from 'lit'; 2 | 3 | class DocsComponent extends LitElement { 4 | public override render(): TemplateResult { 5 | return html`
Documentation here.
`; 6 | } 7 | } 8 | 9 | customElements.define('docs-page', DocsComponent); 10 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | Lit Redux Router 12 | 16 | 17 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /demo/loading.ts: -------------------------------------------------------------------------------- 1 | import { css, html, LitElement, type TemplateResult } from 'lit'; 2 | 3 | class MyLoading extends LitElement { 4 | public static override styles = css` 5 | h1 { 6 | margin-top: 0; 7 | margin-bottom: 16px; 8 | } 9 | `; 10 | 11 | public override render(): TemplateResult { 12 | return html`

Loading...

`; 13 | } 14 | } 15 | 16 | customElements.define('my-loading', MyLoading); 17 | -------------------------------------------------------------------------------- /demo/product.ts: -------------------------------------------------------------------------------- 1 | import { css, html, LitElement, type TemplateResult } from 'lit'; 2 | import { property } from 'lit/decorators.js'; 3 | 4 | class MyProduct extends LitElement { 5 | public static override styles = css` 6 | h1 { 7 | margin-top: 0; 8 | margin-bottom: 16px; 9 | } 10 | `; 11 | 12 | @property({ type: String }) 13 | public override id = ''; 14 | 15 | @property({ type: String }) 16 | protected name = ''; 17 | 18 | public override render(): TemplateResult { 19 | return html`

Product ${this.id} ${this.name}

`; 20 | } 21 | } 22 | 23 | customElements.define('my-product', MyProduct); 24 | -------------------------------------------------------------------------------- /demo/store.ts: -------------------------------------------------------------------------------- 1 | import { lazyReducerEnhancer } from 'pwa-helpers'; 2 | import { combineReducers, compose, createStore } from 'redux'; 3 | 4 | declare global { 5 | interface Window { 6 | // eslint-disable-next-line @typescript-eslint/naming-convention 7 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose; 8 | } 9 | } 10 | 11 | // eslint-disable-next-line no-underscore-dangle 12 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?? compose; 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-deprecated 15 | const store = createStore( 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | (state: any): any => state, 18 | composeEnhancers(lazyReducerEnhancer(combineReducers)), 19 | ); 20 | 21 | export default store; 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import prettier from 'eslint-config-prettier'; 3 | import importPlugin from 'eslint-plugin-import'; 4 | import { configs as lit } from 'eslint-plugin-lit'; 5 | import { configs as wc } from 'eslint-plugin-wc'; 6 | import ymlPlugin from 'eslint-plugin-yml'; 7 | import globals from 'globals'; 8 | import ts from 'typescript-eslint'; 9 | 10 | export default ts.config( 11 | { 12 | ignores: ['coverage/', 'docs/', 'demo/dist/', 'lib/', 'lit-redux-router.*'], 13 | }, 14 | eslint.configs.all, 15 | importPlugin.flatConfigs.recommended, 16 | importPlugin.configs.typescript, 17 | lit['flat/recommended'], 18 | wc['flat/recommended'], 19 | ...ymlPlugin.configs['flat/recommended'], 20 | ...ymlPlugin.configs['flat/prettier'], 21 | { 22 | files: ['**/*.js', '**/*.ts'], 23 | languageOptions: { 24 | ecmaVersion: 'latest', 25 | globals: { ...globals.node }, 26 | parserOptions: { project: 'tsconfig.json' }, 27 | sourceType: 'module', 28 | }, 29 | rules: { 30 | 'import/no-unresolved': 'off', 31 | 'max-lines': ['error', { max: 133, skipBlankLines: true, skipComments: true }], 32 | 'max-lines-per-function': ['error', { max: 30, skipBlankLines: true, skipComments: true }], 33 | 'max-statements': ['error', { max: 35 }], 34 | 'no-ternary': 'off', 35 | 'one-var': 'off', 36 | 'sort-imports': 'off', 37 | }, 38 | settings: { 'import/resolver': { typescript: {} } }, 39 | }, 40 | { 41 | // eslint-disable-next-line import/no-named-as-default-member 42 | extends: [...ts.configs.all], 43 | files: ['**/*.ts'], 44 | rules: { 45 | '@typescript-eslint/class-methods-use-this': 'off', 46 | // eslint-disable-next-line no-magic-numbers 47 | '@typescript-eslint/no-magic-numbers': ['error', { ignore: [0, 1, 2] }], 48 | '@typescript-eslint/prefer-readonly-parameter-types': 'off', 49 | }, 50 | }, 51 | { 52 | files: ['**/*.test.*'], 53 | rules: { 54 | '@typescript-eslint/no-floating-promises': 'off', 55 | '@typescript-eslint/no-magic-numbers': 'off', 56 | 'max-lines': 'off', 57 | 'max-lines-per-function': 'off', 58 | }, 59 | }, 60 | prettier, 61 | ); 62 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | collectCoverageFrom: ['src/**/*.{j,t}s'], 3 | moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' }, 4 | preset: 'ts-jest/presets/js-with-ts-esm', 5 | testEnvironment: 'jsdom', 6 | transformIgnorePatterns: [ 7 | '/node_modules/(?!(@lit|lit|lit-html|lit-element|webcomponents|@open-wc)/).*/', 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lit-redux-router", 3 | "version": "0.22.0", 4 | "type": "module", 5 | "description": "Declarative way of routing for lit powered by pwa-helpers and redux", 6 | "repository": "fernandopasik/lit-redux-router", 7 | "homepage": "https://github.com/fernandopasik/lit-redux-router", 8 | "bugs": "https://github.com/fernandopasik/lit-redux-router/issues", 9 | "author": "Fernando Pasik (https://fernandopasik.com)", 10 | "contributors": [ 11 | "Andrew Noblet (https://github.com/anoblet)", 12 | "bnf (https://github.com/bnf)", 13 | "Grant Hutchinson (https://github.com/hutchgrant)", 14 | "Ramy (https://github.com/ramykl)", 15 | "Waxolunist (https://github.com/Waxolunist)", 16 | "Zain Afzal (https://github.com/zainafzal08)" 17 | ], 18 | "license": "MIT", 19 | "main": "lit-redux-router.js", 20 | "typings": "lit-redux-router.d.ts", 21 | "module": "lit-redux-router.js", 22 | "files": [ 23 | "/lib", 24 | "/lit-redux-router.*" 25 | ], 26 | "keywords": [ 27 | "lit", 28 | "lit-element", 29 | "lit-html", 30 | "redux", 31 | "router", 32 | "routing", 33 | "web-components" 34 | ], 35 | "scripts": { 36 | "all-contributors": "all-contributors --config .all-contributorsrc.json", 37 | "analyze": "wca analyze src --format json --outFile custom-elements.json", 38 | "build": "tsc -p tsconfig.build.json && rollup -c", 39 | "check-types": "tsc --noEmit", 40 | "clean": "del coverage lib lit-redux-router.*", 41 | "format": "prettier --write .", 42 | "format:check": "prettier --check .", 43 | "lint": "eslint", 44 | "lit-analyze": "lit-analyzer src --strict", 45 | "prebuild": "del lib lit-redux-router.*", 46 | "prepare": "husky", 47 | "preversion": "npm run verify", 48 | "size": "size-limit", 49 | "start": "rollup -c", 50 | "test": "jest", 51 | "test:coverage": "jest --coverage", 52 | "verify": "npm run format:check && npm run lint && npm run lit-analyze && npm run check-types && npm run test:coverage && npm run build && npm run size" 53 | }, 54 | "peerDependencies": { 55 | "lit": "*", 56 | "pwa-helpers": "0.x.x", 57 | "redux": "4.x.x" 58 | }, 59 | "dependencies": { 60 | "regexparam": "^3.0.0" 61 | }, 62 | "devDependencies": { 63 | "@commitlint/cli": "^19.8.0", 64 | "@commitlint/config-conventional": "^19.8.0", 65 | "@eslint/js": "^9.24.0", 66 | "@jest/globals": "^29.7.0", 67 | "@literals/rollup-plugin-html-css-minifier": "^3.0.1", 68 | "@rollup/plugin-html": "^2.0.0", 69 | "@rollup/plugin-node-resolve": "^16.0.1", 70 | "@rollup/plugin-terser": "^0.4.4", 71 | "@rollup/plugin-typescript": "^12.1.2", 72 | "@size-limit/file": "^11.2.0", 73 | "@tsconfig/strictest": "^2.0.5", 74 | "@types/deep-freeze": "^0.1.5", 75 | "@types/jest": "^29.5.14", 76 | "@types/mime": "^4.0.0", 77 | "@types/redux-mock-store": "1.5.0", 78 | "@typescript-eslint/eslint-plugin": "^8.30.0", 79 | "@typescript-eslint/parser": "^8.30.0", 80 | "@webcomponents/webcomponentsjs": "^2.8.0", 81 | "all-contributors-cli": "^6.26.1", 82 | "deep-freeze": "^0.0.1", 83 | "del-cli": "^6.0.0", 84 | "eslint": "^9.24.0", 85 | "eslint-config-prettier": "^10.1.2", 86 | "eslint-import-resolver-typescript": "^4.3.2", 87 | "eslint-plugin-import": "^2.31.0", 88 | "eslint-plugin-lit": "^2.0.0", 89 | "eslint-plugin-prettier": "^5.2.6", 90 | "eslint-plugin-wc": "^3.0.0", 91 | "eslint-plugin-yml": "^1.17.0", 92 | "globals": "^16.0.0", 93 | "husky": "^9.1.7", 94 | "jest": "^29.7.0", 95 | "jest-environment-jsdom": "^29.7.0", 96 | "jest-junit": "^16.0.0", 97 | "lint-staged": "^16.0.0", 98 | "lit": "^3.3.0", 99 | "lit-analyzer": "^2.0.3", 100 | "prettier": "^3.5.3", 101 | "prettier-plugin-organize-imports": "^4.1.0", 102 | "prettier-plugin-pkg": "^0.20.0", 103 | "prettier-plugin-sh": "^0.17.2", 104 | "pwa-helpers": "^0.9.1", 105 | "redux": "^4.2.1", 106 | "redux-mock-store": "^1.5.5", 107 | "rollup": "^4.40.0", 108 | "rollup-plugin-serve": "^3.0.0", 109 | "size-limit": "^11.2.0", 110 | "ts-jest": "^29.3.2", 111 | "ts-lit-plugin": "^2.0.2", 112 | "typescript": "^5.8.3", 113 | "typescript-eslint": "^8.30.0", 114 | "web-component-analyzer": "^2.0.0" 115 | }, 116 | "overrides": { 117 | "glob-parent": "6.0.2" 118 | }, 119 | "sideEffects": false 120 | } 121 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { literalsHtmlCssMinifier } from '@literals/rollup-plugin-html-css-minifier'; 2 | import html from '@rollup/plugin-html'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import terser from '@rollup/plugin-terser'; 5 | import typescript from '@rollup/plugin-typescript'; 6 | import serve from 'rollup-plugin-serve'; 7 | 8 | const onwarn = (warning, warn) => { 9 | if (warning.code === 'THIS_IS_UNDEFINED') { 10 | return; 11 | } 12 | warn(warning); 13 | }; 14 | 15 | const isServe = process.env.npm_lifecycle_event === 'start'; 16 | 17 | export default isServe 18 | ? { 19 | input: './demo/app.ts', 20 | output: { 21 | dir: 'docs', 22 | format: 'esm', 23 | inlineDynamicImports: true, 24 | sourcemap: true, 25 | }, 26 | plugins: [ 27 | typescript({ 28 | exclude: ['lib', 'lit-redux-router.*', '**/*.test.*'], 29 | tsconfig: 'tsconfig.build.json', 30 | }), 31 | resolve(), 32 | html({ 33 | template: './demo/index.html', 34 | }), 35 | serve({ contentBase: 'docs', historyApiFallback: true }), 36 | ], 37 | } 38 | : { 39 | external: ['lit', 'lit/decorators.js', 'lit/directives/unsafe-html.js', 'pwa-helpers'], 40 | input: 'lit-redux-router.js', 41 | onwarn, 42 | output: { 43 | file: 'lit-redux-router.min.js', 44 | format: 'esm', 45 | sourcemap: true, 46 | }, 47 | plugins: [ 48 | literalsHtmlCssMinifier(), 49 | resolve(), 50 | terser({ 51 | mangle: { 52 | module: true, 53 | properties: true, 54 | }, 55 | }), 56 | ], 57 | }; 58 | -------------------------------------------------------------------------------- /src/lib/actions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, jest } from '@jest/globals'; 2 | import { addRoute, navigate, setActiveRoute } from './actions.ts'; 3 | import { checkNavigation } from './service.ts'; 4 | 5 | jest.mock('./service', () => ({ 6 | checkNavigation: jest.fn(), 7 | })); 8 | 9 | describe('router actions', () => { 10 | describe('add route', () => { 11 | it('has a type', () => { 12 | expect(addRoute('/')).toHaveProperty('type', 'ADD_ROUTE'); 13 | }); 14 | 15 | it('adds a path', () => { 16 | const path = '/about'; 17 | 18 | expect(addRoute(path)).toHaveProperty('path', path); 19 | }); 20 | }); 21 | 22 | describe('navigate to route', () => { 23 | it('has a type', () => { 24 | expect(navigate('')).toHaveProperty('type', 'NAVIGATE'); 25 | }); 26 | 27 | it('to a path', () => { 28 | const path = '/about'; 29 | 30 | expect(navigate(path)).toHaveProperty('path', path); 31 | }); 32 | 33 | it('call check navigation service', () => { 34 | const path = '/about'; 35 | navigate(path); 36 | 37 | expect(checkNavigation).toHaveBeenCalledWith(path); 38 | }); 39 | }); 40 | 41 | describe('set active route', () => { 42 | it('has a type', () => { 43 | expect(setActiveRoute('')).toHaveProperty('type', 'SET_ACTIVE_ROUTE'); 44 | }); 45 | 46 | it('to a path', () => { 47 | const path = '/about'; 48 | 49 | expect(setActiveRoute(path)).toHaveProperty('path', path); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/lib/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import type { Action } from 'redux'; 3 | import { checkNavigation } from './service.ts'; 4 | 5 | export enum ActionTypes { 6 | ADD_ROUTE = 'ADD_ROUTE', 7 | NAVIGATE = 'NAVIGATE', 8 | SET_ACTIVE_ROUTE = 'SET_ACTIVE_ROUTE', 9 | } 10 | 11 | export interface Actions extends Action { 12 | path: string; 13 | type: string; 14 | } 15 | 16 | export const addRoute = (path: string): Actions => ({ 17 | path, 18 | type: ActionTypes.ADD_ROUTE, 19 | }); 20 | 21 | export const navigate = (path: string): Actions => { 22 | checkNavigation(path); 23 | return { 24 | path, 25 | type: ActionTypes.NAVIGATE, 26 | }; 27 | }; 28 | 29 | export const setActiveRoute = (path: string): Actions => ({ 30 | path, 31 | type: ActionTypes.SET_ACTIVE_ROUTE, 32 | }); 33 | -------------------------------------------------------------------------------- /src/lib/reducer.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention, no-undefined */ 2 | import { describe, expect, it } from '@jest/globals'; 3 | import deepFreeze from 'deep-freeze'; 4 | import reducer from './reducer.ts'; 5 | 6 | describe('router reducer', () => { 7 | it('returns default state', () => { 8 | const initialState = deepFreeze({ activeRoute: '/about', routes: {} }); 9 | 10 | expect(reducer(initialState)).toStrictEqual(initialState); 11 | }); 12 | 13 | it('has a default initial state for active route', () => { 14 | expect(reducer()).toHaveProperty('activeRoute', '/'); 15 | }); 16 | 17 | it('has a default initial state for routes', () => { 18 | expect(reducer()).toHaveProperty('routes', {}); 19 | }); 20 | 21 | describe('add route', () => { 22 | it('can add a path', () => { 23 | const path = '/contact'; 24 | const action = { path, type: 'ADD_ROUTE' }; 25 | const newState = reducer(undefined, action); 26 | 27 | expect(newState.routes).toHaveProperty(path); 28 | }); 29 | 30 | it('when adding a path the route can be active', () => { 31 | const path = '/contact'; 32 | const action = { path, type: 'ADD_ROUTE' }; 33 | const initialState = deepFreeze({ activeRoute: path, routes: {} }); 34 | const newState = reducer(initialState, action); 35 | 36 | expect(newState.routes[path]).toHaveProperty('active', true); 37 | }); 38 | }); 39 | 40 | describe('set active route', () => { 41 | it('can set another route', () => { 42 | const path = '/contact'; 43 | const action = { path, type: 'SET_ACTIVE_ROUTE' }; 44 | const newState = reducer(undefined, action); 45 | 46 | expect(newState).toHaveProperty('activeRoute', path); 47 | }); 48 | 49 | it('checks for active path', () => { 50 | const path = '/contact'; 51 | const routes = { 52 | '/about': { active: false, params: {} }, 53 | '/contact': { active: false, params: {} }, 54 | '/home': { active: false, params: {} }, 55 | }; 56 | const action = { path, type: 'SET_ACTIVE_ROUTE' }; 57 | const initialState = deepFreeze({ activeRoute: '/', routes }); 58 | const newState = reducer(initialState, action); 59 | 60 | expect(newState.routes[path]).toHaveProperty('active', true); 61 | }); 62 | 63 | it('checks for active parameters', () => { 64 | const activePath = '/products/shirt'; 65 | const path = '/products/:id'; 66 | const routes = { 67 | '/about': { active: false, params: {} }, 68 | '/products/:id': { active: false, params: {} }, 69 | }; 70 | const action = { path: activePath, type: 'SET_ACTIVE_ROUTE' }; 71 | const initialState = deepFreeze({ activeRoute: '/', routes }); 72 | const newState = reducer(initialState, action); 73 | 74 | expect(newState.routes[path]).toHaveProperty('active', true); 75 | expect(newState.routes[path]).toHaveProperty('params', { id: 'shirt' }); 76 | }); 77 | 78 | it('parameters can be empty', () => { 79 | const activePath = '/about'; 80 | const path = '/products/:id'; 81 | const routes = { 82 | '/about': { active: false, params: {} }, 83 | '/products/:id': { active: false, params: {} }, 84 | }; 85 | const action = { path: activePath, type: 'SET_ACTIVE_ROUTE' }; 86 | const initialState = deepFreeze({ activeRoute: '/', routes }); 87 | const newState = reducer(initialState, action); 88 | 89 | expect(newState.routes[path]).toHaveProperty('active', false); 90 | expect(newState.routes[path]).toHaveProperty('params', {}); 91 | }); 92 | }); 93 | 94 | describe('navigate to route', () => { 95 | it('can set another route', () => { 96 | const path = '/contact'; 97 | const action = { path, type: 'NAVIGATE' }; 98 | const newState = reducer(undefined, action); 99 | 100 | expect(newState).toHaveProperty('activeRoute', path); 101 | }); 102 | 103 | it('checks for active path', () => { 104 | const path = '/contact'; 105 | const routes = { 106 | '/about': { active: false, params: {} }, 107 | '/contact': { active: false, params: {} }, 108 | '/home': { active: false, params: {} }, 109 | }; 110 | const action = { path, type: 'NAVIGATE' }; 111 | const initialState = deepFreeze({ activeRoute: '/', routes }); 112 | const newState = reducer(initialState, action); 113 | 114 | expect(newState.routes[path]).toHaveProperty('active', true); 115 | }); 116 | 117 | it('checks for active parameters', () => { 118 | const activePath = '/products/shirt'; 119 | const path = '/products/:id'; 120 | const routes = { 121 | '/about': { active: false, params: {} }, 122 | '/products/:id': { active: false, params: {} }, 123 | }; 124 | const action = { path: activePath, type: 'NAVIGATE' }; 125 | const initialState = deepFreeze({ activeRoute: '/', routes }); 126 | const newState = reducer(initialState, action); 127 | 128 | expect(newState.routes[path]).toHaveProperty('active', true); 129 | expect(newState.routes[path]).toHaveProperty('params', { id: 'shirt' }); 130 | }); 131 | 132 | it('parameters can be empty', () => { 133 | const activePath = '/about'; 134 | const path = '/products/:id'; 135 | const routes = { 136 | '/about': { active: false, params: {} }, 137 | '/products/:id': { active: false, params: {} }, 138 | }; 139 | const action = { path: activePath, type: 'NAVIGATE' }; 140 | const initialState = deepFreeze({ activeRoute: '/', routes }); 141 | const newState = reducer(initialState, action); 142 | 143 | expect(newState.routes[path]).toHaveProperty('active', false); 144 | expect(newState.routes[path]).toHaveProperty('params', {}); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /src/lib/reducer.ts: -------------------------------------------------------------------------------- 1 | import { ActionTypes, type Actions } from './actions.ts'; 2 | import { refreshRoute } from './service.ts'; 3 | 4 | export interface Route { 5 | active: boolean; 6 | params?: Record; 7 | } 8 | 9 | export interface RouterState { 10 | activeRoute: string; 11 | routes: Record; 12 | } 13 | 14 | interface Action { 15 | type?: string; 16 | path?: string; 17 | } 18 | 19 | const initialState = { 20 | activeRoute: '/', 21 | routes: {}, 22 | }; 23 | 24 | const reducer = ( 25 | state: RouterState = initialState, 26 | { type = '', path = '' }: Action | Actions = {}, 27 | ): RouterState => { 28 | switch (type) { 29 | // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison 30 | case ActionTypes.NAVIGATE: 31 | // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison, no-fallthrough 32 | case ActionTypes.SET_ACTIVE_ROUTE: 33 | return { 34 | ...state, 35 | activeRoute: path, 36 | routes: Object.keys(state.routes).reduce( 37 | (routes: Record, routeName: string): Record => ({ 38 | ...routes, 39 | [routeName]: refreshRoute(routeName, path), 40 | }), 41 | {}, 42 | ), 43 | }; 44 | // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison 45 | case ActionTypes.ADD_ROUTE: 46 | return { 47 | ...state, 48 | routes: { 49 | ...state.routes, 50 | [path]: refreshRoute(path, state.activeRoute), 51 | }, 52 | }; 53 | default: 54 | return state; 55 | } 56 | }; 57 | 58 | export default reducer; 59 | -------------------------------------------------------------------------------- /src/lib/route.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-type-assertion, no-undefined, @typescript-eslint/no-deprecated */ 2 | import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; 3 | import { customElement } from 'lit/decorators.js'; 4 | import * as pwaHelpers from 'pwa-helpers'; 5 | import type { LazyStore } from 'pwa-helpers/lazy-reducer-enhancer.ts'; 6 | import type { Store } from 'redux'; 7 | // eslint-disable-next-line import/no-named-as-default 8 | import configureStore from 'redux-mock-store'; 9 | import * as actions from './actions.ts'; 10 | import connectRouter, { RouteClass as Route } from './route.ts'; 11 | import * as selectors from './selectors.ts'; 12 | 13 | type TestStore = LazyStore & Store>; 14 | 15 | jest.mock('lit', () => ({ 16 | // eslint-disable-next-line @typescript-eslint/naming-convention 17 | LitElement: class LitElement { 18 | public querySelector(): null { 19 | return null; 20 | } 21 | }, 22 | html: jest.fn((strings: string[], ...values: unknown[]) => 23 | strings 24 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 25 | .map((string: string, index: number) => `${string}${String(values[index] ?? '')}`) 26 | .join(''), 27 | ), 28 | })); 29 | 30 | jest.mock('lit/decorators.js', () => ({ 31 | customElement: jest.fn(), 32 | property: jest.fn(), 33 | state: jest.fn(), 34 | })); 35 | 36 | jest.mock('lit/directives/unsafe-html.js', () => ({ 37 | unsafeHTML: jest.fn((html) => html), 38 | })); 39 | 40 | jest.mock('pwa-helpers', () => ({ 41 | connect: jest.fn(() => jest.fn((elem) => elem)), 42 | installRouter: jest.fn((cb) => cb), 43 | })); 44 | 45 | jest.mock('./selectors', () => ({ 46 | getRouteParams: jest.fn(() => ({})), 47 | isRouteActive: jest.fn(() => false), 48 | })); 49 | 50 | const mockStore = configureStore([]); 51 | 52 | describe('route element', () => { 53 | const customElementsGet = jest.fn(); 54 | 55 | beforeAll(() => { 56 | Object.defineProperty(global, 'window', { 57 | value: { 58 | customElements: { 59 | define: jest.fn(), 60 | get: customElementsGet, 61 | }, 62 | decodeURIComponent: jest.fn((val) => val), 63 | scrollTo: jest.fn(), 64 | }, 65 | }); 66 | }); 67 | 68 | beforeEach(() => { 69 | customElementsGet.mockClear(); 70 | jest.mocked(pwaHelpers.installRouter).mockClear(); 71 | }); 72 | 73 | it('defines the custom element', () => { 74 | connectRouter(mockStore({}) as unknown as TestStore); 75 | 76 | expect(customElement).toHaveBeenCalledWith('lit-route'); 77 | }); 78 | 79 | describe('first updated', () => { 80 | it('installs router', async () => { 81 | connectRouter(mockStore({}) as unknown as TestStore); 82 | const route = new Route(); 83 | 84 | await route.firstUpdated(); 85 | 86 | expect(pwaHelpers.installRouter).toHaveBeenCalledTimes(1); 87 | }); 88 | 89 | it('installs router only once', async () => { 90 | connectRouter(mockStore({}) as unknown as TestStore); 91 | const route = new Route(); 92 | const route2 = new Route(); 93 | 94 | await route.firstUpdated(); 95 | await route2.firstUpdated(); 96 | 97 | expect(pwaHelpers.installRouter).toHaveBeenCalledTimes(1); 98 | }); 99 | 100 | it('registers the route if path present', async () => { 101 | connectRouter(mockStore({}) as unknown as TestStore); 102 | const spy = jest.spyOn(actions, 'addRoute'); 103 | const route = new Route(); 104 | const path = '/1'; 105 | route.path = path; 106 | 107 | await route.firstUpdated(); 108 | 109 | expect(pwaHelpers.installRouter).toHaveBeenCalledTimes(1); 110 | expect(spy).toHaveBeenCalledWith(path); 111 | 112 | spy.mockRestore(); 113 | }); 114 | 115 | it('does not register the route if path not present', async () => { 116 | connectRouter(mockStore({}) as unknown as TestStore); 117 | const spy = jest.spyOn(actions, 'addRoute'); 118 | const route = new Route(); 119 | 120 | await route.firstUpdated(); 121 | 122 | expect(spy).not.toHaveBeenCalled(); 123 | 124 | spy.mockRestore(); 125 | }); 126 | 127 | it('can set active route', async () => { 128 | connectRouter(mockStore({}) as unknown as TestStore); 129 | const route = new Route(); 130 | const spy1 = jest.spyOn(pwaHelpers, 'installRouter'); 131 | const spy2 = jest.spyOn(actions, 'setActiveRoute'); 132 | const pathname = '/example'; 133 | const search = '?test=testing'; 134 | const hash = '#example'; 135 | 136 | await route.firstUpdated(); 137 | const cb = spy1.mock.results[0].value as typeof pwaHelpers.installRouter.arguments; 138 | cb({ hash, pathname, search }); 139 | 140 | expect(spy2).toHaveBeenCalledWith(pathname + search + hash); 141 | 142 | spy1.mockRestore(); 143 | spy2.mockRestore(); 144 | }); 145 | }); 146 | 147 | describe('state changed', () => { 148 | it('can check if route active', () => { 149 | connectRouter(mockStore({}) as unknown as TestStore); 150 | const route = new Route(); 151 | const state = { activeRoute: '/' }; 152 | const path = '/1'; 153 | const spy = jest.spyOn(selectors, 'isRouteActive').mockImplementationOnce(() => true); 154 | route.path = path; 155 | route.scrollDisable = true; 156 | route.stateChanged(state); 157 | 158 | expect(spy).toHaveBeenCalledWith(state, path); 159 | expect(route.active).toBe(true); 160 | expect(window.scrollTo).not.toHaveBeenCalled(); 161 | 162 | spy.mockRestore(); 163 | }); 164 | 165 | it('can get params', () => { 166 | connectRouter(mockStore({}) as unknown as TestStore); 167 | const route = new Route(); 168 | const state = {}; 169 | const path = '/1'; 170 | const params = { one: '1' }; 171 | route.path = path; 172 | const spy = jest.spyOn(selectors, 'getRouteParams').mockImplementationOnce(() => params); 173 | 174 | route.stateChanged(state); 175 | 176 | expect(spy).toHaveBeenCalledWith(state, path); 177 | expect(route.params).toStrictEqual(params); 178 | 179 | spy.mockRestore(); 180 | }); 181 | }); 182 | 183 | describe('render', () => { 184 | beforeAll(() => { 185 | connectRouter(mockStore({}) as unknown as TestStore); 186 | }); 187 | 188 | it('if not active returns empty', () => { 189 | const route = new Route(); 190 | route.active = false; 191 | 192 | const rendered = route.render(); 193 | 194 | expect(rendered).toBeUndefined(); 195 | }); 196 | 197 | it('with children elements', () => { 198 | const route = new Route(); 199 | route.active = true; 200 | 201 | const rendered = route.render(); 202 | 203 | expect(rendered).toBe(''); 204 | }); 205 | 206 | it('with components', () => { 207 | const route = new Route(); 208 | route.active = true; 209 | route.component = 'example'; 210 | 211 | const rendered = route.render(); 212 | 213 | expect(rendered).toBe(''); 214 | }); 215 | 216 | it('with components with parameters', () => { 217 | const route = new Route(); 218 | route.active = true; 219 | route.component = 'example'; 220 | route.params = { 221 | one: '1', 222 | two: '2', 223 | }; 224 | 225 | const rendered = route.render(); 226 | 227 | expect(rendered).toBe(''); 228 | }); 229 | 230 | describe('with component and scrolling', () => { 231 | it('disabled', () => { 232 | const route = new Route(); 233 | const state = { activeRoute: '/test2' }; 234 | route.scrollDisable = true; 235 | route.stateChanged(state); 236 | 237 | expect(window.scrollTo).not.toHaveBeenCalled(); 238 | }); 239 | 240 | it('default', () => { 241 | const route = new Route(); 242 | const state = { activeRoute: '/' }; 243 | const path = '/'; 244 | route.path = path; 245 | jest.spyOn(selectors, 'isRouteActive').mockImplementationOnce(() => true); 246 | route.stateChanged(state); 247 | 248 | expect(window.scrollTo).toHaveBeenCalledWith(0, 0); 249 | }); 250 | 251 | it('via scrollOpt', () => { 252 | const route = new Route(); 253 | const state = { activeRoute: '/' }; 254 | const path = '/'; 255 | const scrollIntoView = jest.fn(); 256 | route.scrollIntoView = scrollIntoView; 257 | route.scrollOpt = { behavior: 'smooth', block: 'nearest', inline: 'nearest' }; 258 | route.path = path; 259 | 260 | jest.spyOn(selectors, 'isRouteActive').mockImplementationOnce(() => true); 261 | route.stateChanged(state); 262 | 263 | expect(scrollIntoView).toHaveBeenCalledWith(route.scrollOpt); 264 | }); 265 | }); 266 | 267 | describe('with dynamic imported components without loading component', () => { 268 | const importFile = async (): Promise => import('../lit-redux-router.js'); 269 | 270 | it('before resolve completes', () => { 271 | const route = new Route(); 272 | route.active = true; 273 | route.component = 'docs-page'; 274 | route.resolve = importFile; 275 | route.path = '/'; 276 | const state = { activeRoute: route.path }; 277 | 278 | customElementsGet.mockReturnValue(undefined); 279 | 280 | jest.spyOn(selectors, 'isRouteActive').mockImplementationOnce(() => true); 281 | 282 | route.stateChanged(state); 283 | 284 | expect(route.isResolving).toBe(true); 285 | 286 | const rendered = route.render(); 287 | 288 | expect(rendered).toBeUndefined(); 289 | }); 290 | 291 | it('after resolve completes', () => { 292 | const route = new Route(); 293 | route.active = true; 294 | route.component = 'docs-page'; 295 | route.resolve = importFile; 296 | route.path = '/'; 297 | const state = { activeRoute: route.path }; 298 | 299 | customElementsGet.mockReturnValue({}); 300 | 301 | jest.spyOn(selectors, 'isRouteActive').mockImplementationOnce(() => true); 302 | route.stateChanged(state); 303 | 304 | expect(route.isResolving).toBe(false); 305 | 306 | const rendered = route.render(); 307 | 308 | expect(rendered).toBe(''); 309 | }); 310 | }); 311 | 312 | describe('with dynamic imported components with loading component', () => { 313 | const importFile = async (): Promise => import('../lit-redux-router.js'); 314 | 315 | it('before resolve completes', () => { 316 | const route = new Route(); 317 | route.active = true; 318 | route.component = 'docs-page'; 319 | route.resolve = importFile; 320 | route.loading = 'my-loading'; 321 | route.path = '/'; 322 | const state = { activeRoute: route.path }; 323 | 324 | customElementsGet.mockReturnValue(undefined); 325 | 326 | const spy = jest.spyOn(selectors, 'isRouteActive').mockImplementationOnce(() => true); 327 | route.stateChanged(state); 328 | 329 | expect(spy).toHaveBeenCalledWith(state, route.path); 330 | expect(route.isResolving).toBe(true); 331 | 332 | const rendered = route.render(); 333 | 334 | expect(rendered).toBe(''); 335 | }); 336 | 337 | it('after resolve completes', () => { 338 | const route = new Route(); 339 | route.active = true; 340 | route.component = 'docs-page'; 341 | route.resolve = importFile; 342 | route.loading = 'my-loading'; 343 | route.path = '/'; 344 | const state = { activeRoute: route.path }; 345 | 346 | customElementsGet.mockReturnValue({}); 347 | 348 | const spy = jest.spyOn(selectors, 'isRouteActive').mockImplementationOnce(() => true); 349 | route.stateChanged(state); 350 | 351 | expect(spy).toHaveBeenCalledWith(state, route.path); 352 | expect(route.isResolving).toBe(false); 353 | 354 | const rendered = route.render(); 355 | 356 | expect(rendered).toBe(''); 357 | }); 358 | 359 | it('before reject completes', () => { 360 | const route = new Route(); 361 | route.active = true; 362 | route.component = 'docs-page'; 363 | route.resolve = async (): Promise => Promise.reject(new Error()); 364 | route.loading = 'my-loading'; 365 | route.path = '/'; 366 | const state = { activeRoute: route.path }; 367 | 368 | customElementsGet.mockReturnValue(undefined); 369 | 370 | const spy = jest.spyOn(selectors, 'isRouteActive').mockImplementationOnce(() => true); 371 | route.stateChanged(state); 372 | 373 | expect(spy).toHaveBeenCalledWith(state, route.path); 374 | expect(route.isResolving).toBe(true); 375 | 376 | const rendered = route.render(); 377 | 378 | expect(rendered).toBe(''); 379 | }); 380 | 381 | it('after reject completes', () => { 382 | const route = new Route(); 383 | route.active = true; 384 | route.component = 'docs-page'; 385 | route.resolve = async (): Promise => Promise.reject(new Error()); 386 | route.loading = 'my-loading'; 387 | route.path = '/'; 388 | 389 | const state = { activeRoute: route.path }; 390 | 391 | customElementsGet.mockReturnValue({}); 392 | 393 | const spy = jest.spyOn(selectors, 'isRouteActive').mockImplementationOnce(() => true); 394 | route.stateChanged(state); 395 | 396 | expect(spy).toHaveBeenCalledWith(state, route.path); 397 | expect(route.isResolving).toBe(false); 398 | 399 | const rendered = route.render(); 400 | 401 | expect(rendered).toBe(''); 402 | }); 403 | }); 404 | }); 405 | 406 | describe('nested routes', () => { 407 | it('composes and sets the path', async () => { 408 | connectRouter(mockStore({}) as unknown as TestStore); 409 | const spy = jest.spyOn(actions, 'addRoute'); 410 | const route = new Route(); 411 | route.path = '/second'; 412 | route.parentElement = new Route(); 413 | route.parentElement.path = '/first'; 414 | route.parentElement.closest = (): void => undefined; 415 | const spy2 = jest 416 | .spyOn(route.parentElement, 'closest') 417 | .mockReturnValueOnce(route.parentElement); 418 | 419 | await route.firstUpdated(); 420 | 421 | expect(route).toHaveProperty('path', '/first/second'); 422 | 423 | spy.mockRestore(); 424 | spy2.mockRestore(); 425 | }); 426 | 427 | it('does not compose and return its path when no child route', async () => { 428 | connectRouter(mockStore({}) as unknown as TestStore); 429 | const spy = jest.spyOn(actions, 'addRoute'); 430 | const route = new Route(); 431 | route.path = '/second'; 432 | route.parentElement = {}; 433 | route.parentElement.closest = (): void => undefined; 434 | const spy2 = jest.spyOn(route.parentElement, 'closest').mockReturnValueOnce(null); 435 | 436 | await route.firstUpdated(); 437 | 438 | expect(route).toHaveProperty('path', '/second'); 439 | 440 | spy.mockRestore(); 441 | spy2.mockRestore(); 442 | }); 443 | 444 | it('parent routes match with wildcard', async () => { 445 | connectRouter(mockStore({}) as unknown as TestStore); 446 | const spy = jest.spyOn(actions, 'addRoute'); 447 | const route = new Route(); 448 | route.path = '/about'; 449 | const childRoute = new Route(); 450 | childRoute.path = '/me'; 451 | const spy2 = jest.spyOn(route, 'querySelector').mockReturnValueOnce(childRoute); 452 | 453 | await route.firstUpdated(); 454 | 455 | expect(route).toHaveProperty('path', '/about.*'); 456 | 457 | spy.mockRestore(); 458 | spy2.mockRestore(); 459 | }); 460 | }); 461 | }); 462 | -------------------------------------------------------------------------------- /src/lib/route.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, nothing, type TemplateResult } from 'lit'; 2 | import { customElement, property, state } from 'lit/decorators.js'; 3 | import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 4 | import { connect, installRouter } from 'pwa-helpers'; 5 | import type { LazyStore } from 'pwa-helpers/lazy-reducer-enhancer.ts'; 6 | import type { Store } from 'redux'; 7 | import { addRoute, setActiveRoute } from './actions.ts'; 8 | import { getRouteParams, isRouteActive, type State } from './selectors.ts'; 9 | 10 | // eslint-disable-next-line @typescript-eslint/init-declarations, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any 11 | export let RouteClass: any; 12 | 13 | // eslint-disable-next-line max-lines-per-function 14 | export default (store: LazyStore & Store): void => { 15 | // 16 | /** 17 | * Element that renders its content or a component 18 | * when browser route matches 19 | * @element lit-route 20 | * @demo ../demo/index.html 21 | */ 22 | @customElement('lit-route') 23 | class Route extends connect(store)(LitElement) { 24 | private static routerInstalled = false; 25 | 26 | @property({ reflect: true, type: Boolean }) 27 | public active = false; 28 | 29 | @property({ type: String }) 30 | public component?: string; 31 | 32 | @property({ type: String }) 33 | public path?: string | undefined; 34 | 35 | @property() 36 | public resolve?: () => Promise; 37 | 38 | @property({ type: String }) 39 | public loading?: string; 40 | 41 | @property({ type: Object }) 42 | public scrollOpt?: ScrollIntoViewOptions | boolean; 43 | 44 | @property({ type: Boolean }) 45 | public scrollDisable = false; 46 | 47 | @state() 48 | protected params: Record = {}; 49 | 50 | @state() 51 | protected isResolving = false; 52 | 53 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 54 | public override async firstUpdated(): Promise { 55 | await this.updateComplete; 56 | if (!Route.routerInstalled) { 57 | installRouter(({ pathname, search, hash }: Location): void => { 58 | const path = window.decodeURIComponent(pathname + search + hash); 59 | store.dispatch(setActiveRoute(path)); 60 | }); 61 | Route.routerInstalled = true; 62 | } 63 | 64 | let current: HTMLElement | Route | null = this.parentElement; 65 | let { path } = this; 66 | 67 | while (current) { 68 | const closestLitRoute: Route | null = current.closest('lit-route'); 69 | 70 | if (closestLitRoute) { 71 | path = `${closestLitRoute.path ?? ''}${path ?? ''}`; 72 | } 73 | 74 | current = closestLitRoute?.parentElement ?? null; 75 | } 76 | 77 | const hasChildRoutes = Boolean(this.querySelector('lit-route')); 78 | 79 | if (hasChildRoutes) { 80 | path += '.*'; 81 | } 82 | 83 | this.path = path; 84 | 85 | if (typeof this.path !== 'undefined') { 86 | store.dispatch(addRoute(this.path)); 87 | } 88 | } 89 | 90 | public override stateChanged(newState: State): void { 91 | const isActive = isRouteActive(newState, this.path); 92 | const hasBecomeActive = !this.active && isActive; 93 | this.active = isActive; 94 | this.params = getRouteParams(newState, this.path); 95 | 96 | if (this.active && this.resolve) { 97 | this.setResolving(); 98 | this.resolve() 99 | .then((): void => { 100 | this.unsetResolving(); 101 | }) 102 | .catch((): void => { 103 | this.unsetResolving(); 104 | }); 105 | } 106 | if (this.active && !this.scrollDisable) { 107 | if (typeof this.scrollOpt === 'undefined') { 108 | window.scrollTo(0, 0); 109 | } else if (hasBecomeActive) { 110 | this.scrollIntoView(this.scrollOpt); 111 | } 112 | } 113 | } 114 | 115 | public override render(): TemplateResult | typeof nothing { 116 | if (!this.active) { 117 | return nothing; 118 | } 119 | 120 | if (this.resolve && this.isResolving) { 121 | return typeof this.loading === 'undefined' ? nothing : this.getTemplate(this.loading); 122 | } 123 | 124 | if (typeof this.component === 'undefined') { 125 | return html``; 126 | } 127 | 128 | return this.getTemplate(this.component, this.params); 129 | } 130 | 131 | private getTemplate( 132 | component: string, 133 | attributesObject?: Record, 134 | ): TemplateResult { 135 | const tagName = component.replace(/[^A-Za-z0-9-]/u, ''); 136 | let attributes = ''; 137 | 138 | if (attributesObject) { 139 | attributes = Object.keys(attributesObject) 140 | .map((param: string): string => ` ${param}="${this.params[param]}"`) 141 | .join(''); 142 | } 143 | 144 | const template = `<${tagName}${attributes}>`; 145 | 146 | return html`${unsafeHTML(template)}`; 147 | } 148 | 149 | private setResolving(): void { 150 | if ( 151 | typeof this.component !== 'undefined' && 152 | typeof window.customElements.get(this.component) === 'undefined' 153 | ) { 154 | this.isResolving = true; 155 | } 156 | } 157 | 158 | private unsetResolving(): void { 159 | if ( 160 | typeof this.component !== 'undefined' && 161 | typeof window.customElements.get(this.component) !== 'undefined' 162 | ) { 163 | this.isResolving = false; 164 | } 165 | } 166 | } 167 | 168 | RouteClass = Route; 169 | }; 170 | 171 | declare global { 172 | interface Window { 173 | decodeURIComponent: (encodedURIComponent: string) => string; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/lib/selectors.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { describe, expect, it } from '@jest/globals'; 3 | import { getRoute, getRouteParams, isRouteActive, noRouteActive } from './selectors.ts'; 4 | 5 | describe('router selectors', () => { 6 | describe('get route', () => { 7 | it('return route object', () => { 8 | const path = '/about'; 9 | const route = { active: true, params: { id: '1' } }; 10 | const state = { 11 | router: { 12 | activeRoute: '/', 13 | routes: { 14 | [path]: route, 15 | }, 16 | }, 17 | }; 18 | 19 | expect(getRoute(state, path)).toStrictEqual(route); 20 | }); 21 | 22 | it('return undefined when not present', () => { 23 | const state = { 24 | router: { 25 | activeRoute: '/', 26 | routes: {}, 27 | }, 28 | }; 29 | 30 | expect(getRoute(state, '/about')).toBeUndefined(); 31 | }); 32 | }); 33 | 34 | describe('no route active', () => { 35 | it('when no routes present', () => { 36 | const state = { 37 | router: { 38 | activeRoute: '/', 39 | routes: {}, 40 | }, 41 | }; 42 | 43 | expect(noRouteActive(state)).toBe(true); 44 | }); 45 | 46 | it('when no route active present', () => { 47 | const state = { 48 | router: { 49 | activeRoute: '/', 50 | routes: { 51 | '/about': { active: false }, 52 | '/contact': { active: false }, 53 | }, 54 | }, 55 | }; 56 | 57 | expect(noRouteActive(state)).toBe(true); 58 | }); 59 | 60 | it('when route active present', () => { 61 | const state = { 62 | router: { 63 | activeRoute: '/', 64 | routes: { 65 | '/': { active: true }, 66 | '/about': { active: false }, 67 | }, 68 | }, 69 | }; 70 | 71 | expect(noRouteActive(state)).toBe(false); 72 | }); 73 | }); 74 | 75 | describe('is active', () => { 76 | it('can be true', () => { 77 | const path = '/about'; 78 | const state = { 79 | router: { 80 | activeRoute: '/', 81 | routes: { 82 | [path]: { 83 | active: true, 84 | }, 85 | }, 86 | }, 87 | }; 88 | 89 | expect(isRouteActive(state, path)).toBe(true); 90 | }); 91 | 92 | it('can be true if called with empty path and no route is active', () => { 93 | const state = { 94 | router: { 95 | activeRoute: '/', 96 | routes: { 97 | '/about': { active: false }, 98 | '/contact': { active: false }, 99 | }, 100 | }, 101 | }; 102 | 103 | expect(isRouteActive(state)).toBe(true); 104 | }); 105 | 106 | it('can be false', () => { 107 | const path = '/about'; 108 | const state = { 109 | router: { 110 | activeRoute: '/', 111 | routes: { 112 | [path]: { 113 | active: false, 114 | }, 115 | }, 116 | }, 117 | }; 118 | 119 | expect(isRouteActive(state, path)).toBe(false); 120 | }); 121 | 122 | it('is false if route not present', () => { 123 | const path = '/about'; 124 | const state = { 125 | router: { 126 | activeRoute: '/', 127 | routes: {}, 128 | }, 129 | }; 130 | 131 | expect(isRouteActive(state, path)).toBe(false); 132 | }); 133 | }); 134 | 135 | describe('get params', () => { 136 | it('return empty object when route not present', () => { 137 | const path = '/about'; 138 | const state = { 139 | router: { 140 | activeRoute: '/', 141 | routes: {}, 142 | }, 143 | }; 144 | 145 | expect(getRouteParams(state, path)).toStrictEqual({}); 146 | }); 147 | 148 | it('return params object', () => { 149 | const path = '/about'; 150 | const route = { active: true, params: { id: '1' } }; 151 | const state = { 152 | router: { 153 | activeRoute: '/', 154 | routes: { 155 | [path]: route, 156 | }, 157 | }, 158 | }; 159 | 160 | expect(getRouteParams(state, path)).toStrictEqual(route.params); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/lib/selectors.ts: -------------------------------------------------------------------------------- 1 | import type { Route, RouterState } from './reducer.ts'; 2 | 3 | export interface State { 4 | router: RouterState; 5 | } 6 | 7 | export const getRoute = ({ router: { routes } }: State, route?: string): Route | undefined => 8 | // eslint-disable-next-line no-undefined 9 | typeof route !== 'undefined' && route in routes ? routes[route] : undefined; 10 | 11 | export const noRouteActive = ({ router: { routes } }: State): boolean => 12 | Object.keys(routes).reduce( 13 | (noActive: boolean, route: string): boolean => noActive && !routes[route].active, 14 | true, 15 | ); 16 | 17 | export const isRouteActive = (state: State, route?: string): boolean => 18 | typeof route === 'undefined' ? noRouteActive(state) : Boolean(getRoute(state, route)?.active); 19 | 20 | export const getRouteParams = (state: State, route?: string): NonNullable => 21 | getRoute(state, route)?.params ?? {}; 22 | -------------------------------------------------------------------------------- /src/lib/service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, jest } from '@jest/globals'; 2 | import { checkNavigation, refreshRoute } from './service.ts'; 3 | 4 | describe('router service', () => { 5 | it('match static route', () => { 6 | const route = '/contact'; 7 | const activeRoute = '/contact'; 8 | 9 | const SUT = refreshRoute(route, activeRoute); 10 | 11 | expect(SUT.active).toBe(true); 12 | }); 13 | 14 | it('match a parameter', () => { 15 | const route = '/product/:id'; 16 | const activeRoute = '/product/1'; 17 | 18 | const SUT = refreshRoute(route, activeRoute); 19 | 20 | expect(SUT.active).toBe(true); 21 | expect(SUT.params).toStrictEqual({ id: '1' }); 22 | }); 23 | 24 | it('do not match a parameter if route not active', () => { 25 | const route = '/product/:id'; 26 | const activeRoute = '/other/1'; 27 | 28 | const SUT = refreshRoute(route, activeRoute); 29 | 30 | expect(SUT.active).toBe(false); 31 | expect(SUT.params).toStrictEqual({}); 32 | }); 33 | 34 | it('match a parameter and not after text', () => { 35 | const route = '/product/:id'; 36 | const activeRoute = '/product/1/edit'; 37 | 38 | const SUT = refreshRoute(route, activeRoute); 39 | 40 | expect(SUT.active).toBe(false); 41 | expect(SUT.params).toStrictEqual({}); 42 | }); 43 | 44 | it('match a parameter and after text', () => { 45 | const route = '/product/:id/edit'; 46 | const activeRoute = '/product/1/edit'; 47 | 48 | const SUT = refreshRoute(route, activeRoute); 49 | 50 | expect(SUT.active).toBe(true); 51 | expect(SUT.params).toStrictEqual({ id: '1' }); 52 | }); 53 | 54 | it('match a more than one parameter', () => { 55 | const route = '/product/:id/:time'; 56 | const activeRoute = '/product/1/nov2017'; 57 | 58 | const SUT = refreshRoute(route, activeRoute); 59 | 60 | expect(SUT.active).toBe(true); 61 | expect(SUT.params).toStrictEqual({ id: '1', time: 'nov2017' }); 62 | }); 63 | 64 | it('match a starting parameter', () => { 65 | const route = '/:id/product'; 66 | const activeRoute = '/1/product'; 67 | 68 | const SUT = refreshRoute(route, activeRoute); 69 | 70 | expect(SUT.active).toBe(true); 71 | expect(SUT.params).toStrictEqual({ id: '1' }); 72 | }); 73 | 74 | it('match an optional parameter without value', () => { 75 | const route = '/product/:id?'; 76 | const activeRoute = '/product'; 77 | 78 | const SUT = refreshRoute(route, activeRoute); 79 | 80 | expect(SUT.active).toBe(true); 81 | expect(SUT.params).toStrictEqual({ id: '' }); 82 | }); 83 | 84 | it('match an optional parameter with value', () => { 85 | const route = '/product/:id?'; 86 | const activeRoute = '/product/1'; 87 | 88 | const SUT = refreshRoute(route, activeRoute); 89 | 90 | expect(SUT.active).toBe(true); 91 | expect(SUT.params).toStrictEqual({ id: '1' }); 92 | }); 93 | 94 | it('match more than one optional parameter without values', () => { 95 | const route = '/product/:id?/:time?'; 96 | const activeRoute = '/product'; 97 | 98 | const SUT = refreshRoute(route, activeRoute); 99 | 100 | expect(SUT.active).toBe(true); 101 | expect(SUT.params).toStrictEqual({ id: '', time: '' }); 102 | }); 103 | 104 | it('match more than one optional parameter with one value', () => { 105 | const route = '/product/:id?/:time?'; 106 | const activeRoute = '/product/1'; 107 | 108 | const SUT = refreshRoute(route, activeRoute); 109 | 110 | expect(SUT.active).toBe(true); 111 | expect(SUT.params).toStrictEqual({ id: '1', time: '' }); 112 | }); 113 | 114 | it('match more than one optional parameter with all values', () => { 115 | const route = '/product/:id?/:time?'; 116 | const activeRoute = '/product/1/nov2017'; 117 | 118 | const SUT = refreshRoute(route, activeRoute); 119 | 120 | expect(SUT.active).toBe(true); 121 | expect(SUT.params).toStrictEqual({ id: '1', time: 'nov2017' }); 122 | }); 123 | 124 | it('check navigation pushes route to history', () => { 125 | const route = '/about'; 126 | const spy = jest.spyOn(window.history, 'pushState'); 127 | 128 | checkNavigation(route); 129 | 130 | expect(spy).toHaveBeenCalledWith({}, '', route); 131 | }); 132 | 133 | it('match routes with query string', () => { 134 | const route = '/product/:id'; 135 | const activeRoute = '/product/1?asdf=123'; 136 | 137 | const SUT = refreshRoute(route, activeRoute); 138 | 139 | expect(SUT.active).toBe(true); 140 | expect(SUT.params).toStrictEqual({ id: '1' }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /src/lib/service.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'regexparam'; 2 | import type { Route } from './reducer.ts'; 3 | 4 | export const refreshRoute = (route: string, activeRoute: string): Route => { 5 | const { pattern, keys } = parse(route); 6 | // eslint-disable-next-line prefer-named-capture-group 7 | const noQueryRoute = activeRoute.replace(/(\?|#).*/u, ''); 8 | const match = pattern.exec(noQueryRoute); 9 | const active = pattern.test(noQueryRoute); 10 | 11 | return { 12 | active, 13 | params: active 14 | ? keys.reduce( 15 | ( 16 | list: NonNullable, 17 | item: string, 18 | index: number, 19 | ): NonNullable => ({ 20 | ...list, 21 | [item]: match?.[index + 1] ?? '', 22 | }), 23 | {}, 24 | ) 25 | : {}, 26 | }; 27 | }; 28 | 29 | export const checkNavigation = (route: string): void => { 30 | window.history.pushState({}, '', route); 31 | }; 32 | -------------------------------------------------------------------------------- /src/lit-redux-router.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-deprecated */ 2 | import { describe, expect, it, jest } from '@jest/globals'; 3 | import type { LazyStore } from 'pwa-helpers/lazy-reducer-enhancer.ts'; 4 | import type { Store } from 'redux'; 5 | // eslint-disable-next-line import/no-named-as-default 6 | import configureStore from 'redux-mock-store'; 7 | import { navigate as navigateAction } from './lib/actions.ts'; 8 | import reducer from './lib/reducer.ts'; 9 | import Route from './lib/route.ts'; 10 | import { connectRouter, navigate } from './lit-redux-router.ts'; 11 | 12 | jest.mock('./lib/route', () => jest.fn()); 13 | jest.mock('./lib/reducer', () => jest.fn()); 14 | jest.mock('./lib/actions', () => ({ navigate: jest.fn() })); 15 | 16 | const mockStore = configureStore([]); 17 | 18 | describe('lit redux router', () => { 19 | it('connects router to reducer', () => { 20 | const store = mockStore({}) as unknown as LazyStore & Store>; 21 | const addReducers = jest.fn(); 22 | store.addReducers = addReducers; 23 | connectRouter(store); 24 | 25 | expect(addReducers).toHaveBeenCalledWith({ router: reducer }); 26 | }); 27 | 28 | it('creates the route component connected to store', () => { 29 | const store = mockStore({}) as unknown as LazyStore & Store>; 30 | const addReducers = jest.fn(); 31 | store.addReducers = addReducers; 32 | connectRouter(store); 33 | 34 | expect(Route).toHaveBeenCalledWith(store); 35 | }); 36 | 37 | it('exports a navigate action', () => { 38 | expect(navigate).toStrictEqual(navigateAction); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/lit-redux-router.ts: -------------------------------------------------------------------------------- 1 | import type { LazyStore } from 'pwa-helpers/lazy-reducer-enhancer.ts'; 2 | import type { Store } from 'redux'; 3 | import reducer from './lib/reducer.ts'; 4 | import Route from './lib/route.ts'; 5 | 6 | export const connectRouter = (store: LazyStore & Store): void => { 7 | store.addReducers({ router: reducer }); 8 | 9 | // eslint-disable-next-line new-cap 10 | Route(store); 11 | }; 12 | 13 | export { navigate } from './lib/actions.js'; 14 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { "noEmit": false, "outDir": ".", "rewriteRelativeImportExtensions": true }, 4 | "exclude": ["**/*.test.*"], 5 | "include": ["src/**/*"] 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "experimentalDecorators": true, 8 | "inlineSources": true, 9 | "lib": ["dom", "dom.iterable", "es2021"], 10 | "module": "ESNext", 11 | "moduleResolution": "node", 12 | "noEmit": true, 13 | "noUncheckedIndexedAccess": false, 14 | "plugins": [{ "name": "ts-lit-plugin", "strict": true }], 15 | "resolveJsonModule": true, 16 | "rootDirs": ["src", "demo"], 17 | "sourceMap": true, 18 | "target": "es2019" 19 | }, 20 | "exclude": ["*.config.js", "coverage", "lib", "lit-redux-router.*"], 21 | "include": [".**/**/*", "**/*"] 22 | } 23 | --------------------------------------------------------------------------------