├── docs ├── index.md ├── .nojekyll ├── CNAME ├── img │ ├── workflow.png │ ├── Different-Errors.png │ └── documentation-button.png ├── interactive-example.md ├── _sidebar.md ├── badges │ ├── badge-lines.svg │ ├── badge-branches.svg │ ├── badge-functions.svg │ └── badge-statements.svg ├── index.html ├── why.md ├── _coverpage.md ├── error-handling.md ├── testing.md ├── advanced-usecases.md ├── getting-started.md └── state.md ├── .npmignore ├── .gitignore ├── .grenrc.yml ├── .devcontainer └── devcontainer.json ├── babel.config.cjs ├── src ├── dispatch.ts ├── index.ts ├── pure-lit.minimal.test.ts ├── dispatch.test.ts ├── types.ts ├── pure-lit.props.test.ts ├── pure-lit.props-defaults.test.ts ├── pure-lit.suspense-unhappy.test.ts ├── pure-lit.boolean-attrs.test.ts ├── pure-lit.suspense.part2.test.ts ├── pure-lit.suspense.part3.test.ts ├── pure-lit.suspense.part1.test.ts ├── properties.ts ├── pure-lit.test.ts ├── properties.test.ts ├── pure-lit.async.test.ts └── pure-lit.ts ├── .github ├── workflows │ ├── run-test.yml │ ├── dependabot.yml │ └── npm-publish.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── tsconfig.json ├── LICENCE ├── coverage └── coverage-summary.json ├── rollup.config.js ├── readme.md ├── package.json ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md /docs/index.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | pure-lit.org -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | docs/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | docs/pure-lit 3 | lib/ 4 | *-conf.yml -------------------------------------------------------------------------------- /docs/img/workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthiasKainer/pure-lit/HEAD/docs/img/workflow.png -------------------------------------------------------------------------------- /docs/img/Different-Errors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthiasKainer/pure-lit/HEAD/docs/img/Different-Errors.png -------------------------------------------------------------------------------- /.grenrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dataSource: "commits" 3 | includeMessages: "commits" 4 | changelogFilename: "CHANGELOG.md" 5 | -------------------------------------------------------------------------------- /docs/img/documentation-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthiasKainer/pure-lit/HEAD/docs/img/documentation-button.png -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 3 | "features": { 4 | "ghcr.io/devcontainers/features/node:1": { } 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/interactive-example.md: -------------------------------------------------------------------------------- 1 | # Interactive example 2 | 3 | Use this example to play around with pure-lit yourself 4 | 5 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | targets: { 7 | node: "current", 8 | }, 9 | }, 10 | ], 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /src/dispatch.ts: -------------------------------------------------------------------------------- 1 | import { LitElementWithProps } from "."; 2 | 3 | export const dispatch = (element: LitElementWithProps, type: string, data?: T, options?: CustomEventInit) => 4 | element.dispatchEvent(new CustomEvent(type.trim(), {...options, detail : data})) -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {pureLit} from "./pure-lit" 2 | export * from "./types" 3 | export {useState, useReducer, useWorkflow, State, Reduce, Reducer, Workflow, WorkflowHistory } from "lit-element-state-decoupler" 4 | export {useEffect, useOnce} from "lit-element-effect" 5 | export {dispatch} from "./dispatch" -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | * [Home](/) 2 | * [Getting Started](getting-started.md) 3 | * [But... why?](why.md) 4 | * [Testing](testing.md#Testing) 5 | * [Error Handling](error-handling.md#error-boundaries) 6 | * [Advanced Usecases](advanced-usecases.md#Advanced-usage) 7 | * [State Handling](state.md#State) 8 | * [Interactive Example](interactive-example.md#interactive-example) -------------------------------------------------------------------------------- /src/pure-lit.minimal.test.ts: -------------------------------------------------------------------------------- 1 | import { html } from "lit"; 2 | import { pureLit } from "./pure-lit"; 3 | 4 | test("pure-lit", async () => { 5 | const component = pureLit("hello-lit", () => html`

Hello Lit!

`); 6 | document.body.appendChild(component) 7 | await component.updateComplete 8 | expect(component.shadowRoot?.textContent).toContain("Hello Lit!") 9 | }) -------------------------------------------------------------------------------- /.github/workflows/run-test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 13 | - uses: actions/checkout@v2 14 | 15 | - name: Setup Node.js environment 16 | uses: actions/setup-node@v2 17 | 18 | - name: Setup npm 19 | run: | 20 | npm install 21 | 22 | - name: Build the project 23 | run: npm run build 24 | 25 | - name: Test the project 26 | run: npm test 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "es2015", 5 | "lib": ["es2017", "dom", "dom.iterable"], 6 | "outDir": "./dist", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "allowSyntheticDefaultImports": true, 17 | "experimentalDecorators": true, 18 | "forceConsistentCasingInFileNames": true, 19 | }, 20 | "include": ["**/*.ts"], 21 | "exclude": ["docs/", "dist/"] 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Matthias Kainer 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-approve 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v1.1.1 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Approve a PR 19 | run: gh pr review --approve "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | - name: Enable auto-merge for Dependabot PRs 24 | run: gh pr merge --auto --merge "$PR_URL" 25 | env: 26 | PR_URL: ${{github.event.pull_request.html_url}} 27 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | -------------------------------------------------------------------------------- /src/dispatch.test.ts: -------------------------------------------------------------------------------- 1 | import {dispatch} from "." 2 | 3 | describe("on", () => { 4 | const mock = jest.fn() 5 | const mockElement = { 6 | dispatchEvent: (e: CustomEvent) => mock(e.type, e.detail, e.bubbles) 7 | } 8 | beforeEach(() => { 9 | jest.resetAllMocks() 10 | }) 11 | 12 | it("adds the event dispatcher to the element with no data and bubbling", () => { 13 | dispatch(mockElement, "custom"); 14 | expect(mock).toBeCalledWith("custom", null, false) 15 | }) 16 | 17 | it("adds the event dispatcher and data if passed", () => { 18 | dispatch(mockElement, "custom", "data"); 19 | expect(mock).toBeCalledWith("custom", "data", false) 20 | }) 21 | it("adds the event dispatcher, data and options if passed", () => { 22 | dispatch(mockElement, "custom", "data", { bubbles: true }); 23 | expect(mock).toBeCalledWith("custom", "data", true) 24 | }) 25 | }) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /docs/badges/badge-lines.svg: -------------------------------------------------------------------------------- 1 | Coverage:lines: 100%Coverage:lines100% -------------------------------------------------------------------------------- /docs/badges/badge-branches.svg: -------------------------------------------------------------------------------- 1 | Coverage:branches: 100%Coverage:branches100% -------------------------------------------------------------------------------- /docs/badges/badge-functions.svg: -------------------------------------------------------------------------------- 1 | Coverage:functions: 100%Coverage:functions100% -------------------------------------------------------------------------------- /docs/badges/badge-statements.svg: -------------------------------------------------------------------------------- 1 | Coverage:statements: 100%Coverage:statements100% -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | 9 | 17 | 18 | 19 |
20 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /coverage/coverage-summary.json: -------------------------------------------------------------------------------- 1 | {"total": {"lines":{"total":101,"covered":101,"skipped":0,"pct":100},"statements":{"total":116,"covered":116,"skipped":0,"pct":100},"functions":{"total":38,"covered":38,"skipped":0,"pct":100},"branches":{"total":52,"covered":52,"skipped":0,"pct":100},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":"Unknown"}} 2 | ,"/home/mkainer/projects/private/pure-lit/src/dispatch.ts": {"lines":{"total":2,"covered":2,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} 3 | ,"/home/mkainer/projects/private/pure-lit/src/properties.ts": {"lines":{"total":24,"covered":24,"skipped":0,"pct":100},"functions":{"total":11,"covered":11,"skipped":0,"pct":100},"statements":{"total":34,"covered":34,"skipped":0,"pct":100},"branches":{"total":19,"covered":19,"skipped":0,"pct":100}} 4 | ,"/home/mkainer/projects/private/pure-lit/src/pure-lit.ts": {"lines":{"total":75,"covered":75,"skipped":0,"pct":100},"functions":{"total":26,"covered":26,"skipped":0,"pct":100},"statements":{"total":79,"covered":79,"skipped":0,"pct":100},"branches":{"total":33,"covered":33,"skipped":0,"pct":100}} 5 | } 6 | -------------------------------------------------------------------------------- /docs/why.md: -------------------------------------------------------------------------------- 1 | # Rational 2 | 3 | The main idea behind pureLit is to reduce the amount of code one has to write. Compare the code you need for a standard Lit-Element with one where pure-lit to see the result: 4 | 5 | 22 |
23 | 24 | [gist: before.ts](https://gist.githubusercontent.com/MatthiasKainer/ef075d9c0200964351b1c0a4392b9772/raw/2d4a52259201d2c5c9db68bd8a8e418b7cdbe766/before.ts ':include :type=code') 25 | 26 | [gist: after.ts](https://gist.githubusercontent.com/MatthiasKainer/410795b0c437936124002afde32cdd52/raw/702e5e11b7a53942c5ed33a9a3954c53cb6d64f1/after.ts ':include :type=code') 27 | 28 |
29 | 30 | A longer description on the why can be found in the article [Write Less Code For Smaller Packages With `pure-lit`](https://matthias-kainer.de/blog/posts/write-less-code-with-pure-lit/) -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, CSSResultArray, TemplateResult, LitElement, PropertyDeclaration, CSSResultGroup } from "lit"; 2 | 3 | export type PurePropertyDeclaration = { 4 | [key: string]: PropertyDeclaration; 5 | } 6 | 7 | export type PropDefinedPureArguments = { 8 | styles?: CSSResultGroup 9 | props?: (PurePropertyDeclaration | string)[] 10 | defaults?: TProps & DefaultObjectDefinition 11 | suspense?: TemplateResult 12 | } 13 | 14 | export type DefaultObjectDefinition = {[key: string]: unknown} 15 | 16 | export type DefaultDefinedPureArguments = { 17 | styles?: CSSResult | CSSResultArray 18 | defaults?: TProps & DefaultObjectDefinition 19 | suspense?: TemplateResult 20 | } 21 | 22 | export type PureArguments = PropDefinedPureArguments | DefaultDefinedPureArguments 23 | 24 | export type LitElementWithProps = LitElement & TProps & { reinitialize : () => void, suspenseComplete: () => Promise } 25 | 26 | export type RenderFunction = (element: LitElementWithProps) => TemplateResult 27 | export type AsyncRenderFunction = (element: LitElementWithProps) => Promise 28 | 29 | export type RegisteredElements = { 30 | [elementName: string] : LitElementWithProps 31 | } -------------------------------------------------------------------------------- /src/pure-lit.props.test.ts: -------------------------------------------------------------------------------- 1 | import { pureLit } from "./pure-lit"; 2 | import { html, css } from "lit"; 3 | import { LitElementWithProps } from "./types"; 4 | 5 | describe("pure-lit with prop specs", () => { 6 | type Props = { who: string, whoElse: string } 7 | let component: LitElementWithProps 8 | beforeEach(() => { 9 | component = pureLit("my-component", 10 | (el : LitElementWithProps) => html`

Hello ${el.who}${el.whoElse}!

`, 11 | { 12 | styles: [css`:host {}`], 13 | props: [ {"who": {type: String} }, {"whoElse": {type: String}} ] 14 | }); 15 | document.body.appendChild(component) 16 | }) 17 | 18 | afterEach(() => { 19 | document.body.removeChild(component) 20 | }) 21 | 22 | 23 | it("renders the empty defaults", async () => { 24 | await component.updateComplete 25 | expect(component.shadowRoot?.textContent).toContain("Hello !") 26 | }); 27 | it("renders updated props correctlty", async () => { 28 | component.setAttribute("who", "John") 29 | await component.updateComplete 30 | expect(component.shadowRoot?.textContent).toContain("Hello John!"); 31 | }); 32 | it("renders updated dashed props correctlty", async () => { 33 | component.setAttribute("who", "John") 34 | component.setAttribute("who-else", "Wayne") 35 | await component.updateComplete 36 | expect(component.shadowRoot?.textContent).toContain("Hello JohnWayne!"); 37 | }); 38 | }) -------------------------------------------------------------------------------- /src/pure-lit.props-defaults.test.ts: -------------------------------------------------------------------------------- 1 | import { pureLit } from "./pure-lit"; 2 | import { html, css } from "lit"; 3 | import { LitElementWithProps } from "./types"; 4 | 5 | describe("pure-lit with prop by default specs", () => { 6 | type Props = { who: string, whoElse: string } 7 | let component: LitElementWithProps 8 | beforeEach(() => { 9 | component = pureLit("my-component", 10 | (el : LitElementWithProps) => html`

Hello ${el.who}${el.whoElse}!

`, 11 | { 12 | styles: [css`:host {}`], 13 | defaults: { 14 | who: "", 15 | whoElse: "" 16 | } 17 | }); 18 | document.body.appendChild(component) 19 | }) 20 | 21 | afterEach(() => { 22 | document.body.removeChild(component) 23 | }) 24 | 25 | 26 | it("renders the empty defaults", async () => { 27 | await component.updateComplete 28 | expect(component.shadowRoot?.textContent).toContain("Hello !") 29 | }); 30 | it("renders updated props correctlty", async () => { 31 | component.setAttribute("who", "John") 32 | await component.updateComplete 33 | expect(component.shadowRoot?.textContent).toContain("Hello John!"); 34 | }); 35 | it("renders updated dashed props correctlty", async () => { 36 | component.setAttribute("who", "John") 37 | component.setAttribute("who-else", "Wayne") 38 | await component.updateComplete 39 | expect(component.shadowRoot?.textContent).toContain("Hello JohnWayne!"); 40 | }); 41 | }) -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | # pure-lit 2 | 3 | [![Version](https://img.shields.io/npm/v/pure-lit?style=for-the-badge)](https://www.npmjs.com/package/pure-lit) 4 | [![Size](https://img.shields.io/bundlephobia/minzip/pure-lit?style=for-the-badge)](https://bundlephobia.com/result?p=pure-lit) 5 | [![vulnerabilities](https://img.shields.io/snyk/vulnerabilities/npm/pure-lit?style=for-the-badge)](https://snyk.io/test/github/MatthiasKainer/pure-lit?targetFile=package.json) 6 | [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen?style=for-the-badge)](https://bundlephobia.com/result?p=pure-lit)
7 | ![Statements](badges/badge-statements.svg) 8 | ![Branch](badges/badge-branches.svg) 9 | ![Functions](badges/badge-functions.svg) 10 | ![Lines](badges/badge-lines.svg) 11 | 12 | > [lit](https://lit.dev/) elements as a pure function. 13 | 14 | 15 | - Simple and lightweight 16 | - No classes 17 | - With state, reducer and workflows 18 | 19 | 27 | 28 |
29 | 30 | ```html 31 | 37 | 38 | ``` 39 | 40 |
41 | 42 | [GitHub](https://github.com/MatthiasKainer/pure-lit) 43 | [Get Started](getting-started.md) -------------------------------------------------------------------------------- /docs/error-handling.md: -------------------------------------------------------------------------------- 1 | # Error boundaries 2 | 3 | Each pure-lit component will handle errors and not propagate them. Async functions will also show the error message, and allow you to create custom error templates by specifing slots. 4 | 5 | Take for example a simple list that looks like this: 6 | 7 | ```js 8 | pureLit( 9 | "posts-list", 10 | async (element) => { 11 | const data = await fetch(element.src).then((response) => response.json()); 12 | 13 | return html`
    14 | ${data.map((entry) => html`
  • ${entry.title}
  • `)} 15 |
`; 16 | }, 17 | { 18 | defaults: { src: "" }, 19 | } 20 | ); 21 | ``` 22 | 23 | Imagine the follow three usages in a page: 24 | 25 | ```html 26 |
27 |

Success

28 | Please wait while loading... 29 |
30 |
31 |

Failure without template

32 | Please wait while loading... 33 |
34 |
35 |

Failure with slot template

36 | 37 | Please wait while loading... 38 |
39 |

Error

40 |

Something went wrong. Please try again later.

41 |
42 |
43 |
44 | ``` 45 | 46 | This will result in the following representation: 47 | 48 | ![Different representations on the screen](img/Different-Errors.png) 49 | 50 | The successful one will show the list. The failing one without a `slot="error"` will show the error message, whereas the last one with the slot will display the slot. -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v[0-9]+.[0-9]+.[0-9]+" 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: 21 19 | - run: npm ci 20 | - run: npm test 21 | 22 | create-release: 23 | name: Create release 24 | needs: test 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | - name: Get the release version from the tag 30 | shell: bash 31 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 32 | - name: Setup Node.js environment 33 | uses: actions/setup-node@v2 34 | - run: npm ci 35 | - run: npm run build 36 | - name: Release 37 | uses: softprops/action-gh-release@v1 38 | 39 | publish-npm: 40 | needs: test 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v3 44 | - uses: actions/setup-node@v2 45 | with: 46 | node-version: 21 47 | registry-url: https://registry.npmjs.org/ 48 | - run: npm ci 49 | - run: npm run build 50 | - run: npm publish 51 | env: 52 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 53 | -------------------------------------------------------------------------------- /src/pure-lit.suspense-unhappy.test.ts: -------------------------------------------------------------------------------- 1 | import { pureLit } from "./pure-lit"; 2 | import { html } from "lit"; 3 | import { LitElementWithProps } from "./types"; 4 | 5 | describe("pure-lit suspense unhappy case", () => { 6 | type Props = { who: string } 7 | let component: LitElementWithProps 8 | let cb: (e?: Error | undefined) => void; 9 | let setWaiter = (callback: (e?: Error | undefined) => void) => cb = callback 10 | 11 | let fail = (message: string) => { 12 | cb(new Error(message)) 13 | } 14 | 15 | beforeEach(async () => { 16 | console.error = jest.fn() 17 | component = pureLit("my-component", 18 | async (el: LitElementWithProps) => 19 | await new Promise((resolve, reject) => 20 | setWaiter((e) => 21 | e 22 | ? reject(e) 23 | : resolve(html`

Hello ${el.who}!

`) 24 | ) 25 | ), 26 | { 27 | defaults: { who: "noone" }, 28 | suspense: html`wait while loading...` 29 | }); 30 | document.body.appendChild(component) 31 | await component.updateComplete 32 | }) 33 | 34 | afterEach(() => { 35 | document.body.removeChild(component) 36 | jest.resetAllMocks() 37 | }) 38 | 39 | it("renders the error correctly", async () => { 40 | component.setAttribute("who", "John") 41 | await component.updateComplete 42 | fail("omg omg we all gonna die") 43 | // suspense is slighty more async then your regular component 44 | await component.updateComplete 45 | await new Promise(process.nextTick) 46 | expect(component.shadowRoot?.textContent).toContain("omg omg we all gonna die") 47 | }); 48 | 49 | }); -------------------------------------------------------------------------------- /src/pure-lit.boolean-attrs.test.ts: -------------------------------------------------------------------------------- 1 | import { pureLit, LitElementWithProps } from "."; 2 | import { html, css } from "lit"; 3 | import {screen} from 'testing-library__dom'; 4 | 5 | type Props = { val: boolean, string: string }; 6 | 7 | const testComponent = pureLit( 8 | "my-component", 9 | (el: LitElementWithProps) => html`

Val is ${el.val} ${el.string}!

`, 10 | { 11 | styles: [ 12 | css` 13 | :host { 14 | } 15 | `, 16 | ], 17 | defaults: { val: false, string: "bla" } 18 | } 19 | ); 20 | 21 | describe("pure-lit boolean attributes default", () => { 22 | beforeEach(async () => { 23 | document.body.appendChild(testComponent); 24 | }); 25 | 26 | afterEach(() => { 27 | document.body.removeChild(testComponent); 28 | }); 29 | 30 | it("should have the correct default value", () => { 31 | expect(screen.getByText(/Val is false/gi)).toBeDefined() 32 | }) 33 | 34 | it("should update the attribute if it changes", async () => { 35 | testComponent.val = true 36 | await testComponent.updateComplete 37 | expect(screen.getByText(/Val is true/gi)).toBeDefined() 38 | }) 39 | }); 40 | 41 | describe("pure-lit boolean attributes set explicitly", () => { 42 | beforeEach(async () => { 43 | document.body.insertAdjacentHTML( 'beforeend', "" ) 44 | }); 45 | 46 | afterEach(() => { 47 | document.body.removeChild(document.body.querySelector("my-component")!); 48 | }); 49 | 50 | it("should have the correct default value", () => { 51 | expect(screen.getByText(/Val is true/gi)).toBeDefined() 52 | }) 53 | it("should have the string in there", () => { 54 | expect(screen.getByText(/whatever/gi)).toBeDefined() 55 | }) 56 | }); 57 | -------------------------------------------------------------------------------- /src/pure-lit.suspense.part2.test.ts: -------------------------------------------------------------------------------- 1 | import { pureLit } from "./pure-lit"; 2 | import { html } from "lit"; 3 | import { LitElementWithProps } from "./types"; 4 | 5 | // jest doesn't run tests in the same file in isolation, 6 | // which has led to sideeffects and bugs. Splitting this up 7 | 8 | describe("pure-lit suspense happy case", () => { 9 | type Props = { who: string } 10 | let component: LitElementWithProps 11 | let cb: (e?: Error | undefined) => void; 12 | let setWaiter = (callback: (e?: Error | undefined) => void) => cb = callback 13 | let releaseSuspense = async () => { 14 | await component.updateComplete 15 | if (cb) cb(); 16 | (cb as any) = null 17 | } 18 | 19 | beforeEach(async () => { 20 | component = pureLit("my-component", 21 | async (el: LitElementWithProps) => 22 | await new Promise((resolve, reject) => { 23 | return setWaiter((e) => 24 | e 25 | ? (console.log("Rejecting", e), reject(e)) 26 | : (resolve(html`

Hello ${el.who}!

`)) 27 | ) 28 | }), 29 | { 30 | defaults: { who: "noone" }, 31 | suspense: html`wait while loading...` 32 | }); 33 | document.body.appendChild(component) 34 | await component.updateComplete 35 | }) 36 | 37 | afterEach(() => { 38 | global.window.document.documentElement.innerHTML = ""; 39 | }) 40 | 41 | it("renders updated props correctly for suspense", async () => { 42 | await releaseSuspense() 43 | await component.suspenseComplete(); 44 | expect(component.shadowRoot?.textContent).toContain("Hello noone!") 45 | 46 | component.setAttribute("who", "John") 47 | await releaseSuspense() 48 | await component.suspenseComplete(); 49 | expect(component.shadowRoot?.textContent).toContain("Hello John!"); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { terser } from "rollup-plugin-terser"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import replace from "@rollup/plugin-replace"; 4 | import commonjs from "@rollup/plugin-commonjs"; 5 | import json from "@rollup/plugin-json"; 6 | import typescript from "rollup-plugin-typescript2"; 7 | import filesize from "rollup-plugin-filesize"; 8 | import minifyHTML from "rollup-plugin-minify-html-literals"; 9 | 10 | import pkg from "./package.json"; 11 | 12 | const input = `./src/index.ts`; 13 | const plugins = [ 14 | typescript(), 15 | replace({ "Reflect.decorate": "undefined" }), 16 | minifyHTML(), 17 | commonjs(), 18 | resolve(), 19 | json(), 20 | terser({ 21 | module: true, 22 | warnings: true, 23 | mangle: { 24 | properties: { 25 | regex: /^__/, 26 | }, 27 | }, 28 | }), 29 | filesize({ 30 | showBrotliSize: true, 31 | }), 32 | ]; 33 | const external = ["lit"] 34 | 35 | const get = (input, pkg) => { 36 | const mainBundle = { 37 | input, 38 | external, 39 | output: { 40 | file: pkg.module, 41 | format: "esm", 42 | sourcemap: true, 43 | }, 44 | plugins, 45 | }; 46 | 47 | const cjsBundle = { 48 | input, 49 | external, 50 | output: { 51 | file: pkg.main, 52 | format: "cjs", 53 | sourcemap: true, 54 | }, 55 | plugins, 56 | }; 57 | 58 | const systemBundle = { 59 | input, 60 | external, 61 | output: { 62 | file: pkg.system, 63 | format: "system", 64 | sourcemap: true, 65 | }, 66 | plugins, 67 | }; 68 | return [mainBundle, cjsBundle, systemBundle] 69 | } 70 | 71 | export default [ 72 | ...get(input, pkg) 73 | ]; 74 | -------------------------------------------------------------------------------- /src/pure-lit.suspense.part3.test.ts: -------------------------------------------------------------------------------- 1 | import { pureLit } from "./pure-lit"; 2 | import { html } from "lit"; 3 | import { LitElementWithProps } from "./types"; 4 | 5 | // jest doesn't run tests in the same file in isolation, 6 | // which has led to sideeffects and bugs. Splitting this up 7 | 8 | describe("pure-lit suspense happy case", () => { 9 | type Props = { who: string } 10 | let component: LitElementWithProps 11 | let cb: (e?: Error | undefined) => void; 12 | let setWaiter = (callback: (e?: Error | undefined) => void) => cb = callback 13 | let releaseSuspense = async () => { 14 | await component.updateComplete 15 | if (cb) cb(); 16 | (cb as any) = null 17 | } 18 | 19 | beforeEach(async () => { 20 | component = pureLit("my-component", 21 | async (el: LitElementWithProps) => 22 | await new Promise((resolve, reject) => { 23 | return setWaiter((e) => 24 | e 25 | ? (console.log("Rejecting", e), reject(e)) 26 | : (resolve(html`

Hello ${el.who}!

`)) 27 | ) 28 | }), 29 | { 30 | defaults: { who: "noone" }, 31 | suspense: html`wait while loading...` 32 | }); 33 | document.body.appendChild(component) 34 | await component.updateComplete 35 | }) 36 | 37 | afterEach(() => { 38 | global.window.document.documentElement.innerHTML = ""; 39 | }) 40 | 41 | 42 | it("reinitializes correctly", async () => { 43 | await releaseSuspense() 44 | await component.suspenseComplete(); 45 | expect(component.shadowRoot?.textContent).toContain("Hello noone!") 46 | 47 | component.setAttribute("who", "John") 48 | await Promise.all([ 49 | component.suspenseComplete(), 50 | releaseSuspense(), 51 | ]); 52 | expect(component.shadowRoot?.textContent).toContain("Hello John!"); 53 | 54 | component.reinitialize() 55 | await Promise.all([ 56 | component.suspenseComplete(), 57 | releaseSuspense(), 58 | ]); 59 | expect(component.shadowRoot?.textContent).toContain("Hello noone!") 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/pure-lit.suspense.part1.test.ts: -------------------------------------------------------------------------------- 1 | import { registered, pureLit } from "./pure-lit"; 2 | import { html } from "lit"; 3 | import { LitElementWithProps } from "./types"; 4 | 5 | describe("pure-lit suspense happy case", () => { 6 | type Props = { who: string } 7 | let component: LitElementWithProps 8 | let cb: (e?: Error | undefined) => void; 9 | let setWaiter = (callback: (e?: Error | undefined) => void) => cb = callback 10 | let releaseSuspense = async () => { 11 | await component.updateComplete 12 | if (cb) cb(); 13 | (cb as any) = null 14 | } 15 | 16 | beforeEach(async () => { 17 | component = pureLit("my-component", 18 | async (el: LitElementWithProps) => 19 | await new Promise((resolve, reject) => { 20 | return setWaiter((e) => 21 | e 22 | ? (reject(e)) 23 | : (resolve(html`

Hello ${el.who}!

`)) 24 | ) 25 | }), 26 | { 27 | defaults: { who: "noone" }, 28 | suspense: html`wait while loading...` 29 | }); 30 | document.body.appendChild(component) 31 | await component.updateComplete 32 | }) 33 | 34 | afterEach(() => { 35 | global.window.document.documentElement.innerHTML = ""; 36 | }) 37 | 38 | it("creates a new component", () => { 39 | expect(component.outerHTML).toEqual(""); 40 | expect(Object.keys(registered).length).toBe(1); 41 | }); 42 | 43 | it("renders the default correctly", async () => { 44 | expect(component.shadowRoot?.textContent).toContain("wait while loading...") 45 | // while this has not resolved, it should continue to show loading 46 | component.requestUpdate() 47 | await new Promise(process.nextTick) 48 | expect(component.shadowRoot?.textContent).toContain("wait while loading...") 49 | }); 50 | 51 | it("renders the result correctly once the suspense has been released", async () => { 52 | await releaseSuspense() 53 | await component.suspenseComplete(); 54 | expect(component.shadowRoot?.textContent).toContain("Hello noone!") 55 | }); 56 | 57 | }); 58 | -------------------------------------------------------------------------------- /src/properties.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PurePropertyDeclaration, 3 | DefaultObjectDefinition, 4 | PureArguments, 5 | DefaultDefinedPureArguments, 6 | PropDefinedPureArguments, 7 | } from "./types"; 8 | import { PropertyDeclaration, PropertyDeclarations } from "lit"; 9 | 10 | function toSafeDeclaration(declaration: PurePropertyDeclaration, [key, value]: [string, any]) { 11 | if (key.toLowerCase() !== key) { 12 | declaration[key] = {...value, attribute: key.replace(/[A-Z]/g, '-$&').toLowerCase()}; 13 | } else { 14 | declaration[key] = value; 15 | } 16 | 17 | return declaration; 18 | } 19 | 20 | export function getType(value: unknown) : PropertyDeclaration { 21 | if (typeof value === "boolean") return {type: Boolean} 22 | if (Array.isArray(value)) return {type: Array} 23 | if (typeof value === "object") return {type: Object} 24 | return {} 25 | } 26 | 27 | export const toTypeDeclaration = (object: {[key: string]: unknown}) => 28 | Object.entries(object).reduce((result, [key, value]) => { 29 | return toSafeDeclaration(result, [key, getType(value)]) 30 | }, {} as PurePropertyDeclaration) as PurePropertyDeclaration 31 | 32 | export const toPropertyDeclaration = (defaults?: DefaultObjectDefinition) => 33 | toTypeDeclaration(defaults || {}); 34 | 35 | export const toPropertyDeclarationMap = (props?: (PurePropertyDeclaration | string)[]) => 36 | (props || []).reduce((declaration: PurePropertyDeclaration, prop) => { 37 | Object.entries(prop).forEach((entry) => declaration = toSafeDeclaration(declaration, entry)); 38 | return declaration; 39 | }, {} as PropertyDeclarations); 40 | 41 | export const isDefault = (args?: PureArguments): args is DefaultDefinedPureArguments => 42 | args !== undefined && (args as DefaultDefinedPureArguments).defaults !== undefined; 43 | export const hasProps = (args?: PureArguments): args is PropDefinedPureArguments => 44 | args !== undefined && (args as PropDefinedPureArguments).props !== undefined; 45 | 46 | export const toProperties = (args?: PureArguments) => 47 | hasProps(args) ? toPropertyDeclarationMap(args.props) : toPropertyDeclaration(args?.defaults); 48 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Jest 4 | 5 | It's pretty simple to test it with jest. In your jest config you will need the following two lines 6 | 7 | ```json 8 | "preset": "ts-jest/presets/js-with-babel", 9 | "transformIgnorePatterns": [ 10 | "node_modules/(?!(lit|lit-element|lit-html|pure-lit)/)" 11 | ], 12 | ``` 13 | 14 | which you will need for lit-element and pure-lit to be transpiled as well. 15 | 16 | Then you need (if you haven't already) a `babel.conf.js` with at least the following content 17 | 18 | ```js 19 | module.exports = { 20 | presets: [ 21 | [ 22 | "@babel/preset-env", 23 | { 24 | targets: { 25 | node: "current", 26 | }, 27 | }, 28 | ], 29 | ], 30 | }; 31 | ``` 32 | 33 | The `pureLit` function returns the element, which you can then put on the page for tests. 34 | 35 | The test itself can be seen [in this project](src/pure-lit.tests.ts), but the gist looks like this: 36 | 37 | ```typescript 38 | // MyComponent.ts 39 | export default pureLit("my-component", 40 | (el) => html`

Hello ${el.who}!

`, 41 | { defaults: { who: "noone" }}); 42 | 43 | // myComponent.test.ts 44 | import MyComponent from "./MyComponent.ts" 45 | 46 | describe("pure-lit", () => { 47 | type Props = { who: string } 48 | beforeEach(async () => { 49 | document.body.appendChild(MyComponent) 50 | await MyComponent.updateComplete 51 | }) 52 | 53 | afterEach(() => { 54 | document.body.removeChild(MyComponent) 55 | }) 56 | 57 | it("renders the default correctly", async () => { 58 | expect(MyComponent.shadowRoot?.innerHTML).toContain("Hello noone!") 59 | }); 60 | 61 | it("renders updated props correctlty", async () => { 62 | MyComponent.setAttribute("who", "John") 63 | await MyComponent.updateComplete 64 | expect(MyComponent.shadowRoot?.innerHTML).toContain("

Hello John!

"); 65 | }); 66 | ``` 67 | 68 | Note that the tests in jest are not entirely side-effect free: In between the tests jsdom will not clean up the registry for the custom component. So if you create a component in the test, it's created only the first time. 69 | 70 | This means you cannot change the behavior of the component later (ie change the render method, or the defaults). To run it with a different scenario create a new test file. -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # pure-lit 2 | 3 | [![Version](https://img.shields.io/npm/v/pure-lit?style=for-the-badge)](https://www.npmjs.com/package/pure-lit) 4 | [![Size](https://img.shields.io/bundlephobia/minzip/pure-lit?style=for-the-badge)](https://bundlephobia.com/result?p=pure-lit) 5 | [![vulnerabilities](https://img.shields.io/snyk/vulnerabilities/npm/pure-lit?style=for-the-badge)](https://snyk.io/test/github/MatthiasKainer/pure-lit?targetFile=package.json) 6 | [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen?style=for-the-badge)](https://bundlephobia.com/result?p=pure-lit) 7 | ![Statements](docs/badges/badge-statements.svg) 8 | ![Branch](docs/badges/badge-branches.svg) 9 | ![Functions](docs/badges/badge-functions.svg) 10 | ![Lines](docs/badges/badge-lines.svg) 11 | 12 | > [lit](https://lit.dev/) with pure functions. 13 | 14 | 15 | ## Install 16 | 17 | `npm install pure-lit` 18 | 19 | or add it to your page as module like this: 20 | 21 | `` 22 | 23 | ## Getting started 24 | 25 | [![pure-lit.org](docs/img/documentation-button.png)](https://pure-lit.org) 26 | 27 | The quickest way of getting started is by using JavaScript modules. 28 | 29 | Create a file `index.html` that looks like this: 30 | 31 | ```html 32 | 33 | 34 | 35 | 36 | Awesome pure-lit 37 | 51 | 52 | 53 | 54 | 55 | 56 | ``` 57 | 58 | Open it in the browser. Done. 59 | 60 | ## Adding some state 61 | 62 | pureLit exports the hooks from [lit-element-state-decoupler](https://github.com/MatthiasKainer/lit-element-state-decoupler) and [lit-element-effect](https://github.com/MatthiasKainer/lit-element-effect) which you can use to manage your state inside the functional components. 63 | 64 | You can import them via 65 | 66 | ```typescript 67 | import { pureLit, useState, useReducer, useWorkflow, useEffect, useOnce } from "pure-lit"; 68 | ``` 69 | 70 | and then use them like this: 71 | 72 | ```typescript 73 | pureLit("hello-world", (element) => { 74 | const counter = useState(element, 0); 75 | return html`` 76 | }); 77 | ``` -------------------------------------------------------------------------------- /src/pure-lit.test.ts: -------------------------------------------------------------------------------- 1 | import { registered, pureLit } from "./pure-lit"; 2 | import { html } from "lit"; 3 | import { LitElementWithProps } from "./types"; 4 | 5 | describe("pure-lit", () => { 6 | type Props = { who: string } 7 | let component: LitElementWithProps 8 | let events: { [key: string]: any[] } = {} 9 | 10 | function isCustomEvent(event: Event | CustomEvent): event is CustomEvent { 11 | return (event as CustomEvent).detail 12 | } 13 | 14 | let eventTracker = (name: string) => (event: Event) => 15 | events[name] = [...(events[name] ?? []), isCustomEvent(event) ? event.detail : event] 16 | 17 | const eventListeners = { 18 | connected: eventTracker("connected"), 19 | attributeChanged: eventTracker("attributeChanged"), 20 | firstUpdated: eventTracker("firstUpdated"), 21 | updated: eventTracker("updated") 22 | } 23 | 24 | beforeEach(async () => { 25 | component = pureLit("my-component", 26 | (el: LitElementWithProps) => html`

Hello ${el.who}!

`, 27 | { defaults: { who: "noone" } }); 28 | events = {} 29 | Object.entries(eventListeners).forEach(([name, trigger]) => component.addEventListener(name, trigger)) 30 | document.body.appendChild(component) 31 | await component.updateComplete 32 | }) 33 | 34 | afterEach(() => { 35 | Object.entries(eventListeners).forEach(([name, trigger]) => component.removeEventListener(name, trigger)) 36 | document.body.removeChild(component) 37 | events = {} 38 | }) 39 | 40 | it("creates a new component", () => { 41 | expect(component.outerHTML).toEqual(""); 42 | expect(Object.keys(registered).length).toBe(1); 43 | }); 44 | 45 | it("calls all creation events", () => { 46 | expect(events["connected"].length).toBe(1) 47 | // never updated 48 | expect(events["updated"]).toBe(undefined) 49 | }) 50 | 51 | it("renders the default correctly", async () => { 52 | expect(component.shadowRoot?.textContent).toContain("Hello noone!") 53 | expect(events["updated"]).toBe(undefined) 54 | }); 55 | it("renders updated props correctlty", async () => { 56 | component.setAttribute("who", "John") 57 | await component.updateComplete 58 | expect(events["attributeChanged"].length).toBe(1) 59 | expect(events["attributeChanged"][0]).toEqual({ "name": "who", "old": null, "value": "John" }) 60 | expect(events["updated"].length).toBe(1) 61 | expect(component.shadowRoot?.textContent).toContain("Hello John!"); 62 | }); 63 | 64 | it("does not duplicate the component if it already exists", () => { 65 | pureLit("my-component", () => html``); 66 | pureLit("my-component", () => html``); 67 | expect(Object.keys(registered).length).toBe(1); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pure-lit", 3 | "version": "3.0.5", 4 | "description": "lit elements as functions", 5 | "main": "./lib/index.js", 6 | "module": "./lib/index.module.js", 7 | "system": "./lib/index.system.js", 8 | "type": "module", 9 | "keywords": [ 10 | "lit-element", 11 | "lit-elements", 12 | "functional", 13 | "component" 14 | ], 15 | "scripts": { 16 | "build": "rollup -c", 17 | "build:watch": "rollup -c --watch", 18 | "test": "jest --coverage", 19 | "test:badges": "npm test && npx jest-coverage-badges --output ./docs/badges", 20 | "documentation": "npx docsify-cli serve ./docs", 21 | "release:beta": "npm test && npm run build && npm publish --tag=beta", 22 | "release:patch": "npm run test:badges && npm run build && npm version --no-commit-hooks patch -m 'Creating new release %s' && git push && git push --tags", 23 | "release:minor": "npm run test:badges && npm run build && npm version --no-commit-hooks minor -m 'Creating new release %s' && git push && git push --tags", 24 | "release:major": "npm run test:badges && npm run build && npm version --no-commit-hooks major -m 'Creating new release %s' && git push && git push --tags" 25 | }, 26 | "devDependencies": { 27 | "@babel/preset-env": "^7.15.6", 28 | "@rollup/plugin-commonjs": "^20.0.0", 29 | "@rollup/plugin-json": "^4.1.0", 30 | "@rollup/plugin-node-resolve": "^15.2.3", 31 | "@rollup/plugin-replace": "^3.0.0", 32 | "@testing-library/dom": "^8.6.0", 33 | "@testing-library/user-event": "^13.2.1", 34 | "@types/jest": "^29.5.10", 35 | "@typescript-eslint/eslint-plugin": "^4.31.2", 36 | "@typescript-eslint/parser": "^4.31.2", 37 | "jest": "^29.7.0", 38 | "jest-environment-jsdom": "^29.7.0", 39 | "lit": "^3.0.0", 40 | "rollup": "^3.29.5", 41 | "rollup-plugin-filesize": "^10.0.0", 42 | "rollup-plugin-minify-html-literals": "^1.2.6", 43 | "rollup-plugin-terser": "^7.0.2", 44 | "rollup-plugin-typescript2": "^0.36.0", 45 | "rollup-plugin-url-resolve": "^0.2.0", 46 | "testing-library__dom": "^7.20.1-beta.1", 47 | "ts-jest": "^29.0.0", 48 | "typescript": "^4.4.3" 49 | }, 50 | "peerDependencies": { 51 | "lit": "^2.0.0 || ^3.0.0" 52 | }, 53 | "dependencies": { 54 | "lit-element-effect": "^1.0.2", 55 | "lit-element-state-decoupler": "^2.0.3" 56 | }, 57 | "prettier": { 58 | "trailingComma": "es5", 59 | "tabWidth": 2, 60 | "printWidth": 110 61 | }, 62 | "jest": { 63 | "testEnvironment": "jsdom", 64 | "preset": "ts-jest/presets/js-with-babel", 65 | "testPathIgnorePatterns": [ 66 | "build", 67 | "docs" 68 | ], 69 | "maxWorkers": "1", 70 | "transformIgnorePatterns": [ 71 | "node_modules/(?!(lit|lit-element|@lit|lit-html|testing-library__dom)/)" 72 | ], 73 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx)$", 74 | "coverageReporters": [ 75 | "json-summary", 76 | "text" 77 | ], 78 | "coverageThreshold": { 79 | "global": { 80 | "branches": 100, 81 | "functions": 100, 82 | "lines": 100, 83 | "statements": 100 84 | } 85 | }, 86 | "coveragePathIgnorePatterns": [ 87 | "index.ts" 88 | ] 89 | }, 90 | "author": "Matthias Kainer", 91 | "license": "ISC" 92 | } 93 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## What do I need to know to help? 2 | 3 | If you are looking to help to with a code contribution our project uses [typescript](https://www.typescriptlang.org/), [jest](https://jestjs.io/) for testing, [rollup.js](https://rollupjs.org/) for building and it is an extension of [lit](https://lit.dev/). 4 | 5 | If you are interested in making a code contribution and would like to learn more about the technologies that we use, check out the links. 6 | 7 | ## How do I make a contribution? 8 | 9 | Never made an open source contribution before? Wondering how contributions work in the in our project? Here's a quick rundown! 10 | 11 | 1. Find an issue that you are interested in addressing or a feature that you would like to add. 12 | 2. Install [node](https://nodejs.org/en/) and [git](https://git-scm.com/) on your computer. 13 | 3. Fork the repository associated with the issue to your local GitHub organization. This means that you will have a copy of the repository under `your-GitHub-username/pure-lit`. 14 | 4. Clone the repository to your local machine using git clone https://github.com/your-GitHub-username/pure-lit.git. 15 | 5. Create a new branch for your fix using `git checkout -b branch-name-here`. 16 | 6. Install all dependencies the library needs by running `npm install` and make sure it works by running `npm test`. All tests should pass. 17 | 7. Make the appropriate changes for the issue you are trying to address or the feature that you want to add. 18 | 8. Run `npm test` to ensure your changes work and that you have tests for your added code. 19 | 9. Use `git add insert-paths-of-changed-files-here` to add the file contents of the changed files to the "snapshot" git uses to manage the state of the project, also known as the index. 20 | 10. Use `git commit -m "#number-of-the-issue-you-created-in-step-1 Insert a short message of the changes made here"` to store the contents of the index with a descriptive message. 21 | 10. Push the changes to the remote repository using `git push origin branch-name-here`. 22 | 11. Submit a pull request to the upstream repository by opening the repo in your browser. 23 | 12. Title the pull request with a short description of the changes made and the issue or bug number associated with your change. For example, you can title an issue like so "Added more log outputting to resolve #4352". 24 | 13. In the description of the pull request, explain the changes that you made, any issues you think exist with the pull request you made, and any questions you have for the maintainer. It's OK if your pull request is not perfect (no pull request is), the reviewer will be able to help you fix any problems and improve it! 25 | 14. Wait for the pull request to be reviewed by a maintainer. 26 | 15. Make changes to the pull request if the reviewing maintainer recommends them. 27 | 16. Celebrate your success after your pull request is merged! 28 | 29 | ## Where can I go for help? 30 | 31 | If you need help on your pull request, create your current state as `Draft` and request help from the maintainers. 32 | 33 | ## What does the Code of Conduct mean for me? 34 | 35 | Our Code of Conduct means that you are responsible for treating everyone on the project with respect and courtesy regardless of their identity. If you are the victim of any inappropriate behavior or comments as described in our [Code of Conduct](CODE_OF_CONDUCT.md), we are here for you and will do the best to ensure that the abuser is reprimanded appropriately, per our code. 36 | -------------------------------------------------------------------------------- /src/properties.test.ts: -------------------------------------------------------------------------------- 1 | import { toProperties, toPropertyDeclaration, toPropertyDeclarationMap } from "./properties"; 2 | import { getType, toTypeDeclaration } from "./properties" 3 | 4 | describe("typer", () => { 5 | it("should map a boolean value correctly", () => { 6 | expect(getType(true)) 7 | .toEqual({ type: Boolean }) 8 | }) 9 | it("should map an object value correctly", () => { 10 | expect(getType({ value: {} })) 11 | .toEqual({ type: Object }) 12 | }) 13 | it("should map a array value correctly", () => { 14 | expect(getType([])) 15 | .toEqual({ type: Array }) 16 | }) 17 | it("should map a string value correctly", () => { 18 | expect(getType("true")) 19 | .toEqual({ }) 20 | }) 21 | 22 | it("should map a set of default properties correctly", () => { 23 | expect(toTypeDeclaration({ 24 | bool: true, 25 | obj : {}, 26 | arr: [], 27 | else: "string" 28 | })).toEqual({ 29 | bool: {type: Boolean}, 30 | obj: {type: Object}, 31 | arr: {type: Array}, 32 | else: { } 33 | }) 34 | }) 35 | }) 36 | 37 | describe("toProperties", () => { 38 | it("returns empty if nothing", () => { 39 | expect(toProperties()).toEqual({}); 40 | }) 41 | 42 | it("returns properties for default values", () => { 43 | expect(toProperties({ defaults: { some: "value", bool: true } })) 44 | .toEqual({some: {}, bool: {type: Boolean}}) 45 | }) 46 | it("returns multiple generated properties for camelCases to support react https://reactjs.org/docs/dom-elements.html#all-supported-html-attributes `You may also use custom attributes as long as they’re fully lowercase.`", () => { 47 | expect(toProperties({ props: [{ someCamelCase: { type: String }, bool: {type: Boolean} }] })) 48 | .toEqual({ 49 | someCamelCase: { type: String, attribute: "some-camel-case" }, 50 | bool: {type: Boolean} 51 | }) 52 | expect(toProperties({ defaults: { someCamelCase: "value", bool: true } })) 53 | .toEqual({ 54 | someCamelCase: {attribute: "some-camel-case"}, 55 | bool: {type: Boolean} 56 | }) 57 | }) 58 | it("returns properties for default declarations", () => { 59 | expect(toProperties({ props: [{ some: { type: String } }] })) 60 | .toEqual({some: { type: String }}) 61 | }) 62 | }) 63 | 64 | describe("toPropertyDeclaration", () => { 65 | expect(toPropertyDeclaration()).toEqual({}); 66 | expect( 67 | toPropertyDeclaration({ 68 | "something": { 69 | "inner": "data" 70 | }, 71 | blub: true 72 | }) 73 | ).toEqual({ 74 | something: { 75 | type: Object 76 | }, 77 | blub: { 78 | type: Boolean 79 | } 80 | }); 81 | }) 82 | 83 | describe("toPropertyDeclarationMap", () => { 84 | it("returns an empty property map on no data", () => { 85 | expect(toPropertyDeclarationMap()).toEqual({}); 86 | }); 87 | 88 | it("should map propertymaps correctly", () => { 89 | expect( 90 | toPropertyDeclarationMap([ 91 | { type: { type: String } }, 92 | { bla: { type: Boolean }, blub: { type: Object } }, 93 | ]) 94 | ).toEqual({ 95 | type: { type: String }, 96 | bla: { type: Boolean }, 97 | blub: { type: Object } 98 | }); 99 | }); 100 | }); -------------------------------------------------------------------------------- /src/pure-lit.async.test.ts: -------------------------------------------------------------------------------- 1 | import { registered, pureLit } from "./pure-lit"; 2 | import { html } from "lit"; 3 | import { LitElementWithProps } from "./types"; 4 | 5 | describe("pure-lit async", () => { 6 | type Props = { who: string } 7 | let component: LitElementWithProps 8 | beforeEach(async () => { 9 | component = pureLit("my-component", 10 | async (el : LitElementWithProps) => await Promise.resolve(html`

Hello ${el.who}!

`), 11 | { defaults: { who: "noone" }}); 12 | document.body.appendChild(component) 13 | await component.updateComplete 14 | }) 15 | 16 | afterEach(() => { 17 | document.body.removeChild(component) 18 | }) 19 | 20 | it("creates a new component", () => { 21 | expect(component.outerHTML).toEqual(""); 22 | expect(Object.keys(registered).length).toBe(1); 23 | }); 24 | 25 | it("renders the default correctly", () => { 26 | expect(component.shadowRoot?.textContent).toContain("Hello noone!") 27 | }); 28 | 29 | it("renders updated props correctlty", async () => { 30 | component.setAttribute("who", "John") 31 | await component.updateComplete 32 | expect(component.shadowRoot?.textContent).toContain("Hello John!"); 33 | 34 | // updated props survive rerender 35 | component.requestUpdate() 36 | await component.updateComplete 37 | expect(component.shadowRoot?.textContent).toContain("Hello John!"); 38 | 39 | // updated props don't survive reinit 40 | component.reinitialize() 41 | await component.updateComplete 42 | expect(component.shadowRoot?.textContent).toContain("Hello noone!"); 43 | }); 44 | 45 | it("doesn't fail on calls to suspense waiter, even if there is no suspense", async () => { 46 | component.setAttribute("who", "Connor") 47 | await component.suspenseComplete() 48 | expect(component.shadowRoot?.textContent).toContain("Hello Connor!"); 49 | }) 50 | 51 | it("does not duplicate the component if it already exists", () => { 52 | pureLit("my-component", () => html``); 53 | pureLit("my-component", () => html``); 54 | expect(Object.keys(registered).length).toBe(1); 55 | }); 56 | }); 57 | describe("pure-lit async failure", () => { 58 | type Props = { who: string | undefined } 59 | let component: LitElementWithProps 60 | beforeAll(() => { 61 | console.error = jest.fn() 62 | }) 63 | 64 | beforeEach(async () => { 65 | 66 | component = pureLit("my-failing-component", 67 | async (el : LitElementWithProps) => 68 | el.who 69 | ? await Promise.resolve(html`

Hello ${el.who}!

`) 70 | : await Promise.reject(html`

Noone to greet

`), 71 | { defaults: { who: undefined }}); 72 | const errorSlot = document.createElement("div") 73 | errorSlot.setAttribute("slot", "error") 74 | component.appendChild(errorSlot) 75 | document.body.appendChild(component) 76 | await component.updateComplete 77 | }) 78 | 79 | afterEach(() => { 80 | document.body.removeChild(component) 81 | }); 82 | 83 | afterAll(() => { 84 | (console.error as any).mockRestore(); 85 | }); 86 | 87 | it("creates a new component", () => { 88 | expect(component.outerHTML).toEqual("
"); 89 | }); 90 | 91 | it("logs the error to console log", () => { 92 | expect(console.error).toBeCalledWith(html`

Noone to greet

`); 93 | }); 94 | 95 | it("renders the error slot correctly", () => { 96 | expect(component.shadowRoot?.textContent).toContain("Noone to greet") 97 | }); 98 | 99 | it("renders updated props correctlty", async () => { 100 | component.setAttribute("who", "John") 101 | await component.updateComplete 102 | expect(component.shadowRoot?.textContent).toContain("Hello John!"); 103 | }); 104 | }); -------------------------------------------------------------------------------- /docs/advanced-usecases.md: -------------------------------------------------------------------------------- 1 | # Advanced usage 2 | 3 | ## Dispatching events 4 | 5 | ### Using the dispatch-function 6 | 7 | If you want to dispatch a custom event, you don't have to write the `CustomEvent` code, you can either use a reducer or just use the `dispatch` method provided like so: 8 | 9 | ```typescript 10 | pureLit("todo-add", (element: LitElement) => { 11 | const todo = useState(element, ""); 12 | 13 | const onComplete = () => { 14 | if (todo.get().length > 0) { 15 | // dipatch a custom event "added" 16 | dispatch(element, "added", todo.get()); 17 | todo.set(""); 18 | } 19 | }; 20 | const onUpdate = ({ value }: { value: string }) => todo.set(value); 21 | return html` 22 |
23 | 31 | 32 |
33 | `; 34 | }); 35 | ``` 36 | 37 | This event can then be subscribed by a parent like any other event 38 | 39 | ```typescript 40 | pureLit("todo-app", (element: LitElement) => { 41 | const { get, set } = useState(element, []); 42 | return html` 43 |
44 | ) => set([...get(), e.detail])}> 45 |
46 |
47 | ) => set([...get().filter((el) => el === e.detail)])} 50 | > 51 |
52 | `; 53 | }); 54 | ``` 55 | 56 | ### Dispatching from the reducer 57 | 58 | When using a reducer, you can also use it to directly dispatch an event that has the same name as the reducer action. Whenever `set` is called, the event will be triggered. 59 | 60 | ```typescript 61 | const todo = (state: string) => ({ 62 | update: (payload: string) => payload, 63 | added: () => state, 64 | }); 65 | 66 | pureLit("todo-add", (element: LitElement) => { 67 | const { set, get } = useReducer(element, todo, "", { 68 | // this is the line that dispatches a custom event for you 69 | dispatchEvent: true, 70 | }); 71 | const onComplete = () => get().length > 0 && (set("added"), set("update", "")); 72 | const onUpdate = ({ value }: { value: string }) => set("update", value); 73 | return html` 74 |
75 | 83 | 84 |
85 | `; 86 | }); 87 | ``` 88 | 89 | ## Usage with React 90 | 91 | As long as you use the property syntax from lit-element, everything will work seamlessly. It will be more difficult once you try to use your component in a framework like react. There are two things to consider: 92 | 93 | 1. React does not work well with `camelCase` attribute names (see also https://reactjs.org/docs/dom-elements.html#all-supported-html-attributes). 94 | 2. React does not work well with your custom events. 95 | 96 | To workaround this issue pure-lit tries to be friendly, but there is something for you to be left. 97 | 98 | Let's take a look at an example: 99 | 100 | ```js 101 | pureLit("hello-world", 102 | (el) => { 103 | dispatch(el, "knockknock", `Who's there? - ${el.whoIsTher} - Not funny`)) 104 | return html`

Hello ${el.whoIsThere}!

` 105 | }, 106 | { defaults: { whoIsThere: "noone" }}); 107 | ``` 108 | 109 | Using this via lit-html looks like this: 110 | 111 | ```js 112 | const who = "me"; 113 | return html` console.log(e.detail)}>`; 114 | ``` 115 | 116 | First, pure-lit changes `camelCase` attribute names to `dashed`, thus in jsx the component will look like this: 117 | 118 | ```jsx 119 | const who = "me"; 120 | return ; 121 | ``` 122 | 123 | Accessing the event is slightly more difficult. 124 | 125 | ```jsx 126 | function HelloWorld({ whoIsThere, knockknock }) { 127 | // create a ref for the html element 128 | const helloWorldRef = useRef() 129 | 130 | // The event listener that handles the submitted event 131 | function eventListener(e) { 132 | knockknock(e.detail) 133 | } 134 | 135 | // add the event listener to the ref in an effect 136 | useEffect(() => { 137 | const { current } = helloWorldRef 138 | current.addEventListener('knockknock', eventListener) 139 | 140 | // remove it when the element is unmounted 141 | return () => current.removeEventListener('knockknock', eventListener) 142 | }, []) 143 | 144 | return ( 145 | 149 | 150 | ) 151 | } 152 | ``` 153 | 154 | Now we can use the Wrapped Component like so: 155 | 156 | ```jsx 157 | 158 | ``` -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | code-of-conduct@pure-lit.org. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | You can read an introduction here: [Write Less Code For Smaller Packages With `pure-lit`](https://matthias-kainer.de/blog/posts/write-less-code-with-pure-lit/) 4 | 5 | ## Install 6 | 7 | ```bash 8 | npm install pure-lit 9 | ``` 10 | 11 | or add it to your page as module like this: 12 | 13 | ```html 14 | 15 | ``` 16 | 17 | ## Hello World 18 | 19 | The quickest way of getting started is by using JavaScript modules. 20 | 21 | Create a file `index.html` that looks like this: 22 | 23 | ```html 24 | 25 | 26 | 27 | 28 | Awesome pure-lit 29 | 43 | 44 | 45 | 46 | 47 | 48 | ``` 49 | 50 | Open it in the browser. Done. 51 | 52 | ## Function Interface 53 | 54 | ```typescript 55 | pureLit = ( 56 | name: string, 57 | render: (element: LitElementWithProps) => TemplateResult | Promise, 58 | args?: { 59 | styles?: CSSResult | CSSResultArray 60 | props?: [key: string]: PropertyDeclaration[] 61 | } | { 62 | styles?: CSSResult | CSSResultArray 63 | defaults?: {[key: string]: unknown} 64 | } 65 | ) 66 | ``` 67 | 68 | | name | description | 69 | | --------------- | ----------------------------------------------------------------------------------------------------------------- | 70 | | `name` | the name of the custom element | 71 | | `render` | a function that gets a `LitElement` with specified `Props`, and returns a `lit-html` TemplateResult. Can be async | 72 | | `args.styles` | `lit-html` CSSResult or CSSResultArray to add styles to the custom component | 73 | | `args.props` | Property declarations for the element. A well defined PropertyDeclaration | 74 | | `args.defaults` | Set defaults for the properties. If set and no props are set, the PropertyDeclaration will be created for you | 75 | | `args.suspense` | A placeholder that will be shown while an async result is loading | 76 | 77 | 78 | ## Adding some state 79 | 80 | pureLit exports the hooks from [lit-element-state-decoupler](https://github.com/MatthiasKainer/lit-element-state-decoupler) and [lit-element-effect](https://github.com/MatthiasKainer/lit-element-effect) which you can use to manage your state inside the functional components. 81 | 82 | You can import them via 83 | 84 | ```typescript 85 | import { pureLit, useState, useReducer, useWorkflow, useEffect, useOnce } from "pure-lit"; 86 | ``` 87 | 88 | and then use them like this: 89 | 90 | ```typescript 91 | pureLit("hello-world", (element) => { 92 | const counter = useState(element, 0); 93 | return html`` 94 | }); 95 | ``` 96 | 97 | ## Example using a lot of things 98 | 99 | ```typescript 100 | type Props = { id: number }; 101 | 102 | const baseUrl = "https://jsonplaceholder.typicode.com/users" 103 | 104 | pureLit("hello-incrementor", 105 | // this is an async function now, allowing us to await 106 | async (element: LitElementWithProps) => { 107 | // clicks is maintaining the state how often the item has been clicked 108 | const clicks = useState(element, element.id) 109 | // the number of clicks is used as id of the user to fetch 110 | const { name } = await fetch(`${baseUrl}/${clicks.value}`) 111 | .then((response) => response.json()); 112 | return html` 113 |

114 | Hello { 115 | clicks.set(clicks.value + 1) 116 | }}> ${name} ! 117 |

118 |

119 | You were clicked ${clicks.value} times 120 |

`; 121 | }, 122 | { 123 | styles: [ 124 | css` 125 | :host { 126 | display: block; 127 | } 128 | `, 129 | css` 130 | em { 131 | color: red; 132 | } 133 | `, 134 | ], 135 | defaults: { 136 | id: 1, 137 | }, 138 | } 139 | ); 140 | ``` 141 | 142 | ## Example for a slow backend url 143 | 144 | ```typescript 145 | type Props = { id: number }; 146 | 147 | const baseUrl = "https://jsonplaceholder.typicode.com/users" 148 | 149 | pureLit("hello-incrementor", 150 | // this is an async function, allowing us to await 151 | async () => { 152 | // The api is fast, so we just wait for a second for fun 153 | await new Promise(resolve => setTimeout(resolve, 1000)) 154 | const { name } = await fetch("https://jsonplaceholder.typicode.com/users/1") 155 | .then((response) => response.json()); 156 | return html` 157 |

158 | Hello ${name} ! 159 |

`; 160 | }, 161 | { 162 | // this will be shown while we wait for the promise to resolve 163 | suspense: html`please wait while loading...`, 164 | } 165 | ); 166 | ``` 167 | 168 | ## More examples 169 | 170 | For more examples see [MatthiasKainer/pure-lit-demos](https://github.com/MatthiasKainer/pure-lit-demos) -------------------------------------------------------------------------------- /src/pure-lit.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, TemplateResult } from "lit"; 2 | import { 3 | RegisteredElements, 4 | RenderFunction, 5 | LitElementWithProps, 6 | PureArguments, 7 | AsyncRenderFunction, 8 | } from "./types"; 9 | import { toProperties, isDefault } from "./properties"; 10 | import { dispatch } from "./dispatch"; 11 | 12 | export const registered: RegisteredElements = {}; 13 | 14 | enum SuspenseStatus { 15 | INITAL, 16 | WAITING, 17 | COMPLETE, 18 | ERROR 19 | } 20 | 21 | class SuspenseRender { 22 | status: SuspenseStatus = SuspenseStatus.INITAL 23 | suspense: TemplateResult; 24 | result: TemplateResult; 25 | renderLater: AsyncRenderFunction | RenderFunction; 26 | onChanged: () => void 27 | #waitForComplete: (() => void)[] = [] 28 | #onComplete: () => void = () => { this.#waitForComplete.forEach(completion => completion()) } 29 | 30 | constructor( 31 | suspense: TemplateResult, 32 | render: AsyncRenderFunction | RenderFunction, 33 | onChange: () => void 34 | ) { 35 | this.suspense = suspense; 36 | this.result = suspense; 37 | this.renderLater = render; 38 | this.onChanged = onChange; 39 | } 40 | 41 | reset() { 42 | // clean up all waits 43 | this.#onComplete() 44 | this.#waitForComplete = [] 45 | this.status = SuspenseStatus.INITAL; 46 | } 47 | 48 | complete(): Promise { 49 | const queue = this.#waitForComplete 50 | return new Promise(r => { 51 | if (this.status === SuspenseStatus.COMPLETE) { 52 | r(true); 53 | } else { 54 | let queued = () => { 55 | r(true) 56 | // remove yourself 57 | queue.splice(queue.indexOf(queued), 1) 58 | } 59 | queue.push(queued) 60 | } 61 | }) 62 | } 63 | 64 | render(element: LitElementWithProps) { 65 | switch (this.status) { 66 | case SuspenseStatus.INITAL: 67 | this.result = this.suspense; 68 | this.status = SuspenseStatus.WAITING; 69 | 70 | Promise.resolve(this.renderLater(element)) 71 | .then(result => ( 72 | this.result = result, 73 | this.status = SuspenseStatus.COMPLETE, 74 | this.#onComplete(), 75 | this.onChanged() 76 | )) 77 | .catch(e => ( 78 | console.error(e), 79 | this.result = html`${e}`, 80 | this.status = SuspenseStatus.COMPLETE, 81 | this.#onComplete(), 82 | this.onChanged() 83 | )); 84 | 85 | return this.result; 86 | case SuspenseStatus.WAITING: 87 | return this.result; 88 | default: 89 | return this.result; 90 | } 91 | } 92 | } 93 | 94 | export const pureLit = ( 95 | name: string, 96 | render: AsyncRenderFunction | RenderFunction, 97 | args?: PureArguments 98 | ): LitElementWithProps => { 99 | if (registered[name]) return registered[name]; 100 | 101 | class RuntimeRepresentation extends LitElement { 102 | content: TemplateResult = html``; 103 | suspense?: SuspenseRender; 104 | static get properties() { 105 | return toProperties(args); 106 | } 107 | static get styles() { 108 | /* istanbul ignore next */ 109 | return args?.styles; 110 | } 111 | constructor() { 112 | super(); 113 | if (args?.suspense) 114 | this.suspense = new SuspenseRender( 115 | args.suspense, 116 | render, 117 | () => this.requestUpdate() 118 | ); 119 | if (isDefault(args)) { 120 | Object.entries(args!.defaults!).forEach(([key, value]) => { 121 | (this as any)[key] = value 122 | }) 123 | } 124 | } 125 | connectedCallback() { 126 | super.connectedCallback() 127 | dispatch(this, "connected") 128 | } 129 | 130 | firstUpdated(changedProperties: Map) { 131 | super.firstUpdated(changedProperties) 132 | dispatch(this, "firstUpdated", changedProperties) 133 | } 134 | 135 | attributeChangedCallback(name: string, old: string | null, value: string | null): void { 136 | this.suspense?.reset() 137 | super.attributeChangedCallback(name, old, value) 138 | dispatch(this, "attributeChanged", { name, old, value }) 139 | } 140 | 141 | reinitialize() { 142 | this.suspense?.reset() 143 | if (isDefault(args)) { 144 | Object.entries(args!.defaults!).forEach(([key, value]) => { 145 | (this as any)[key] = value 146 | }) 147 | } 148 | dispatch(this, "reinitialized") 149 | this.requestUpdate() 150 | } 151 | 152 | async suspenseComplete(): Promise { 153 | // first complete any open tasks 154 | await this.updateComplete 155 | // wait until the suspense is done 156 | await this.suspense?.complete() ?? Promise.resolve(true) 157 | // wait until the changes are processed 158 | return this.updateComplete 159 | } 160 | 161 | protected async performUpdate(): Promise { 162 | if (this.suspense) { 163 | this.content = this.suspense.render((this as any) as LitElementWithProps); 164 | } else { 165 | this.content = await Promise.resolve(render((this as any) as LitElementWithProps)) 166 | .catch(e => (console.error(e), html`${e}`)); 167 | } 168 | const result = super.performUpdate(); 169 | dispatch(this, "updated") 170 | return result 171 | } 172 | render(): TemplateResult { 173 | return this.content; 174 | } 175 | } 176 | 177 | customElements.define(name, RuntimeRepresentation); 178 | 179 | const element = document.createElement(name); 180 | registered[name] = element; 181 | return element as LitElementWithProps; 182 | }; 183 | -------------------------------------------------------------------------------- /docs/state.md: -------------------------------------------------------------------------------- 1 | # State 2 | 3 | State handling is provided by the two external libraries 4 | 5 | * [lit-element-state-decoupler](https://github.com/MatthiasKainer/lit-element-state-decoupler) - `useState`, `useReducer` and `useWorkflow` hooks 6 | * [lit-element-effect](https://github.com/MatthiasKainer/lit-element-effect) - `useEffect` and `useOnce` hooks 7 | 8 | See their respective repositories if there are open questions after these paragraphs. 9 | 10 | ## useState 11 | 12 | Getting access to the state can be done by calling the `useState` function. 13 | 14 | This should be done on one location in the lifecycle, and not inside a loop with 15 | a changing number of iterations because it tries to re-resolve the correct 16 | element from the previous run. 17 | 18 | ```typescript 19 | pureLit("demo-element", element => { 20 | const {get, set, value, subscribe} = 21 | useState(element, defaultState, options) 22 | }) 23 | ``` 24 | 25 | Depending on your preferences you can either use the `get` & `set` functions, or the `value` property. 26 | 27 | ```typescript 28 | const first = useState(element, defaultState); 29 | const { value: second } = useState(element, defaultState); 30 | return html` 31 | 34 | 37 | `; 38 | ``` 39 | 40 | The behaviour is the same, and can even be mixed 41 | 42 | ```typescript 43 | const { get, set, value } = useState(element, defaultState); 44 | return html` 45 | Variant State: ${value} 46 | 49 | 52 | `; 53 | ``` 54 | 55 | ### Options 56 | 57 | | variable | description | 58 | | ----------------------------------------- | --------------------------------------------------------------------------------- | 59 | | `updateDefauls: boolean` (default: false) | If set to true, updates the state with the default values every time it is called | 60 | 61 | The state exposes three functions, `get`, `set` and `subscribe`, and takes in a 62 | reference to the current LitElement and a default state. Whenever the state is 63 | updated, the LitElement will be updated, and the `render()` method of the 64 | component will be called. 65 | 66 | ```typescript 67 | pureLit("demo-element", element => { 68 | const {get, set} = useState(element, { values: [] }) 69 | return html` 70 | 71 | 72 | ` 73 | }) 74 | ``` 75 | 76 | | function | description | 77 | | ----------------------------------------- | ----------------------------------------------------------- | 78 | | get() => YourState | Returns the current state | 79 | | set(newState: YourState) => Promise<void> | Updates the state to a new state | 80 | | subscribe(yourSubscriberFunction) => void | Notifies subscribed functions if the state has been changed | 81 | 82 | ## useReducer 83 | 84 | Getting access to the reducer can be done by calling the `useReducer` function. 85 | 86 | This should be done on one location in the lifecycle, and not inside a loop with 87 | a changing number of iterations because it tries to re-resolve the correct 88 | element from the previous run. 89 | 90 | ```typescript 91 | pureLit("demo-element", element => { 92 | const {get, set, subscribe} = useReducer(element, yourReducer, defaultState, options?) 93 | }) 94 | ``` 95 | 96 | Similar to the state, the reducer exposes three functions, `get`, `set` and 97 | `subscribe`, and takes in a reference to the current LitElement and a default 98 | state. In addition, it also requires a reducer function and can directly trigger 99 | custom events that bubble up and can be used by the parent. 100 | 101 | Whenever the state is updated, the LitElement will be updated, and the 102 | `render()` method of the component will be called. 103 | 104 | ### Reducer Function 105 | 106 | The reducer follows a definition of 107 | `(state: T, payload: unknown) => {[action: string]: () => T}`, so it's a 108 | function that returns a map of actions that are triggered by a specific action. 109 | Other then in `redux`, no default action has to be provided. If the action does 110 | not exist, it falls back to returning the current state. 111 | 112 | An example implementation of a reducer is thus: 113 | 114 | ```typescript 115 | class StateExample { 116 | constructor(public values = []) {} 117 | } 118 | 119 | const exampleReducer = (state: StateExample) => ({ 120 | add: (payload: string) => ({ ...state, value: [...state.values, payload] }), 121 | empty: () => ({ ...state, value: [] }), 122 | }); 123 | ``` 124 | 125 | ### Options 126 | 127 | | variable | description | 128 | | ----------------------------------------- | --------------------------------------------------------------------------------- | 129 | | `dispatchEvent: boolean` (default: false) | If set to true, dispatches a action as custom event from the component | 130 | | `updateDefauls: boolean` (default: false) | If set to true, updates the state with the default values every time it is called | 131 | 132 | 133 | ### Arguments 134 | 135 | | function | description | 136 | | --------------------------------------------- | ------------------------------------------------------------------ | 137 | | get() => YourState | Returns the current state | 138 | | set(action: string, payload: unknown) => void | Triggers the defined `action` on your reducer, passing the payload | 139 | | subscribe(yourSubscriberFunction) => void | Notifies subscribed functions when the state has been changed | 140 | | when(action, yourSubscriberFunction) => void | Notifies subscribed functions when the action has been triggered | 141 | 142 | #### set 143 | 144 | The reducer can be triggered whenever the reducer's `set` function is triggered, 145 | i.e. 146 | 147 | ```typescript 148 | pureLit('example-element', element => { 149 | const {set, get} = useReducer(element, exampleReducer, { values: [] }); 150 | return html` 151 | 152 | 153 | 154 | ` 155 | }) 156 | ``` 157 | 158 | #### set with custom events 159 | 160 | If specified in the options, the set will also be dispatched as a custom event. 161 | An example would look like this: 162 | 163 | ```typescript 164 | class StateExample { 165 | constructor(public values = []) {} 166 | } 167 | 168 | const exampleReducer = (state: StateExample) => ({ 169 | add: (payload) => ({ ...state, value: [...state.values, payload] }), 170 | }); 171 | 172 | pureLit('demo-clickme', element => { 173 | const { set, get } = useReducer(element, exampleReducer, 0, { 174 | dispatchEvent: true, 175 | }); 176 | 177 | return html``; 180 | }) 181 | 182 | // usage 183 | html` 184 | 185 | `; 186 | ``` 187 | 188 | #### Subscribe to seted events 189 | 190 | For side effects it might be interesting for you to listen to your own 191 | dispatched events. This can be done via `subscribe`. 192 | 193 | Usage: 194 | 195 | ```typescript 196 | const { set, get, subscribe } = useReducer( 197 | element, 198 | exampleReducer, 199 | 0, 200 | ); 201 | 202 | subscribe((action, state) => 203 | console.log("Action triggered:", action, "State:", state) 204 | ); 205 | 206 | return html` 207 | 208 | `; 209 | ``` 210 | 211 | In case you want to listen to a single action you can use the convenience method 212 | `when`. 213 | 214 | ```typescript 215 | const { set, get, when } = useReducer(element, exampleReducer, 0); 216 | 217 | when("add", (state) => console.log("Add triggered! State:", state)); 218 | 219 | return html` 220 | 222 | `; 223 | ``` 224 | 225 | 226 | ## One way flow 227 | 228 | Both `useState` and `useReducer` have an option to `updateDefauls: boolean` 229 | (default: false). If set to true, it updates the state with the default values 230 | every time it is called. This is handy for one-way data binding. One example 231 | could be a list like: 232 | 233 | ```typescript 234 | const { set } = useReducer(element, listReducer, [...element.items], { 235 | dispatchEvent: true, 236 | updateDefauls: true, 237 | }); 238 | return html` { 241 | const element = (e.target as HTMLInputElement); 242 | if (element.value !== "" && e.key === "Enter") { 243 | set("add", element.value); 244 | element.value = ""; 245 | } 246 | }} 247 | /> 248 |
    249 | ${element.items.map((todo) => html`
  • ${todo}
  • `)} 250 |
251 | `; 252 | ``` 253 | 254 | if this is used in a page like this 255 | 256 | ```typescript 257 | set(e.details)}> 258 | 259 | ``` 260 | 261 | Changing the attribute has been fully delegated to the user, while the control 262 | itself can still change it. 263 | 264 | ## Avoiding endless state updates 265 | 266 | Imaging a scenario where you need get some information from an endpoint you'd 267 | would want to store in the state. So you fetch it, and set it. An example would 268 | look like this: 269 | 270 | ```typescript 271 | pureLit("demo-element", element => { 272 | const {get, set} = 273 | useReducer(element, NotificationReducer, { status: "Loading" }) 274 | fetch("/api/notifications") 275 | .then(response => response.json()) 276 | .then(data => set("loaded", data)) 277 | .catch(err => set("failed", err)) 278 | 279 | const { status, notifications } = get() 280 | switch(status) { 281 | case "Error": return html`An error has occured`; 282 | case "Success": return html`` 283 | } 284 | return html`Please wait while loading`; 285 | }) 286 | ``` 287 | 288 | Unfortunately, this will lead to an endless loop. The reason is the following 289 | flow: 290 | 291 | ```txt 292 | +--------------------------------+ 293 | | | 294 | +--> render -> fetch -> set +----+ 295 | ``` 296 | 297 | The render triggers the fetch, which triggers a set. A set however triggers a 298 | render, which triggers a fetch, which triggers a set. This triggers a render, 299 | which triggers a fetch, which triggers a set. All of that forever, and really 300 | fast. 301 | 302 | While deploying this is great to performance test your apis, and might not be 303 | the original plan. To work around this, you might want to use the `useEffect` 304 | and `useOnce` hooks, which allows you to execute a certain callback only once, 305 | or if something changes. 306 | 307 | ```typescript 308 | pureLit("demo-element", element => { 309 | const {get, set} = 310 | useReducer(element, NotificationReducer, { status: "Loading" }) 311 | useOnce(element, () => { 312 | fetch("/api/notifications") 313 | .then(response => response.json()) 314 | .then(data => set("loaded", data)) 315 | .catch(err => set("failed", err)) 316 | }) 317 | 318 | const { status, notifications } = get() 319 | switch(status) { 320 | case "Error": return html`An error has occured`; 321 | case "Success": return html`` 322 | } 323 | return html`Please wait while loading`; 324 | }) 325 | ``` 326 | 327 | With this little addition it is ensured that the fetch will be called only once. 328 | Accordingly, if you want to call the fetch on a property change only, use the 329 | `useEffect` hook as follows: 330 | 331 | ```typescript 332 | 333 | pureLit("demo-element", element => { 334 | const {get, set} = 335 | useReducer(element, NotificationReducer, { status: "Loading" }) 336 | useEffect(element, () => { 337 | fetch(`/api/notifications/${element.user}`) 338 | .then(response => response.json()) 339 | .then(data => set("loaded", data)) 340 | .catch(err => set("failed", err)) 341 | }, [element.user]) 342 | 343 | const { status, notifications } = get() 344 | switch(status) { 345 | case "Error": return html`An error has occured`; 346 | case "Success": return html`` 347 | } 348 | return html`Please wait while loading`; 349 | } 350 | ``` 351 | 352 | ## Workflows 353 | 354 | Workflows allow you to create longer-running activities in your frontends. Think 355 | about an app that is setup as a temporary chat, where the flow might look like 356 | this: 357 | 358 | ![Flow of creating a temporary chat](img/workflow.png) 359 | 360 | With the workflow hook, and the async rendering features for pure-lit, this 361 | implementation would look like this: 362 | 363 | ```typescript 364 | import { pureLit, useOnce, useWorkflow } from "pure-lit"; 365 | import { html } from "lit"; 366 | import { io } from "https://cdn.socket.io/4.3.1/socket.io.esm.min.js"; 367 | import { hours } from "./duration"; 368 | import "./components"; 369 | 370 | const socket = io("http://localhost:3001"); 371 | 372 | // The reducer for the user 373 | const userReducer = () => ({ 374 | createUser: async (userName) => ({ userName }), 375 | deleteUser: async (userName) => undefined, 376 | }); 377 | 378 | // the reducer for managing the chat 379 | const chatReducer = (state) => ({ 380 | joinChat: (id) => Promise.resolve({ id }), 381 | sendMessage: async (message) => { 382 | socket.emit("message", message); 383 | return state; 384 | }, 385 | receiveMessage: async (message) => { 386 | return { 387 | ...state, 388 | messages: [ 389 | ...(state.messages || []), 390 | message, 391 | ], 392 | }; 393 | }, 394 | leaveChat: async () => undefined, 395 | }); 396 | 397 | pureLit("easy-chat", async (element) => { 398 | const workflow = useWorkflow(element, { 399 | user: { reducer: userReducer }, 400 | chat: { reducer: chatReducer }, 401 | }); 402 | 403 | return await workflow.plan({ 404 | // this will be triggered unless we have a 405 | // projection for the user different from 406 | // the initial value 407 | user: async () => { 408 | return html` { 409 | workflow.addActivity("createUser", userName); 410 | // componsation is stored, and whenever workflow.compensate() 411 | // is called, the deleteUser action will be triggered 412 | workflow.addCompensation("deleteUser", userName); 413 | }}>`; 414 | }, 415 | // Once we have a user, the second part 416 | // of the plan is executed, waiting for a 417 | // projection of the chat 418 | chat: async () => { 419 | // if the user does not join a chat in hour, delete the account 420 | workflow.after(hours(1), { 421 | type: "addActivity", 422 | args: ["joinChat"], 423 | }, async () => await workflow.compensate()); 424 | return html` { 425 | workflow.addActivity("joinChat", chat); 426 | workflow.addCompensation("leaveChat", chat); 427 | }}>`; 428 | }, 429 | // This fallback at the end is triggered 430 | // whenever all previous plans are 431 | // executed 432 | "": async () => { 433 | // if the user has not participated for an hour, leave the chat and delete the account 434 | const registerTimeout = () => { 435 | workflow.after(hours(1), { 436 | type: "addActivity", 437 | args: ["sendMessage"], 438 | }, async () => await workflow.compensate()); 439 | }; 440 | 441 | useOnce(element, () => { 442 | socket.on("message", (stream) => { 443 | workflow.addActivity("receiveMessage", stream); 444 | }); 445 | registerTimeout(); 446 | }); 447 | 448 | const { userName } = workflow.projections("user"); 449 | const { id, messages } = workflow.projections("chat"); 450 | return html 451 | ` { 454 | workflow.addActivity("sendMessage", { id, message, userName }); 455 | registerTimeout(); 456 | }}>`; 457 | }, 458 | }); 459 | }); 460 | ``` 461 | 462 | Note that you don't have to use the plan, you could as well write 463 | 464 | ```js 465 | if (!workflow.projection("user")) { 466 | return html`