├── .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 | 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 | -------------------------------------------------------------------------------- /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').FunctionComponent | import('preact').ComponentClass | import('preact').FunctionalComponent } ComponentDefinition 5 | * @typedef {{ shadow: false } | { shadow: true, mode: 'open' | 'closed'}} Options 6 | * @typedef {HTMLElement & { _root: ShadowRoot | HTMLElement, _vdomComponent: ComponentDefinition, _vdom: ReturnType | null }} PreactCustomElement 7 | */ 8 | 9 | /** 10 | * Register a preact component as web-component. 11 | * @param {ComponentDefinition} Component The preact component to register 12 | * @param {string} [tagName] The HTML element tag-name (must contain a hyphen and be lowercase) 13 | * @param {string[]} [propNames] HTML element attributes to observe 14 | * @param {Options} [options] Additional element options 15 | * @example 16 | * ```ts 17 | * // use custom web-component class 18 | * class PreactWebComponent extends Component { 19 | * static tagName = 'my-web-component'; 20 | * render() { 21 | * return

Hello world!

22 | * } 23 | * } 24 | * 25 | * register(PreactComponent); 26 | * 27 | * // use a preact component 28 | * function PreactComponent({ prop }) { 29 | * return

Hello {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 `` that "forwards" the current 173 | * context value to the rendered child. The child will trigger a custom 174 | * event, where will add the context value to. Because events work 175 | * synchronously, the child can immediately pull of the value right 176 | * after having fired the event. 177 | */ 178 | function Slot(props, context) { 179 | const ref = (r) => { 180 | if (!r) { 181 | this.ref.removeEventListener('_preact', this._listener); 182 | } else { 183 | this.ref = r; 184 | if (!this._listener) { 185 | this._listener = (event) => { 186 | event.stopPropagation(); 187 | event.detail.context = context; 188 | }; 189 | r.addEventListener('_preact', this._listener); 190 | } 191 | } 192 | }; 193 | return h('slot', { ...props, ref }); 194 | } 195 | 196 | function toVdom(element, nodeName) { 197 | if (element.nodeType === 3) return element.data; 198 | if (element.nodeType !== 1) return null; 199 | let children = [], 200 | props = {}, 201 | i = 0, 202 | a = element.attributes, 203 | cn = element.childNodes; 204 | for (i = a.length; i--; ) { 205 | if (a[i].name !== 'slot') { 206 | props[a[i].name] = a[i].value; 207 | props[toCamelCase(a[i].name)] = a[i].value; 208 | } 209 | } 210 | 211 | for (i = cn.length; i--; ) { 212 | const vnode = toVdom(cn[i], null); 213 | // Move slots correctly 214 | const name = cn[i].slot; 215 | if (name) { 216 | props[name] = h(Slot, { name }, vnode); 217 | } else { 218 | children[i] = vnode; 219 | } 220 | } 221 | 222 | // Only wrap the topmost node with a slot 223 | const wrappedChildren = nodeName ? h(Slot, null, children) : children; 224 | return h(nodeName || element.nodeName.toLowerCase(), props, wrappedChildren); 225 | } 226 | -------------------------------------------------------------------------------- /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 | it('renders element in shadow dom open mode', async () => { 250 | function ShadowDomOpen() { 251 | return
Shadow DOM Open
; 252 | } 253 | 254 | registerElement(ShadowDomOpen, 'x-shadowdom-open', [], { 255 | shadow: true, 256 | mode: 'open', 257 | }); 258 | 259 | const el = document.createElement('x-shadowdom-open'); 260 | root.appendChild(el); 261 | const shadowRoot = el.shadowRoot; 262 | assert.isTrue(!!shadowRoot); 263 | const child = shadowRoot.querySelector('.shadow-child'); 264 | assert.isTrue(!!child); 265 | assert.equal(child.textContent, 'Shadow DOM Open'); 266 | }); 267 | 268 | it('renders element in shadow dom closed mode', async () => { 269 | function ShadowDomClosed() { 270 | return
Shadow DOM Closed
; 271 | } 272 | 273 | registerElement(ShadowDomClosed, 'x-shadowdom-closed', [], { 274 | shadow: true, 275 | mode: 'closed', 276 | }); 277 | 278 | const el = document.createElement('x-shadowdom-closed'); 279 | root.appendChild(el); 280 | assert.isTrue(el.shadowRoot === null); 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------