diff --git a/src/index.js b/src/index.js index 42318d7..6d5f3f0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,26 +1,39 @@ -import { h, cloneElement, render, hydrate } from 'preact'; +import { h, cloneElement, render, hydrate, Fragment } from 'preact'; +// This function is used to register a component with the given tag name. export default function register(Component, tagName, propNames, options) { + // Create an instance of PreactElement, which extends HTMLElement. function PreactElement() { const inst = Reflect.construct(HTMLElement, [], PreactElement); inst._vdomComponent = Component; + + // If options for shadow DOM are given, attach a shadow root. + // Otherwise, assign the instance itself as root. inst._root = options && options.shadow ? inst.attachShadow({ mode: 'open' }) : inst; + return inst; } + + // Extend the PreactElement from HTMLElement. PreactElement.prototype = Object.create(HTMLElement.prototype); PreactElement.prototype.constructor = PreactElement; - PreactElement.prototype.connectedCallback = connectedCallback; + + // Assign lifecycle methods to the PreactElement. + PreactElement.prototype.connectedCallback = function () { + connectedCallback.call(this, options); + }; PreactElement.prototype.attributeChangedCallback = attributeChangedCallback; PreactElement.prototype.disconnectedCallback = disconnectedCallback; + // Assign observed attributes. propNames = propNames || Component.observedAttributes || Object.keys(Component.propTypes || {}); PreactElement.observedAttributes = propNames; - // Keep DOM properties and Preact props in sync + // Sync DOM properties and Preact props. propNames.forEach((name) => { Object.defineProperty(PreactElement.prototype, name, { get() { @@ -35,7 +48,7 @@ export default function register(Component, tagName, propNames, options) { this.connectedCallback(); } - // Reflect property changes to attributes if the value is a primitive + // Reflect property changes to attributes if the value is a primitive. const type = typeof v; if ( v == null || @@ -49,12 +62,16 @@ export default function register(Component, tagName, propNames, options) { }); }); + // Define the custom element. return customElements.define( tagName || Component.tagName || Component.displayName || Component.name, PreactElement ); } +// The rest of the functions are utility functions used within the register function. + +// This function provides the context for child components. function ContextProvider(props) { this.getChildContext = () => props.context; // eslint-disable-next-line no-unused-vars @@ -62,7 +79,8 @@ function ContextProvider(props) { return cloneElement(children, rest); } -function connectedCallback() { +// This function is called when the custom element is inserted into the DOM +function connectedCallback(options) { // Obtain a reference to the previous context by pinging the nearest // higher up node that was rendered with Preact. If one Preact component // higher up receives our ping, it will set the `detail` property of @@ -79,7 +97,7 @@ function connectedCallback() { this._vdom = h( ContextProvider, { ...this._props, context }, - toVdom(this, this._vdomComponent) + toVdom(this, this._vdomComponent, options) ); (this.hasAttribute('hydrate') ? hydrate : render)(this._vdom, this._root); } @@ -113,6 +131,7 @@ function disconnectedCallback() { * synchronously, the child can immediately pull of the value right * after having fired the event. */ +// This function provides a slot for context propagation. function Slot(props, context) { const ref = (r) => { if (!r) { @@ -131,7 +150,27 @@ function Slot(props, context) { return h('slot', { ...props, ref }); } -function toVdom(element, nodeName) { +// This function provides a pseudo-slot for context propagation without shadow dom. +function PseudoSlot(props, context) { + const ref = (r) => { + if (!r) { + this.ref.removeEventListener('_preact', this._listener); + } else { + this.ref = r; + if (!this._listener) { + this._listener = (event) => { + event.stopPropagation(); + event.detail.context = context; + }; + r.addEventListener('_preact', this._listener); + } + } + }; + return h(Fragment, { ...props, ref }); +} + +// This function converts DOM elements to virtual DOM. +function toVdom(element, nodeName, options) { if (element.nodeType === 3) return element.data; if (element.nodeType !== 1) return null; let children = [], @@ -147,7 +186,7 @@ function toVdom(element, nodeName) { } for (i = cn.length; i--; ) { - const vnode = toVdom(cn[i], null); + const vnode = toVdom(cn[i], null, options); // Move slots correctly const name = cn[i].slot; if (name) { @@ -158,6 +197,14 @@ function toVdom(element, nodeName) { } // Only wrap the topmost node with a slot - const wrappedChildren = nodeName ? h(Slot, null, children) : children; + + const wrappedChildren = nodeName + ? h(options && options.shadow === false ? PseudoSlot : Slot, null, children) + : children; + + // Remove all children from the topmost node in non-shadow mode + if (options && options.shadow === false && nodeName) { + element.innerHTML = ''; + } return h(nodeName || element.nodeName.toLowerCase(), props, wrappedChildren); } diff --git a/src/index.test.jsx b/src/index.test.jsx index 00da646..e362ad1 100644 --- a/src/index.test.jsx +++ b/src/index.test.jsx @@ -1,5 +1,5 @@ import { assert } from '@open-wc/testing'; -import { h, createContext } from 'preact'; +import { h, createContext, Fragment } from 'preact'; import { useContext } from 'preact/hooks'; import { act } from 'preact/test-utils'; import registerElement from './index'; @@ -245,4 +245,58 @@ describe('web components', () => { }); assert.equal(getShadowHTML(), '

Active theme: sunny

'); }); + + it('renders my-foo with child element correctly', () => { + function FooComponent(props) { + return ( + +

My Heading

+
{props.children}
+
+ ); + } + + registerElement(FooComponent, 'my-foo', [], { shadow: false }); + + const el = document.createElement('my-foo'); + + const specialElement = document.createElement( + 'some-special-custom-element' + ); + specialElement.textContent = 'Lorem doFoo'; + el.appendChild(specialElement); + + root.appendChild(el); + assert.equal( + root.innerHTML, + '

My Heading

Lorem doFoo
' + ); + }); + + it('renders my-foo with child element in shadow dom with slot', () => { + function FooComponent(props) { + return ( + +

My Heading

+
{props.children}
+
+ ); + } + + registerElement(FooComponent, 'my-foo-shadow', [], { shadow: true }); + + const el = document.createElement('my-foo-shadow'); + + const specialElement = document.createElement( + 'some-special-custom-element' + ); + specialElement.textContent = 'Lorem doFoo'; + el.appendChild(specialElement); + + root.appendChild(el); + assert.equal( + el.shadowRoot.innerHTML, + '

My Heading

Lorem doFoo
' + ); + }); });