├── .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 | 26 | ``` 27 | 28 | Output: 29 | 30 | ```html 31 |

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 | // 44 | class Greeting extends Component { 45 | // Register as : 46 | static tagName = 'x-greeting'; 47 | 48 | // Track these attributes: 49 | static observedAttributes = ['name']; 50 | 51 | render({ name }) { 52 | return

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 `` that "forwards" the current 111 | * context value to the rendered child. The child will trigger a custom 112 | * event, where will add the context value to. Because events work 113 | * synchronously, the child can immediately pull of the value right 114 | * after having fired the event. 115 | */ 116 | function Slot(props, context) { 117 | const ref = (r) => { 118 | if (!r) { 119 | this.ref.removeEventListener('_preact', this._listener); 120 | } else { 121 | this.ref = r; 122 | if (!this._listener) { 123 | this._listener = (event) => { 124 | event.stopPropagation(); 125 | event.detail.context = context; 126 | }; 127 | r.addEventListener('_preact', this._listener); 128 | } 129 | } 130 | }; 131 | return h('slot', { ...props, ref }); 132 | } 133 | 134 | function toVdom(element, nodeName) { 135 | if (element.nodeType === 3) return element.data; 136 | if (element.nodeType !== 1) return null; 137 | let children = [], 138 | props = {}, 139 | i = 0, 140 | a = element.attributes, 141 | cn = element.childNodes; 142 | for (i = a.length; i--; ) { 143 | if (a[i].name !== 'slot') { 144 | props[a[i].name] = a[i].value; 145 | props[toCamelCase(a[i].name)] = a[i].value; 146 | } 147 | } 148 | 149 | for (i = cn.length; i--; ) { 150 | const vnode = toVdom(cn[i], null); 151 | // Move slots correctly 152 | const name = cn[i].slot; 153 | if (name) { 154 | props[name] = h(Slot, { name }, vnode); 155 | } else { 156 | children[i] = vnode; 157 | } 158 | } 159 | 160 | // Only wrap the topmost node with a slot 161 | const wrappedChildren = nodeName ? h(Slot, null, children) : children; 162 | return h(nodeName || element.nodeName.toLowerCase(), props, wrappedChildren); 163 | } 164 | -------------------------------------------------------------------------------- /src/index.test.jsx: -------------------------------------------------------------------------------- 1 | import { assert } from '@open-wc/testing'; 2 | import { h, createContext } from 'preact'; 3 | import { useContext } from 'preact/hooks'; 4 | import { act } from 'preact/test-utils'; 5 | import registerElement from './index'; 6 | 7 | describe('web components', () => { 8 | /** @type {HTMLDivElement} */ 9 | let root; 10 | 11 | beforeEach(() => { 12 | root = document.createElement('div'); 13 | document.body.appendChild(root); 14 | }); 15 | 16 | afterEach(() => { 17 | document.body.removeChild(root); 18 | }); 19 | 20 | function Clock({ time }) { 21 | return {time}; 22 | } 23 | 24 | registerElement(Clock, 'x-clock', ['time', 'custom-date']); 25 | 26 | it('renders ok, updates on attr change', () => { 27 | const el = document.createElement('x-clock'); 28 | el.setAttribute('time', '10:28:57 PM'); 29 | 30 | root.appendChild(el); 31 | assert.equal( 32 | root.innerHTML, 33 | '10:28:57 PM' 34 | ); 35 | 36 | el.setAttribute('time', '11:01:10 AM'); 37 | assert.equal( 38 | root.innerHTML, 39 | '11:01:10 AM' 40 | ); 41 | }); 42 | 43 | function NullProps({ size = 'md' }) { 44 | return
{size.toUpperCase()}
; 45 | } 46 | 47 | registerElement(NullProps, 'x-null-props', ['size'], { shadow: true }); 48 | 49 | // #50 50 | it('remove attributes without crashing', () => { 51 | const el = document.createElement('x-null-props'); 52 | assert.doesNotThrow(() => (el.size = 'foo')); 53 | root.appendChild(el); 54 | 55 | assert.doesNotThrow(() => el.removeAttribute('size')); 56 | }); 57 | 58 | describe('DOM properties', () => { 59 | it('passes property changes to props', () => { 60 | const el = document.createElement('x-clock'); 61 | 62 | el.time = '10:28:57 PM'; 63 | assert.equal(el.time, '10:28:57 PM'); 64 | 65 | root.appendChild(el); 66 | assert.equal( 67 | root.innerHTML, 68 | '10:28:57 PM' 69 | ); 70 | 71 | el.time = '11:01:10 AM'; 72 | assert.equal(el.time, '11:01:10 AM'); 73 | 74 | assert.equal( 75 | root.innerHTML, 76 | '11:01:10 AM' 77 | ); 78 | }); 79 | 80 | function DummyButton({ onClick, text = 'click' }) { 81 | return ; 82 | } 83 | 84 | registerElement(DummyButton, 'x-dummy-button', ['onClick', 'text']); 85 | 86 | it('passes simple properties changes to props', () => { 87 | const el = document.createElement('x-dummy-button'); 88 | 89 | el.text = 'foo'; 90 | assert.equal(el.text, 'foo'); 91 | 92 | root.appendChild(el); 93 | assert.equal( 94 | root.innerHTML, 95 | '' 96 | ); 97 | 98 | // Update 99 | el.text = 'bar'; 100 | assert.equal( 101 | root.innerHTML, 102 | '' 103 | ); 104 | }); 105 | 106 | it('passes complex properties changes to props', () => { 107 | const el = document.createElement('x-dummy-button'); 108 | 109 | let clicks = 0; 110 | const onClick = () => clicks++; 111 | el.onClick = onClick; 112 | assert.equal(el.onClick, onClick); 113 | 114 | root.appendChild(el); 115 | assert.equal( 116 | root.innerHTML, 117 | '' 118 | ); 119 | 120 | act(() => { 121 | el.querySelector('button').click(); 122 | }); 123 | assert.equal(clicks, 1); 124 | 125 | // Update 126 | let other = 0; 127 | el.onClick = () => other++; 128 | act(() => { 129 | el.querySelector('button').click(); 130 | }); 131 | assert.equal(other, 1); 132 | }); 133 | }); 134 | 135 | function Foo({ text, children }) { 136 | return ( 137 | 138 |
{children}
139 |
{text}
140 |
141 | ); 142 | } 143 | 144 | registerElement(Foo, 'x-foo', [], { shadow: true }); 145 | 146 | it('renders slots as props with shadow DOM', () => { 147 | const el = document.createElement('x-foo'); 148 | 149 | // here is a slot 150 | const slot = document.createElement('span'); 151 | slot.textContent = 'here is a slot'; 152 | slot.slot = 'text'; 153 | el.appendChild(slot); 154 | 155 | //
no slot
156 | const noSlot = document.createElement('div'); 157 | noSlot.textContent = 'no slot'; 158 | el.appendChild(noSlot); 159 | el.appendChild(slot); 160 | 161 | root.appendChild(el); 162 | assert.equal( 163 | root.innerHTML, 164 | '
no slot
here is a slot
' 165 | ); 166 | 167 | const shadowHTML = document.querySelector('x-foo').shadowRoot.innerHTML; 168 | assert.equal( 169 | shadowHTML, 170 | '
no slot
here is a slot
' 171 | ); 172 | }); 173 | 174 | const kebabName = 'custom-date-long-name'; 175 | const camelName = 'customDateLongName'; 176 | const lowerName = camelName.toLowerCase(); 177 | function PropNameTransform(props) { 178 | return ( 179 | 180 | {props[kebabName]} {props[lowerName]} {props[camelName]} 181 | 182 | ); 183 | } 184 | registerElement(PropNameTransform, 'x-prop-name-transform', [ 185 | kebabName, 186 | camelName, 187 | ]); 188 | 189 | it('handles kebab-case attributes with passthrough', () => { 190 | const el = document.createElement('x-prop-name-transform'); 191 | el.setAttribute(kebabName, '11/11/2011'); 192 | el.setAttribute(camelName, 'pretended to be camel'); 193 | 194 | root.appendChild(el); 195 | assert.equal( 196 | root.innerHTML, 197 | `11/11/2011 pretended to be camel 11/11/2011` 198 | ); 199 | 200 | el.setAttribute(kebabName, '01/01/2001'); 201 | assert.equal( 202 | root.innerHTML, 203 | `01/01/2001 pretended to be camel 01/01/2001` 204 | ); 205 | }); 206 | 207 | const Theme = createContext('light'); 208 | 209 | function DisplayTheme() { 210 | const theme = useContext(Theme); 211 | return

Active theme: {theme}

; 212 | } 213 | 214 | registerElement(DisplayTheme, 'x-display-theme', [], { shadow: true }); 215 | 216 | function Parent({ children, theme = 'dark' }) { 217 | return ( 218 | 219 |
{children}
220 |
221 | ); 222 | } 223 | 224 | registerElement(Parent, 'x-parent', ['theme'], { shadow: true }); 225 | 226 | it('passes context over custom element boundaries', async () => { 227 | const el = document.createElement('x-parent'); 228 | 229 | const noSlot = document.createElement('x-display-theme'); 230 | el.appendChild(noSlot); 231 | 232 | root.appendChild(el); 233 | assert.equal( 234 | root.innerHTML, 235 | '' 236 | ); 237 | 238 | const getShadowHTML = () => 239 | document.querySelector('x-display-theme').shadowRoot.innerHTML; 240 | assert.equal(getShadowHTML(), '

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 | --------------------------------------------------------------------------------