├── .editorconfig ├── .gitignore ├── .release-it.json ├── .travis.yml ├── LICENSE ├── README.md ├── demo └── index.html ├── index.js ├── karma.conf.js ├── package-lock.json ├── package.json ├── src ├── HauntedLitElement.js └── component.js └── test ├── haunted-lit-element.test.js └── library.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | 23 | [*.json] 24 | indent_size = 2 25 | 26 | [*.{html,js,md}] 27 | block_comment_start = /** 28 | block_comment = * 29 | block_comment_end = */ 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## editors 2 | /.idea 3 | /.vscode 4 | 5 | ## system files 6 | .DS_Store 7 | 8 | ## npm 9 | /node_modules/ 10 | /npm-debug.log 11 | 12 | ## testing 13 | /coverage/ 14 | 15 | ## temp folders 16 | /.tmp/ 17 | 18 | # build 19 | /_site/ 20 | /dist/ 21 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "changelog": "git log --pretty=format:'* %s (%h)' [REV_RANGE]" 4 | }, 5 | "github": { 6 | "release": true, 7 | "releaseName": "Release %s", 8 | "tokenRef": "GITHUB_TOKEN" 9 | }, 10 | "publishConfig": { 11 | "access": "public" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: node 3 | addons: 4 | chrome: stable 5 | script: 6 | - npm run lint && npm run test 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 haunted-lit-element 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Haunted Lit Element 2 | 3 | [![Build Status](https://travis-ci.com/jdin/haunted-lit-element.svg?branch=master)](https://travis-ci.com/jdin/haunted-lit-element) 4 | [![npm](https://img.shields.io/npm/v/haunted-lit-element)](https://www.npmjs.com/package/haunted-lit-element) 5 | [![size](https://img.shields.io/bundlephobia/minzip/haunted-lit-element)](https://bundlephobia.com/result?p=haunted-lit-element) 6 | [![downloads](https://img.shields.io/npm/dt/haunted-lit-element)](https://www.npmjs.com/package/haunted-lit-element) 7 | 8 | A missing connection between [Haunted](https://github.com/matthewp/haunted) and [LitElement](https://github.com/polymer/lit-element). 9 | 10 | It makes it possible to use LitElement's features like 11 | [properties](https://lit-element.polymer-project.org/guide/properties) 12 | and [styles](https://lit-element.polymer-project.org/guide/styles) in Haunted. 13 | 14 | > This project follows the [open-wc](https://github.com/open-wc/open-wc) recommendation. 15 | 16 | ## Installation 17 | ```bash 18 | npm i haunted-lit-element 19 | ``` 20 | 21 | ## Usage 22 | 23 | This library provides `component` function that is made in the way as it is in `Haunted`. 24 | 25 | ### `component(MyEl)` 26 | 27 | Similar to `haunted` but the base class is `LitElement`: 28 | 29 | ```javascript 30 | import {html} from 'lit-html'; 31 | import {component} from 'haunted-lit-element'; 32 | window.customElements.define('my-el', component(() => html`hello world`)); 33 | ``` 34 | 35 | ### `component(MyEl, optsOrBaseCls)` 36 | 37 | The second parameter in `component` function can be `options` or a `base class` 38 | which should be derived from `HauntedLitElement`. 39 | 40 | The `options` in most cases are [properties](https://lit-element.polymer-project.org/guide/properties) 41 | and [styles](https://lit-element.polymer-project.org/guide/styles) from `LitElement`. 42 | But it can actually be anything as at the end it is just a static field in the base class. 43 | It is done in that way because there are `LitElement` extensions that use similar approach with their own configuration. 44 | 45 | Example of defining `options` as second argument: 46 | ```javascript 47 | import {css} from 'lit-element'; 48 | import {component} from 'haunted-lit-element'; 49 | 50 | const MyEl = () => { /*...*/ }; 51 | 52 | const properties = {myParam: {type: String}, /*...*/}; 53 | const styles = css`/* my css styles */`; 54 | 55 | window.customElements.define('my-el', component(MyEl, {properties, styles})); 56 | ``` 57 | 58 | Example of defining `base class` as second argument: 59 | ```javascript 60 | import {component, HauntedLitElement} from 'haunted-lit-element'; 61 | 62 | class MyExtHauntedLitElement extends HauntedLitElement { 63 | // ... my own stuff 64 | } 65 | 66 | const MyEl = () => { /*...*/ }; 67 | 68 | window.customElements.define('my-el', component(MyEl, MyExtHauntedLitElement)); 69 | ``` 70 | 71 | ### `component(myEl, baseCls, opts)` 72 | 73 | If you want to use options and a base class than the base class is the second argument and options are the third. 74 | 75 | Example of using LitElement's [properties](https://lit-element.polymer-project.org/guide/properties) 76 | and [styles](https://lit-element.polymer-project.org/guide/styles) helper with a custom base class. 77 | 78 | ```html 79 | 86 | 87 | 88 | 123 | ``` 124 | 125 | The output for properties is going to be: 126 | 127 | ```text 128 | typeof mystring = string 129 | typeof mynumber = number 130 | typeof mybool = boolean 131 | typeof myobj = object 132 | Array.isArray(myarray) = true 133 | ``` 134 | 135 | ## Testing using karma 136 | ```bash 137 | npm run test 138 | ``` 139 | 140 | ## Linting 141 | ```bash 142 | npm run lint 143 | ``` 144 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 19 | 20 | 21 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { component } from './src/component.js'; 2 | 3 | export { component }; 4 | export { HauntedLitElement } from './src/HauntedLitElement.js'; 5 | export default component; 6 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const { createDefaultConfig } = require('@open-wc/testing-karma'); 3 | const merge = require('webpack-merge'); 4 | 5 | module.exports = config => { 6 | config.set( 7 | merge(createDefaultConfig(config), { 8 | files: [ 9 | // runs all files ending with .test in the test folder, 10 | // can be overwritten by passing a --grep flag. examples: 11 | // 12 | // npm run test -- --grep test/foo/bar.test.js 13 | // npm run test -- --grep test/bar/* 14 | { pattern: config.grep ? config.grep : 'test/**/*.test.js', type: 'module' }, 15 | ], 16 | 17 | esm: { 18 | nodeResolve: true, 19 | }, 20 | // you can overwrite/extend the config further 21 | }), 22 | ); 23 | return config; 24 | }; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "haunted-lit-element", 3 | "version": "0.1.3", 4 | "description": "A missing connection between LitElement and Haunted", 5 | "author": "jdin", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/jdin/haunted-lit-element" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/jdin/haunted-lit-element/issues" 12 | }, 13 | "homepage": "https://github.com/jdin/haunted-lit-element#readme", 14 | "license": "MIT", 15 | "main": "index.js", 16 | "module": "index.js", 17 | "keywords": [ 18 | "lit-element", 19 | "lit-html", 20 | "haunted", 21 | "hooks", 22 | "react-hooks", 23 | "web-components" 24 | ], 25 | "files": [ 26 | "index.js", 27 | "src" 28 | ], 29 | "scripts": { 30 | "start": "es-dev-server --app-index demo/index.html --node-resolve --open --watch", 31 | "start:compatibility": "es-dev-server --app-index demo/index.html --compatibility all --node-resolve --open --watch", 32 | "lint:eslint": "eslint --ext .js,.html . --ignore-path .gitignore", 33 | "format:eslint": "eslint --ext .js,.html . --fix --ignore-path .gitignore", 34 | "lint:prettier": "prettier \"**/*.js\" --check --ignore-path .gitignore", 35 | "format:prettier": "prettier \"**/*.js\" --write --ignore-path .gitignore", 36 | "lint": "npm run lint:eslint && npm run lint:prettier", 37 | "format": "npm run format:eslint && npm run format:prettier", 38 | "test": "karma start --coverage", 39 | "test:watch": "karma start --auto-watch=true --single-run=false", 40 | "test:update-snapshots": "karma start --update-snapshots", 41 | "test:prune-snapshots": "karma start --prune-snapshots", 42 | "test:compatibility": "karma start --compatibility all --coverage", 43 | "test:compatibility:watch": "karma start --compatibility all --auto-watch=true --single-run=false", 44 | "release": "release-it", 45 | "release:dry": "release-it --dry-run" 46 | }, 47 | "peerDependencies": { 48 | "haunted": "^4.6.0", 49 | "lit-element": "^2.0.0" 50 | }, 51 | "devDependencies": { 52 | "haunted": "^4.6.0", 53 | "lit-element": "^2.0.0", 54 | "@open-wc/eslint-config": "^2.0.2", 55 | "@open-wc/prettier-config": "^0.1.10", 56 | "@open-wc/testing": "^2.3.4", 57 | "@open-wc/testing-karma": "^3.1.35", 58 | "es-dev-server": "^1.5.0", 59 | "eslint": "^6.1.0", 60 | "husky": "^4.2.0", 61 | "lint-staged": "^10.0.2", 62 | "release-it": "^12.3.6", 63 | "webpack-merge": "^4.1.5" 64 | }, 65 | "eslintConfig": { 66 | "extends": [ 67 | "@open-wc/eslint-config", 68 | "eslint-config-prettier" 69 | ] 70 | }, 71 | "prettier": "@open-wc/prettier-config", 72 | "husky": { 73 | "hooks": { 74 | "pre-commit": "lint-staged" 75 | } 76 | }, 77 | "lint-staged": { 78 | "*.js": [ 79 | "eslint --fix", 80 | "prettier --write", 81 | "git add" 82 | ] 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/HauntedLitElement.js: -------------------------------------------------------------------------------- 1 | import { LitElement } from 'lit-element'; 2 | import { State } from 'haunted/core.js'; 3 | 4 | const defer = Promise.resolve().then.bind(Promise.resolve()); 5 | 6 | export class HauntedLitElement extends LitElement { 7 | constructor() { 8 | super(); 9 | this.haunted = new State(() => this.requestUpdate(), this); 10 | } 11 | 12 | update(_changedProperties) { 13 | this.haunted.run(() => { 14 | super.update(_changedProperties); 15 | }); 16 | } 17 | 18 | updated(_changedProperties) { 19 | super.updated(_changedProperties); 20 | this.haunted.runLayoutEffects(); 21 | defer(() => this.haunted.runEffects()); 22 | } 23 | 24 | disconnectedCallback() { 25 | this.haunted.teardown(); 26 | super.disconnectedCallback(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/component.js: -------------------------------------------------------------------------------- 1 | import { HauntedLitElement } from './HauntedLitElement.js'; 2 | 3 | export const component = (renderer, propsOrBaseCls = HauntedLitElement, props = {}) => { 4 | const localProps = typeof propsOrBaseCls === 'object' ? propsOrBaseCls : props; 5 | const localBaseClass = typeof propsOrBaseCls === 'function' ? propsOrBaseCls : HauntedLitElement; 6 | 7 | const retCls = class extends localBaseClass { 8 | render() { 9 | return renderer.call(this, this); 10 | } 11 | }; 12 | 13 | Object.entries(localProps).forEach(([key, val]) => { 14 | retCls[key] = val; 15 | }); 16 | 17 | return retCls; 18 | }; 19 | -------------------------------------------------------------------------------- /test/haunted-lit-element.test.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useLayoutEffect, useMemo } from 'haunted'; 2 | import { html, fixture, expect, fixtureCleanup } from '@open-wc/testing'; 3 | import { component } from '../src/component.js'; 4 | 5 | const runEffects = () => new Promise(resolve => setTimeout(resolve)); 6 | 7 | const register = (name, renderer, props = undefined) => 8 | window.customElements.define(name, component(renderer, props)); 9 | 10 | describe('HauntedLitElement', () => { 11 | it('shows basic text', async () => { 12 | register( 13 | 'test-el-1', 14 | () => 15 | html` 16 | Test 17 | `, 18 | ); 19 | const el = await fixture( 20 | html` 21 | 22 | `, 23 | ); 24 | expect(el).shadowDom.to.equal(`Test`); 25 | }); 26 | 27 | it('checks props are set', async () => { 28 | const properties = { test: { type: String } }; 29 | register( 30 | 'test-el-2', 31 | ({ test }) => 32 | html` 33 | test=${test} 34 | `, 35 | { properties }, 36 | ); 37 | const el1 = await fixture( 38 | html` 39 | 40 | `, 41 | ); 42 | expect(el1).shadowDom.to.equal(`test=`); 43 | const el2 = await fixture( 44 | html` 45 | 46 | `, 47 | ); 48 | expect(el2.test).to.equal('bla'); 49 | expect(el2).shadowDom.to.equal(`test=bla`); 50 | }); 51 | 52 | it('checks haunted useState and useCallback works', async () => { 53 | const renderer = () => { 54 | const [count, setCount] = useState(0); 55 | const onClick = useCallback(() => { 56 | setCount(count + 1); 57 | }, []); 58 | return html` 59 |

${count}

60 | 61 | `; 62 | }; 63 | register('test-el-3', renderer); 64 | const el = await fixture( 65 | html` 66 | 67 | `, 68 | ); 69 | expect(el).shadowDom.to.equal(`

0

`); 70 | el.shadowRoot.querySelector('button').click(); 71 | await runEffects(); 72 | expect(el).shadowDom.to.equal(`

1

`); 73 | }); 74 | 75 | it('checks pure useEffect works', async () => { 76 | let isCleanedUp = false; 77 | const renderer = () => { 78 | useEffect(() => { 79 | console.log('use effect'); 80 | isCleanedUp = false; 81 | return () => { 82 | console.log('clean up'); 83 | isCleanedUp = true; 84 | }; 85 | }, []); 86 | return html` 87 | ... 88 | `; 89 | }; 90 | register('test-el-4', renderer); 91 | await fixture( 92 | html` 93 | 94 | `, 95 | ); 96 | expect(isCleanedUp).to.equal(false); 97 | fixtureCleanup(); 98 | expect(isCleanedUp).to.equal(true); 99 | }); 100 | 101 | it('checks haunted useEffect works', async () => { 102 | let isCleanedUp = false; 103 | const renderer = () => { 104 | const [val, setVal] = useState(null); 105 | const [loading, setLoading] = useState(true); 106 | useEffect(() => { 107 | isCleanedUp = false; 108 | setVal(loading ? '...' : 'loaded'); 109 | return () => { 110 | isCleanedUp = true; 111 | }; 112 | }, [loading]); 113 | return html` 114 |

${val}

115 | 116 | `; 117 | }; 118 | register('test-el-5', renderer); 119 | const el = await fixture( 120 | html` 121 | 122 | `, 123 | ); 124 | expect(el).shadowDom.to.equal(`

...

`); 125 | expect(isCleanedUp).to.equal(false); 126 | el.shadowRoot.querySelector('button').click(); 127 | await runEffects(); 128 | expect(el).shadowDom.to.equal(`

loaded

`); 129 | expect(isCleanedUp).to.equal(false); 130 | fixtureCleanup(); 131 | expect(isCleanedUp).to.equal(true); 132 | }); 133 | 134 | it('checks pure useLayoutEffect works', async () => { 135 | let isCleanedUp = false; 136 | const renderer = () => { 137 | useLayoutEffect(() => { 138 | console.log('use effect'); 139 | isCleanedUp = false; 140 | return () => { 141 | console.log('clean up'); 142 | isCleanedUp = true; 143 | }; 144 | }, []); 145 | return html` 146 | ... 147 | `; 148 | }; 149 | register('test-el-6', renderer); 150 | await fixture( 151 | html` 152 | 153 | `, 154 | ); 155 | expect(isCleanedUp).to.equal(false); 156 | fixtureCleanup(); 157 | expect(isCleanedUp).to.equal(true); 158 | }); 159 | 160 | it('checks haunted useLayoutEffect works', async () => { 161 | let isCleanedUp = false; 162 | const renderer = ({ shadowRoot }) => { 163 | const [loading, setLoading] = useState(true); 164 | useLayoutEffect(() => { 165 | isCleanedUp = false; 166 | // eslint-disable-next-line no-param-reassign 167 | shadowRoot.querySelector('p').innerText = loading ? '...' : 'loaded'; 168 | return () => { 169 | isCleanedUp = true; 170 | }; 171 | }, [loading]); 172 | return html` 173 |

174 | 175 | `; 176 | }; 177 | register('test-el-7', renderer); 178 | const el = await fixture( 179 | html` 180 | 181 | `, 182 | ); 183 | expect(el).shadowDom.to.equal(`

...

`); 184 | expect(isCleanedUp).to.equal(false); 185 | el.shadowRoot.querySelector('button').click(); 186 | await runEffects(); 187 | expect(el).shadowDom.to.equal(`

loaded

`); 188 | expect(isCleanedUp).to.equal(false); 189 | fixtureCleanup(); 190 | expect(isCleanedUp).to.equal(true); 191 | }); 192 | 193 | it('checks useMemo works', async () => { 194 | const renderer = () => { 195 | const a = useMemo(() => 25, []); 196 | return html` 197 | ${a} 198 | `; 199 | }; 200 | register('test-el-8', renderer); 201 | const el = await fixture( 202 | html` 203 | 204 | `, 205 | ); 206 | expect(el).shadowDom.to.equal(`25`); 207 | }); 208 | 209 | it('checks useMemo works with updates', async () => { 210 | const renderer = () => { 211 | const [loading, setLoading] = useState(true); 212 | const a = useMemo(() => loading, [loading]); 213 | const onClick = useCallback(() => setLoading(false), []); 214 | return html` 215 |

${a}

216 | 217 | `; 218 | }; 219 | register('test-el-9', renderer); 220 | const el = await fixture( 221 | html` 222 | 223 | `, 224 | ); 225 | expect(el).shadowDom.to.equal(`

true

`); 226 | el.shadowRoot.querySelector('button').click(); 227 | await runEffects(); 228 | expect(el).shadowDom.to.equal(`

false

`); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /test/library.test.js: -------------------------------------------------------------------------------- 1 | import { expect, html } from '@open-wc/testing'; 2 | import { css } from 'lit-element'; 3 | import compDefault, { HauntedLitElement, component } from '../index.js'; 4 | 5 | class MyCls extends HauntedLitElement {} 6 | 7 | describe('library tests', () => { 8 | it('can create basic component', () => { 9 | const comp = compDefault(() => html``); 10 | expect(comp).to.be.not.null; 11 | window.customElements.define('my-el', comp); 12 | const C = window.customElements.get('my-el'); 13 | expect(new C() instanceof HauntedLitElement).to.be.true; 14 | }); 15 | 16 | it('can create basic component with props, styles and custom stuff', () => { 17 | const properties = { a: { type: String } }; 18 | const styles = css` 19 | :host { 20 | display: block; 21 | } 22 | `; 23 | const custom = { a: 'b' }; 24 | const comp = compDefault(() => html``, { properties, styles, custom }); 25 | expect(comp.properties).to.equal(properties); 26 | expect(comp.styles).to.equal(styles); 27 | expect(comp.custom).to.equal(custom); 28 | }); 29 | 30 | it('can create basic comp with custom class and props', () => { 31 | const properties = { a: { type: String } }; 32 | const comp = compDefault(() => html``, MyCls, { properties }); 33 | window.customElements.define('my-custom-el', comp); 34 | const C = window.customElements.get('my-custom-el'); 35 | expect(new C() instanceof MyCls).to.be.true; 36 | expect(comp.properties).to.equal(properties); 37 | }); 38 | 39 | it('can create basic comp with custom class as a second param', () => { 40 | const comp = compDefault(() => html``, MyCls); 41 | window.customElements.define('my-custom-el-2', comp); 42 | const C = window.customElements.get('my-custom-el-2'); 43 | expect(new C() instanceof MyCls).to.be.true; 44 | }); 45 | 46 | it('can create basic comp using component', () => { 47 | const comp = component(() => html``); 48 | window.customElements.define('my-el-3', comp); 49 | const C = window.customElements.get('my-el-3'); 50 | expect(new C() instanceof HauntedLitElement).to.be.true; 51 | }); 52 | }); 53 | --------------------------------------------------------------------------------