vanjs-reactive-element
Version:
A reactive custom element base class for VanJS with any reactivity system
441 lines (437 loc) • 16.1 kB
JavaScript
// src/property-utils.ts
var defaultConverter = {
toAttribute: (value) => value,
fromAttribute: (value) => value
};
var booleanConverter = {
toAttribute: (value) => value ? "" : null,
// Reflect presence/absence
fromAttribute: (value) => value !== null
// Attribute presence means true
};
var numberConverter = {
toAttribute: (value) => value === null ? null : String(value),
fromAttribute: (value) => value === null ? null : Number(value)
};
var stringConverter = {
toAttribute: (value) => value === null ? null : String(value),
fromAttribute: (value) => value === null ? null : String(value)
};
var objectConverter = {
toAttribute: (value) => value == null ? null : JSON.stringify(value),
fromAttribute: (value) => {
if (value == null) return null;
if (typeof value === "object") return value;
try {
return JSON.parse(value);
} catch (e) {
console.error(`Error parsing attribute value as JSON: ${value}`, e);
return value;
}
}
};
var arrayConverter = objectConverter;
var converters = {
"String": stringConverter,
"Number": numberConverter,
"Boolean": booleanConverter,
"Object": objectConverter,
"Array": arrayConverter
};
var camelAndPascalToKebab = (str) => str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
// src/syntax-utils.ts
var reduce = ([strings, ...values]) => {
return strings.reduce((result, str, i) => result + str + (values[i] || ""), "");
};
var css = (...args) => {
return reduce(args);
};
// src/index.ts
var vanRE = (options) => {
const { rxScope = (fn) => (fn?.(), () => {
}), van } = options;
const { defineProperty, entries, fromEntries, getPrototypeOf } = Object;
const isValueState = (value) => {
return value && typeof value === "object" && "val" in value;
};
class VanReactiveElementClass extends HTMLElement {
/** @protected */
renderRoot = null;
// Initialize renderRoot
/** @private */
_disposers = /* @__PURE__ */ new Set();
// Store cleanup functions
/** @private */
_hasSetupProperties = false;
// Ensure properties are initialized only once
/** @private */
_isSetupComplete = false;
// Track if initial setup has run
/** @private */
_reflectingProperty = null;
static properties = {};
/** @private */
_attributeToPropertyMap = /* @__PURE__ */ new Map();
/** @private */
_propertyToAttributeMap = /* @__PURE__ */ new Map();
/**
* Defines options for the shadow root. Override in subclasses.
* Defaults to `{ mode: 'open' }`.
* @returns {ShadowRootInit}
*/
static get shadowRootOptions() {
return { mode: "open" };
}
/**
* Defines component-specific styles. Override in subclasses.
* Should return a CSS string or CSSStyleSheet.
* @returns {string | CSSStyleSheet | null}
*/
static get styles() {
return null;
}
// --- Constructor & Initialization ---
constructor() {
super();
this.registerDisposer(
rxScope(() => {
this._setupProperties();
})
);
}
// --- Native Lifecycle Callbacks ---
static get observedAttributes() {
const attributes = [];
const { properties } = this;
if (!properties) return attributes;
for (const [propName, options2] of entries(properties)) {
if (!options2) continue;
if (options2.attribute === false) continue;
const attrName = typeof options2.attribute === "string" ? options2.attribute : camelAndPascalToKebab(propName);
attributes.push(attrName);
}
return attributes;
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
const propName = this._attributeToPropertyMap.get(name);
if (!propName) return;
if (this._reflectingProperty === propName) return;
const ctor = this.constructor;
const options2 = ctor.properties[propName];
if (!options2) return;
const converter = options2.converter || options2.type && converters[options2.type.name] || defaultConverter;
const value = converter.fromAttribute(newValue);
this[propName] = value;
}
connectedCallback() {
this.renderRoot ??= this.createRenderRoot();
this._disposers ??= /* @__PURE__ */ new Set();
this._setupStyles();
if (!this._isSetupComplete) {
this.registerDisposer(
rxScope(() => {
if (this.renderRoot && this.render) {
van.add(this.renderRoot, this.render());
}
this._isSetupComplete = true;
requestAnimationFrame(() => {
this.isConnected && this._isSetupComplete && this.onMount?.();
});
})
);
}
}
disconnectedCallback() {
if (this.isConnected) return;
this._disposers.forEach((disposer) => {
try {
disposer();
} catch (error) {
console.error("Error disposing effect:", error);
}
});
this._disposers.clear();
this._isSetupComplete = false;
try {
this.onCleanup?.();
} catch (error) {
console.error("Error running user-defined cleanup:", error);
}
}
// --- Custom Lifecycle Callbacks ---
/**
* Creates the root node where the component's content will be rendered.
* By default, creates and returns an open shadow root.
* Override to customize shadow root options or render to light DOM (by returning `this`).
* @returns {ShadowRoot | HTMLElement} The node to render into.
* @protected
*/
createRenderRoot() {
return this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);
}
/**
* Called after the component's disconnected.
* @protected
*/
onCleanup() {
}
/**
* Called after the component's initial setup and DOM creation.
* @protected
*/
onMount() {
}
// --- Automatic Cleanup ---
/**
* Registers a cleanup function for automatic cleanup when the component is disconnected.
* This method adds the given function to the component's cleanup routine.
*
* @param {Function} disposer - A function to call when the component is disconnected.
* @returns {Function} The disposer that was passed in, for chaining.
*
* @example
* this.registerDisposer(() => {
* // Clean up resources
* });
*/
registerDisposer(disposer) {
if (typeof disposer === "function") {
this._disposers.add(disposer);
} else {
console.warn("[VanReactiveElement.registerDisposer] The disposer must be a function.", disposer);
}
return disposer;
}
// --- Instance methods ---
/**
* Checks if the component is using shadow DOM.
* @returns {boolean} True if the component has a shadow DOM, false if using light DOM.
*/
hasShadowDOM() {
return this.renderRoot !== this && this.renderRoot instanceof ShadowRoot;
}
/**
* Sets the properties of the current instance.
* @param {Record<string, unknown>} properties - An object containing the properties to set.
* @returns {VanReactiveElementClass} - The current instance for chaining.
*/
setProperties(properties) {
return Object.assign(this, properties);
}
/**
* Sets a single property on the current instance.
* This is a universal setter that assigns the given value to the specified property.
*
* @param {string} property - The name of the property to set.
* @param {unknown} value - The value to assign to the property.
* @returns {unknown} The value assigned.
*/
setProperty(property, value) {
return this[property] = value;
}
// --- Selector Methods ---
/**
* Returns the first element matching the selector within the component's shadow DOM.
* @param {string} selector CSS selector.
* @returns {Element | null}
*/
query(selector) {
return this.renderRoot?.querySelector(selector) ?? null;
}
/**
* Returns a NodeList of all elements matching the selector within the component's shadow DOM.
* @param {string} selector CSS selector.
* @returns {NodeListOf<Element>} An empty NodeList if renderRoot doesn't exist.
*/
queryAll(selector) {
return this.renderRoot?.querySelectorAll(selector) ?? document.createDocumentFragment().querySelectorAll("*");
}
// --- Event Methods ---
/**
* Dispatches a custom event from the element.
* @param {string} typeName The name of the event.
* @param {CustomEventInit} options Event options (detail, bubbles, composed, cancelable).
* Defaults: bubbles=false, composed=true for shadow DOM/false for light DOM, cancelable=false.
* @returns {boolean} False if event is cancelable and preventDefault() was called, true otherwise.
*/
dispatchCustomEvent(typeName, options2 = {}) {
const event = new CustomEvent(typeName, {
bubbles: false,
composed: this.hasShadowDOM(),
// Only compose (bubble out of shadow DOM) if we have shadow DOM
cancelable: false,
...options2
// User options override defaults
});
return this.dispatchEvent(event);
}
// --- Define methods ---
/**
* Defines a custom element based on its class if no parameters are provided,
* generating the custom element name by converting the class from PascalCase/camelCase to kebab-case.
* Example: A class named `MyButton` will be defined as `my-button`.
* @param {string} [name] Optional name of the custom element.
* @returns {VanReactiveElement} The class itself for chaining.
*/
static define(name) {
const classToDefine = this;
let tagName = name || classToDefine?.name;
if (!tagName) {
console.error("[VanReactiveElement] Cannot define custom element: Tag name is missing or invalid.", classToDefine);
return classToDefine;
}
tagName = `${name ?? camelAndPascalToKebab(tagName)}`;
try {
if (customElements.get(tagName)) {
if (true) {
console.warn(`[VanReactiveElement] Custom element "${tagName}" is already defined.`);
}
return classToDefine;
}
customElements.define(tagName, classToDefine);
} catch (error) {
console.error(`[VanReactiveElement] Failed to define custom element "${tagName}":`, error);
}
return classToDefine;
}
// --- Private methods ---
/** @private */
_setupProperties() {
if (this._hasSetupProperties) return;
const ctor = this.constructor;
const { properties } = ctor;
if (!properties) return;
for (const property in properties) {
const userOptions = properties[property] || {};
const defaultValue = userOptions.default;
if (userOptions.attribute === false) {
let propValue = isValueState(defaultValue) ? defaultValue : van.state(defaultValue);
defineProperty(getPrototypeOf(this), property, {
get() {
return propValue;
},
// Direct reference via closure
set(newValue) {
isValueState(newValue) ? propValue = newValue : propValue.val = newValue;
},
configurable: true,
enumerable: true
});
} else {
const attrName = typeof userOptions.attribute === "string" ? userOptions.attribute : camelAndPascalToKebab(property);
this._attributeToPropertyMap.set(attrName, property);
this._propertyToAttributeMap.set(property, attrName);
const propertyState = van.state(defaultValue);
defineProperty(this, property, {
get() {
return propertyState;
},
// Return the state object itself via closure
set(newValue) {
if (propertyState.rawVal === newValue) return;
propertyState.val = newValue;
if (userOptions.reflect) {
const attrName2 = this._propertyToAttributeMap.get(property);
if (attrName2) {
this._reflectingProperty = property;
const converter = userOptions.converter || userOptions.type && converters[userOptions.type.name] || defaultConverter;
const attrValue = converter.toAttribute(newValue);
if (attrValue === null) {
this.removeAttribute(attrName2);
} else {
this.setAttribute(attrName2, attrValue);
}
this._reflectingProperty = null;
}
}
},
configurable: true,
enumerable: true
});
if (attrName && this.hasAttribute(attrName)) {
const attrValue = this.getAttribute(attrName);
const converter = userOptions.converter || userOptions.type && converters[userOptions.type.name] || defaultConverter;
this[property] = converter.fromAttribute(attrValue);
}
}
}
this._hasSetupProperties = true;
}
/** @private */
_setupStyles() {
if (!this.renderRoot) return;
const ctor = this.constructor;
const styles = ctor.styles;
if (!styles) return;
if (this.renderRoot instanceof ShadowRoot) {
if (styles instanceof CSSStyleSheet) {
const adoptedSheets = this.renderRoot.adoptedStyleSheets || [];
if (!adoptedSheets.includes(styles)) {
this.renderRoot.adoptedStyleSheets = [...adoptedSheets, styles];
}
return;
}
}
if (!this.renderRoot.querySelector("style[vre\\:style]")) {
if (typeof styles === "string") {
const styleEl = document.createElement("style");
styleEl.setAttribute("vre:style", "");
styleEl.textContent = styles;
this.renderRoot.prepend(styleEl);
}
}
}
}
return {
VanReactiveElement: VanReactiveElementClass,
css,
/**
* Defines a custom element with consolidated options.
* @param customElementName - The custom element name (e.g., 'my-component')
* @param options - Configuration object with attributes, properties, shadowRootOptions, and styles
* @param setup - Setup function called once per element instance that returns the render function
*/
define: (customElementName, options2, setup) => {
const { attributes = {}, properties = {}, shadowRootOptions, styles } = options2;
class FunctionalElement extends VanReactiveElementClass {
static properties = {
...attributes,
...fromEntries(entries(properties).map(([key, value]) => [key, { default: value, attribute: false }]))
};
static get shadowRootOptions() {
return shadowRootOptions || super.shadowRootOptions;
}
static get styles() {
return styles || super.styles;
}
constructor() {
super();
this.registerDisposer(
rxScope(() => {
this.render = setup(this, {
noShadowDOM: () => {
if (!this.renderRoot) {
return this.createRenderRoot = () => this;
}
{
console.warn("noShadowDOM() called after renderRoot was created. It will be ignored.");
}
},
onCleanup: (fn) => {
this.onCleanup = fn;
},
onMount: (fn) => {
this.onMount = fn;
}
});
})
);
}
}
return FunctionalElement.define(customElementName);
}
};
};
var src_default = vanRE;
export { src_default as default };