├── .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 | [](https://unpkg.com/lit-redux-router/lit-redux-router.min.js 'Gzip Bundle Size')
6 | [](https://github.com/fernandopasik/lit-redux-router/actions/workflows/main.yml 'Build Status')
7 | [](https://codecov.io/gh/fernandopasik/lit-redux-router 'Coverage Status')
8 | [](https://snyk.io/test/github/fernandopasik/lit-redux-router?targetFile=package.json 'Known Vulnerabilities')
9 |
10 | [](#contributors)
11 | [](https://www.npmjs.com/package/lit-redux-router 'npm version')
12 | [](https://www.npmjs.com/package/lit-redux-router 'npm downloads')
13 | [](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 | this.goTo('/about')}">learn more about us
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 |
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 |
108 | home
109 | products
110 | about
111 | contact
112 | lazy
113 | not exist
114 |
115 |
116 |
117 |
404
118 |
119 | Home
120 | learn more about us
121 |
122 |
123 |
124 | About
125 | Me
126 |
127 | About Me
128 |
129 |
130 |
131 |
138 |
139 |
trigger other state change
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 | name
44 |
45 |
46 |
47 | email
48 |
49 |
50 | submit
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}>${tagName}>`;
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 |
--------------------------------------------------------------------------------