preact-custom-element
Version:
Wrap your component up as a custom element
222 lines (198 loc) • 6.02 kB
JavaScript
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);
}