├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── index.js └── index.test.jsx └── web-test-runner.config.mjs /.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@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 18.x 17 | cache: npm 18 | - name: Install NPM dependencies 19 | run: npm ci 20 | - name: Install playwright dependencies 21 | run: npx playwright install-deps 22 | - name: build 23 | run: npm run prepare 24 | - name: test 25 | run: | 26 | npm run lint 27 | npm run test:browsers 28 | env: 29 | CI: true 30 | SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} 31 | SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .env 4 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "useTabs": true 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 `register` and call it 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](https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name), 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "preact-custom-element", 3 | "version": "4.3.0", 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 | "types": "dist/index.d.ts", 8 | "unpkg": "dist/preact-custom-element.umd.js", 9 | "source": "src/index.js", 10 | "scripts": { 11 | "prepare": "microbundle", 12 | "lint": "eslint src/*.{js,jsx}", 13 | "test": "wtr src/*.test.{js,jsx}", 14 | "test:browsers": "wtr src/*.test.{js,jsx} --playwright --browsers chromium firefox webkit", 15 | "prettier": "prettier **/*.{js,jsx} --write", 16 | "prepublishOnly": "npm run lint && npm run test" 17 | }, 18 | "eslintConfig": { 19 | "extends": "developit", 20 | "settings": { 21 | "react": { 22 | "version": "latest" 23 | } 24 | }, 25 | "rules": { 26 | "brace-style": "off", 27 | "jest/expect-expect": "off", 28 | "lines-around-comment": "off", 29 | "comma-dangle": "off", 30 | "no-unused-vars": [ 31 | 2, 32 | { 33 | "args": "none", 34 | "varsIgnorePattern": "^h$" 35 | } 36 | ] 37 | } 38 | }, 39 | "repository": "preactjs/preact-custom-element", 40 | "keywords": [ 41 | "preact", 42 | "web", 43 | "components", 44 | "custom", 45 | "element" 46 | ], 47 | "authors": [ 48 | "Bradley J. Spaulding", 49 | "The Preact Authors (https://preactjs.com)" 50 | ], 51 | "license": "MIT", 52 | "bugs": "https://github.com/preactjs/preact-custom-element/issues", 53 | "homepage": "https://github.com/preactjs/preact-custom-element", 54 | "peerDependencies": { 55 | "preact": "10.x" 56 | }, 57 | "devDependencies": { 58 | "@open-wc/testing": "^2.5.25", 59 | "@web/dev-server-core": "^0.5.2", 60 | "@web/dev-server-esbuild": "^0.4.1", 61 | "@web/test-runner": "^0.17.0", 62 | "@web/test-runner-playwright": "^0.10.1", 63 | "eslint": "^7.7.0", 64 | "eslint-config-developit": "^1.2.0", 65 | "get-stream": "^6.0.0", 66 | "husky": "^4.2.5", 67 | "lint-staged": "^10.2.13", 68 | "microbundle": "^0.15.1", 69 | "preact": "^10.4.8", 70 | "prettier": "^2.1.1" 71 | }, 72 | "lint-staged": { 73 | "**/*.{js,jsx,ts,tsx,yml}": [ 74 | "prettier --write" 75 | ] 76 | }, 77 | "husky": { 78 | "hooks": { 79 | "pre-commit": "lint-staged" 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { h, cloneElement, render, hydrate } from 'preact'; 2 | 3 | /** 4 | * @typedef {import('preact').FunctionComponentHello world!
22 | * } 23 | * } 24 | * 25 | * register(PreactComponent); 26 | * 27 | * // use a preact component 28 | * function PreactComponent({ prop }) { 29 | * returnHello {prop}!
30 | * } 31 | * 32 | * register(PreactComponent, 'my-component'); 33 | * register(PreactComponent, 'my-component', ['prop']); 34 | * register(PreactComponent, 'my-component', ['prop'], { 35 | * shadow: true, 36 | * mode: 'closed' 37 | * }); 38 | * ``` 39 | */ 40 | export default function register(Component, tagName, propNames, options) { 41 | function PreactElement() { 42 | const inst = /** @type {PreactCustomElement} */ ( 43 | Reflect.construct(HTMLElement, [], PreactElement) 44 | ); 45 | inst._vdomComponent = Component; 46 | inst._root = 47 | options && options.shadow 48 | ? inst.attachShadow({ mode: options.mode || 'open' }) 49 | : inst; 50 | return inst; 51 | } 52 | PreactElement.prototype = Object.create(HTMLElement.prototype); 53 | PreactElement.prototype.constructor = PreactElement; 54 | PreactElement.prototype.connectedCallback = connectedCallback; 55 | PreactElement.prototype.attributeChangedCallback = attributeChangedCallback; 56 | PreactElement.prototype.disconnectedCallback = disconnectedCallback; 57 | 58 | /** 59 | * @type {string[]} 60 | */ 61 | propNames = 62 | propNames || 63 | Component.observedAttributes || 64 | Object.keys(Component.propTypes || {}); 65 | PreactElement.observedAttributes = propNames; 66 | 67 | // Keep DOM properties and Preact props in sync 68 | propNames.forEach((name) => { 69 | Object.defineProperty(PreactElement.prototype, name, { 70 | get() { 71 | return this._vdom.props[name]; 72 | }, 73 | set(v) { 74 | if (this._vdom) { 75 | this.attributeChangedCallback(name, null, v); 76 | } else { 77 | if (!this._props) this._props = {}; 78 | this._props[name] = v; 79 | this.connectedCallback(); 80 | } 81 | 82 | // Reflect property changes to attributes if the value is a primitive 83 | const type = typeof v; 84 | if ( 85 | v == null || 86 | type === 'string' || 87 | type === 'boolean' || 88 | type === 'number' 89 | ) { 90 | this.setAttribute(name, v); 91 | } 92 | }, 93 | }); 94 | }); 95 | 96 | return customElements.define( 97 | tagName || Component.tagName || Component.displayName || Component.name, 98 | PreactElement 99 | ); 100 | } 101 | 102 | function ContextProvider(props) { 103 | this.getChildContext = () => props.context; 104 | // eslint-disable-next-line no-unused-vars 105 | const { context, children, ...rest } = props; 106 | return cloneElement(children, rest); 107 | } 108 | 109 | /** 110 | * @this {PreactCustomElement} 111 | */ 112 | function connectedCallback() { 113 | // Obtain a reference to the previous context by pinging the nearest 114 | // higher up node that was rendered with Preact. If one Preact component 115 | // higher up receives our ping, it will set the `detail` property of 116 | // our custom event. This works because events are dispatched 117 | // synchronously. 118 | const event = new CustomEvent('_preact', { 119 | detail: {}, 120 | bubbles: true, 121 | cancelable: true, 122 | }); 123 | this.dispatchEvent(event); 124 | const context = event.detail.context; 125 | 126 | this._vdom = h( 127 | ContextProvider, 128 | { ...this._props, context }, 129 | toVdom(this, this._vdomComponent) 130 | ); 131 | (this.hasAttribute('hydrate') ? hydrate : render)(this._vdom, this._root); 132 | } 133 | 134 | /** 135 | * Camel-cases a string 136 | * @param {string} str The string to transform to camelCase 137 | * @returns camel case version of the string 138 | */ 139 | function toCamelCase(str) { 140 | return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : '')); 141 | } 142 | 143 | /** 144 | * Changed whenver an attribute of the HTML element changed 145 | * @this {PreactCustomElement} 146 | * @param {string} name The attribute name 147 | * @param {unknown} oldValue The old value or undefined 148 | * @param {unknown} newValue The new value 149 | */ 150 | function attributeChangedCallback(name, oldValue, newValue) { 151 | if (!this._vdom) return; 152 | // Attributes use `null` as an empty value whereas `undefined` is more 153 | // common in pure JS components, especially with default parameters. 154 | // When calling `node.removeAttribute()` we'll receive `null` as the new 155 | // value. See issue #50. 156 | newValue = newValue == null ? undefined : newValue; 157 | const props = {}; 158 | props[name] = newValue; 159 | props[toCamelCase(name)] = newValue; 160 | this._vdom = cloneElement(this._vdom, props); 161 | render(this._vdom, this._root); 162 | } 163 | 164 | /** 165 | * @this {PreactCustomElement} 166 | */ 167 | function disconnectedCallback() { 168 | render((this._vdom = null), this._root); 169 | } 170 | 171 | /** 172 | * 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 | it('renders element in shadow dom open mode', async () => { 250 | function ShadowDomOpen() { 251 | return