UNPKG

vanjs-reactive-element

Version:

A reactive custom element base class for VanJS with any reactivity system

441 lines (437 loc) 16.1 kB
// 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 };