├── .gitignore ├── .prettierrc ├── web-test-runner.config.mjs ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── package.json ├── README.md └── src ├── index.js └── index.test.jsx /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .env 4 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "useTabs": true 4 | } 5 | -------------------------------------------------------------------------------- /web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { esbuildPlugin } from '@web/dev-server-esbuild'; 2 | 3 | export default { 4 | nodeResolve: true, 5 | plugins: [ 6 | esbuildPlugin({ 7 | jsx: true, 8 | jsxFactory: 'h', 9 | jsxFragment: 'Fragment', 10 | }), 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Bradley Spaulding 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. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | types: [opened, synchronize, reopened, ready_for_review] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v1 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: '12.x' 17 | - name: Install webkit deps 18 | run: | 19 | sudo apt-get update 20 | sudo apt-get install libgstreamer1.0-0 libbrotli1 libopus0 libwoff1 libgstreamer-plugins-base1.0-0 libgstreamer-gl1.0-0 libgstreamer-plugins-bad1.0-0 libopenjp2-7 libwebpdemux2 libhyphen0 libgles2 21 | - name: Cache node modules 22 | uses: actions/cache@v1 23 | env: 24 | cache-name: cache-node-modules 25 | with: 26 | path: ~/.npm 27 | # This uses the same name as the build-action so we can share the caches. 28 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 29 | restore-keys: | 30 | ${{ runner.os }}-build-${{ env.cache-name }}- 31 | ${{ runner.os }}-build- 32 | ${{ runner.os }}- 33 | - run: npm ci 34 | - name: build 35 | run: npm run prepare 36 | - name: test 37 | run: | 38 | npm run lint 39 | npm run test:browsers 40 | env: 41 | CI: true 42 | SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} 43 | SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "preact-custom-element", 3 | "version": "4.2.1", 4 | "description": "Wrap your component up as a custom element", 5 | "main": "dist/preact-custom-element.js", 6 | "module": "dist/preact-custom-element.esm.js", 7 | "unpkg": "dist/preact-custom-element.umd.js", 8 | "source": "src/index.js", 9 | "scripts": { 10 | "prepare": "microbundle", 11 | "lint": "eslint src/*.{js,jsx}", 12 | "test": "wtr src/*.test.{js,jsx}", 13 | "test:browsers": "wtr src/*.test.{js,jsx} --playwright --browsers chromium firefox webkit", 14 | "prettier": "prettier **/*.{js,jsx} --write", 15 | "prepublishOnly": "npm run lint && npm run test" 16 | }, 17 | "eslintConfig": { 18 | "extends": "developit", 19 | "settings": { 20 | "react": { 21 | "version": "latest" 22 | } 23 | }, 24 | "rules": { 25 | "brace-style": "off", 26 | "jest/expect-expect": "off", 27 | "lines-around-comment": "off", 28 | "comma-dangle": "off", 29 | "no-unused-vars": [ 30 | 2, 31 | { 32 | "args": "none", 33 | "varsIgnorePattern": "^h$" 34 | } 35 | ] 36 | } 37 | }, 38 | "repository": "preactjs/preact-custom-element", 39 | "keywords": [ 40 | "preact", 41 | "web", 42 | "components", 43 | "custom", 44 | "element" 45 | ], 46 | "authors": [ 47 | "Bradley J. Spaulding", 48 | "The Preact Authors (https://preactjs.com)" 49 | ], 50 | "license": "MIT", 51 | "bugs": "https://github.com/preactjs/preact-custom-element/issues", 52 | "homepage": "https://github.com/preactjs/preact-custom-element", 53 | "peerDependencies": { 54 | "preact": "10.x" 55 | }, 56 | "devDependencies": { 57 | "@open-wc/testing": "^2.5.25", 58 | "@web/dev-server-core": "^0.2.4", 59 | "@web/dev-server-esbuild": "^0.2.2", 60 | "@web/test-runner": "^0.7.12", 61 | "@web/test-runner-playwright": "^0.5.4", 62 | "eslint": "^7.7.0", 63 | "eslint-config-developit": "^1.2.0", 64 | "get-stream": "^6.0.0", 65 | "husky": "^4.2.5", 66 | "lint-staged": "^10.2.13", 67 | "microbundle": "^0.12.3", 68 | "preact": "^10.4.8", 69 | "prettier": "^2.1.1" 70 | }, 71 | "lint-staged": { 72 | "**/*.{js,jsx,ts,tsx,yml}": [ 73 | "prettier --write" 74 | ] 75 | }, 76 | "husky": { 77 | "hooks": { 78 | "pre-commit": "lint-staged" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # preact-custom-element 2 | 3 | Generate/register a custom element from a preact component. As of 3.0.0, this library implements the Custom Elements v1 spec. 4 | Previous versions (< 3.0.0) implemented the v0 proposal, which was only implemented in Chrome and is abandoned. 5 | 6 | ## Usage 7 | 8 | Import `CustomElement` and call with your component a tag name __\*__, and a list of attribute names you want to observe: 9 | 10 | ```javascript 11 | import register from 'preact-custom-element'; 12 | 13 | const Greeting = ({ name = 'World' }) => ( 14 |
Hello, {name}!
15 | ); 16 | 17 | register(Greeting, 'x-greeting', ['name']); 18 | ``` 19 | 20 | > _**\* Note:** as per the [Custom Elements specification](http://w3c.github.io/webcomponents/spec/custom/#prod-potentialcustomelementname), the tag name must contain a hyphen._ 21 | 22 | Use the new tag name in HTML, attribute keys and values will be passed in as props: 23 | 24 | ```html 25 |Hello, Billy Jo!
32 | ``` 33 | 34 | ### Prop Names and Automatic Prop Names 35 | 36 | The Custom Elements V1 specification requires explictly stating the names of any attributes you want to observe. From your Preact component perspective, `props` could be an object with any keys at runtime, so it's not always clear which props should be accepted as attributes. 37 | 38 | If you omit the third parameter to `register()`, the list of attributes to observe can be specified using a static `observedAttributes` property on your Component. This also works for the Custom Element's name, which can be specified using a `tagName` static property: 39 | 40 | ```js 41 | import register from 'preact-custom-element'; 42 | 43 | //Hello, {name}!
; 53 | } 54 | } 55 | register(Greeting); 56 | ``` 57 | 58 | If no `observedAttributes` are specified, they will be inferred from the keys of `propTypes` if present on the Component: 59 | 60 | ```js 61 | // Other option: use PropTypes: 62 | function FullName(props) { 63 | return {props.first} {props.last} 64 | } 65 | FullName.propTypes = { 66 | first: Object, // you can use PropTypes, or this 67 | last: Object // trick to define untyped props. 68 | }; 69 | register(FullName, 'full-name'); 70 | ``` 71 | 72 | 73 | ## Related 74 | 75 | [preact-shadow-dom](https://github.com/bspaulding/preact-shadow-dom) 76 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { h, cloneElement, render, hydrate } from 'preact'; 2 | 3 | export default function register(Component, tagName, propNames, options) { 4 | function PreactElement() { 5 | const inst = Reflect.construct(HTMLElement, [], PreactElement); 6 | inst._vdomComponent = Component; 7 | inst._root = 8 | options && options.shadow ? inst.attachShadow({ mode: 'open' }) : inst; 9 | return inst; 10 | } 11 | PreactElement.prototype = Object.create(HTMLElement.prototype); 12 | PreactElement.prototype.constructor = PreactElement; 13 | PreactElement.prototype.connectedCallback = connectedCallback; 14 | PreactElement.prototype.attributeChangedCallback = attributeChangedCallback; 15 | PreactElement.prototype.disconnectedCallback = disconnectedCallback; 16 | 17 | propNames = 18 | propNames || 19 | Component.observedAttributes || 20 | Object.keys(Component.propTypes || {}); 21 | PreactElement.observedAttributes = propNames; 22 | 23 | // Keep DOM properties and Preact props in sync 24 | propNames.forEach((name) => { 25 | Object.defineProperty(PreactElement.prototype, name, { 26 | get() { 27 | return this._vdom.props[name]; 28 | }, 29 | set(v) { 30 | if (this._vdom) { 31 | this.attributeChangedCallback(name, null, v); 32 | } else { 33 | if (!this._props) this._props = {}; 34 | this._props[name] = v; 35 | this.connectedCallback(); 36 | } 37 | 38 | // Reflect property changes to attributes if the value is a primitive 39 | const type = typeof v; 40 | if ( 41 | v == null || 42 | type === 'string' || 43 | type === 'boolean' || 44 | type === 'number' 45 | ) { 46 | this.setAttribute(name, v); 47 | } 48 | }, 49 | }); 50 | }); 51 | 52 | return customElements.define( 53 | tagName || Component.tagName || Component.displayName || Component.name, 54 | PreactElement 55 | ); 56 | } 57 | 58 | function ContextProvider(props) { 59 | this.getChildContext = () => props.context; 60 | // eslint-disable-next-line no-unused-vars 61 | const { context, children, ...rest } = props; 62 | return cloneElement(children, rest); 63 | } 64 | 65 | function connectedCallback() { 66 | // Obtain a reference to the previous context by pinging the nearest 67 | // higher up node that was rendered with Preact. If one Preact component 68 | // higher up receives our ping, it will set the `detail` property of 69 | // our custom event. This works because events are dispatched 70 | // synchronously. 71 | const event = new CustomEvent('_preact', { 72 | detail: {}, 73 | bubbles: true, 74 | cancelable: true, 75 | }); 76 | this.dispatchEvent(event); 77 | const context = event.detail.context; 78 | 79 | this._vdom = h( 80 | ContextProvider, 81 | { ...this._props, context }, 82 | toVdom(this, this._vdomComponent) 83 | ); 84 | (this.hasAttribute('hydrate') ? hydrate : render)(this._vdom, this._root); 85 | } 86 | 87 | function toCamelCase(str) { 88 | return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : '')); 89 | } 90 | 91 | function attributeChangedCallback(name, oldValue, newValue) { 92 | if (!this._vdom) return; 93 | // Attributes use `null` as an empty value whereas `undefined` is more 94 | // common in pure JS components, especially with default parameters. 95 | // When calling `node.removeAttribute()` we'll receive `null` as the new 96 | // value. See issue #50. 97 | newValue = newValue == null ? undefined : newValue; 98 | const props = {}; 99 | props[name] = newValue; 100 | props[toCamelCase(name)] = newValue; 101 | this._vdom = cloneElement(this._vdom, props); 102 | render(this._vdom, this._root); 103 | } 104 | 105 | function disconnectedCallback() { 106 | render((this._vdom = null), this._root); 107 | } 108 | 109 | /** 110 | * Pass an event listener to each `Active theme: {theme}
; 212 | } 213 | 214 | registerElement(DisplayTheme, 'x-display-theme', [], { shadow: true }); 215 | 216 | function Parent({ children, theme = 'dark' }) { 217 | return ( 218 |Active theme: dark
'); 241 | 242 | // Trigger context update 243 | act(() => { 244 | el.setAttribute('theme', 'sunny'); 245 | }); 246 | assert.equal(getShadowHTML(), 'Active theme: sunny
'); 247 | }); 248 | }); 249 | --------------------------------------------------------------------------------