UNPKG

preact-custom-element

Version:
222 lines (198 loc) 6.02 kB
import { h, cloneElement, render, hydrate, Fragment } from 'preact'; /** * @typedef {import('./internal.d.ts').PreactCustomElement} PreactCustomElement */ /** * @type {import('./index.d.ts')} */ export default function register(Component, tagName, propNames, options) { function PreactElement() { const inst = /** @type {PreactCustomElement} */ ( Reflect.construct(HTMLElement, [], PreactElement) ); inst._vdomComponent = Component; if (options && options.shadow) { inst._root = inst.attachShadow({ mode: options.mode || 'open', serializable: options.serializable ?? false, }); if (options.adoptedStyleSheets) { inst._root.adoptedStyleSheets = options.adoptedStyleSheets; } } else { inst._root = inst; } return inst; } PreactElement.prototype = Object.create(HTMLElement.prototype); PreactElement.prototype.constructor = PreactElement; PreactElement.prototype.connectedCallback = function () { connectedCallback.call(this, options); }; PreactElement.prototype.attributeChangedCallback = attributeChangedCallback; PreactElement.prototype.disconnectedCallback = disconnectedCallback; /** * @type {string[]} */ propNames = propNames || Component.observedAttributes || Object.keys(Component.propTypes || {}); PreactElement.observedAttributes = propNames; if (Component.formAssociated) { PreactElement.formAssociated = true; } // Keep DOM properties and Preact props in sync propNames.forEach((name) => { Object.defineProperty(PreactElement.prototype, name, { get() { return this._vdom ? this._vdom.props[name] : this._props[name]; }, set(v) { if (this._vdom) { this.attributeChangedCallback(name, null, v); } else { if (!this._props) this._props = {}; this._props[name] = v; } // Reflect property changes to attributes if the value is a primitive const type = typeof v; if ( v == null || type === 'string' || type === 'boolean' || type === 'number' ) { this.setAttribute(name, v); } }, }); }); customElements.define( tagName || Component.tagName || Component.displayName || Component.name, PreactElement ); return PreactElement; } function ContextProvider(props) { this.getChildContext = () => props.context; // eslint-disable-next-line no-unused-vars const { context, children, ...rest } = props; return cloneElement(children, rest); } /** * @this {PreactCustomElement} */ 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 // our custom event. This works because events are dispatched // synchronously. const event = new CustomEvent('_preact', { detail: {}, bubbles: true, cancelable: true, }); this.dispatchEvent(event); const context = event.detail.context; this._vdom = h( ContextProvider, { ...this._props, context }, toVdom(this, this._vdomComponent, options) ); (this.hasAttribute('hydrate') ? hydrate : render)(this._vdom, this._root); } /** * Camel-cases a string * @param {string} str The string to transform to camelCase * @returns camel case version of the string */ function toCamelCase(str) { return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : '')); } /** * Changed whenver an attribute of the HTML element changed * @this {PreactCustomElement} * @param {string} name The attribute name * @param {unknown} oldValue The old value or undefined * @param {unknown} newValue The new value */ function attributeChangedCallback(name, oldValue, newValue) { if (!this._vdom) return; // Attributes use `null` as an empty value whereas `undefined` is more // common in pure JS components, especially with default parameters. // When calling `node.removeAttribute()` we'll receive `null` as the new // value. See issue #50. newValue = newValue == null ? undefined : newValue; const props = {}; props[name] = newValue; props[toCamelCase(name)] = newValue; this._vdom = cloneElement(this._vdom, props); render(this._vdom, this._root); } /** * @this {PreactCustomElement} */ function disconnectedCallback() { render((this._vdom = null), this._root); } /** * Pass an event listener to each `<slot>` that "forwards" the current * context value to the rendered child. The child will trigger a custom * event, where will add the context value to. Because events work * synchronously, the child can immediately pull of the value right * after having fired the event. */ function Slot(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); } } }; const { useFragment, ...rest } = props; return h(useFragment ? Fragment : 'slot', { ...rest, ref }); } function toVdom(element, nodeName, options) { if (element.nodeType === 3) return element.data; if (element.nodeType !== 1) return null; let children = [], props = {}, i = 0, a = element.attributes, cn = element.childNodes; for (i = a.length; i--; ) { if (a[i].name !== 'slot') { props[a[i].name] = a[i].value; props[toCamelCase(a[i].name)] = a[i].value; } } for (i = cn.length; i--; ) { const vnode = toVdom(cn[i], null, options); // Move slots correctly const name = cn[i].slot; if (name) { props[name] = h(Slot, { name }, vnode); } else { children[i] = vnode; } } const shadow = !!(options && options.shadow); // Only wrap the topmost node with a slot const wrappedChildren = nodeName ? h(Slot, { useFragment: !shadow }, children) : children; if (!shadow && nodeName) { element.innerHTML = ''; } return h(nodeName || element.nodeName.toLowerCase(), props, wrappedChildren); }