UNPKG

vanjs-reactive-element

Version:

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

275 lines (274 loc) 10.1 kB
import type { ChildDom, State, StateView, Van } from 'vanjs-core'; import { type AttributeConverter } from './property-utils'; export type { AttributeConverter } from './property-utils'; export type { ChildDom, State, StateView, Van }; /** * Configuration options for VanJS Reactive Element * @example * const vanRE = createVanRE({ * van: { add: van.add, state: van.state }, * rxScope: van.derive // Optional: custom reactive scope * }) */ export interface VanREOptions { /** Reactive scope function for managing component lifecycle */ rxScope?: (fn: () => void | (() => void)) => () => void; /** VanJS instance with required methods */ van: { add: Van['add']; state: Van['state']; }; } /** * Type Usage Quick Reference: * * 1. Class Components: * ```typescript * class MyElement extends VanReactiveElement { * declare name: State<string>; * declare count: State<number>; * declare data: State<{ foo: string }>; // All properties are reactive * * static properties = { * name: { type: String, default: 'World' }, * count: { type: Number, default: 0 }, * data: { attribute: false, default: { foo: 'bar' } } * }; * } * ``` * * 2. Functional Components with options object: * ```typescript * define('my-component', { * // Attributes (all get StateView - read-only) * // Must specify type for proper serialization * attributes: { * name: { type: String, default: 'John' }, * count: { type: Number, default: 0 }, * active: { type: Boolean, reflect: true }, * label: { type: String } // No default is fine * }, * * // Internal properties (all get State - read-write) * // MUST be simple values, not PropertyOptions * properties: { * data: { foo: 'bar' }, // Object literal * cache: [], // Empty array * loading: false, // Boolean * message: '' // Empty string * }, * * // Optional styles * styles: ` * :host { display: block; } * button { padding: 8px; } * ` * }, (element) => { * // Attribute properties are StateView (read-only) * const name = element.name.val; // ✓ Can read * // element.name.val = 'Jane'; // ✗ Error: readonly * * // Internal properties are State (read-write) * element.data.val = { foo: 'new' }; // ✓ Can modify * element.cache.val.push('item'); // ✓ Direct mutation * element.loading.val = true; // ✓ Direct assignment * * // Setting properties using setProperty method * element.setProperty('name', 'Jane'); // ✓ Attributes * element.setProperty('data', { foo: 'bar' }); // ✓ Properties * * // Return the render function * return () => button( * { onclick: () => element.setProperty('count', element.count.val + 1) }, * element.label, ': ', element.count * ); * }); * * // With custom shadow root options and styles * define('closed-component', { * attributes: { name: { type: String, default: 'Shadow' } }, * properties: { internal: 'state' }, * shadowRootOptions: { mode: 'closed', delegatesFocus: true }, * styles: `:host { display: inline-block; }` * }, (element) => { * return () => span(element.name); * }); * ``` * * 3. Direct State assignment in class components: * ```typescript * class MyElement extends VanReactiveElement { * static properties = { count: 0 }; * * increment() { * // Runtime accepts both State<T> and plain values * this.count = van.state(100); // Assign State directly * this.count = 50; // Assign plain value * this.count.val = 75; // Modify via .val * } * } * ``` */ export type PropertyType = StringConstructor | NumberConstructor | BooleanConstructor | ObjectConstructor | ArrayConstructor | Function; /** * Property configuration options * @template T - The type of the property value * * Type inference priority: * 1. Explicit type parameter in PropertyOptions<T> * 2. Default value type * 3. Type property (String, Number, Boolean, Object, Array) * * @example * // Type inferred from default value * count: { type: Number, default: 0 } // → State<number> * * @example * // Type inferred from 'type' property when no default * isActive: { * type: Boolean, * attribute: 'data-active', * reflect: true * } // → State<boolean> * * @example * // Simple type-only definition * enabled: { type: Boolean } // → State<boolean> * * @example * // Explicit type takes precedence * internal: { type: Object, attribute: false } as PropertyOptions<User> // → State<User> */ export interface PropertyOptions<T = unknown> { /** Enable attribute binding. true = kebab-case, false = disabled, string = custom name */ attribute?: boolean | string; /** Custom converter for attribute serialization/deserialization */ converter?: AttributeConverter<T>; /** Default value for the property */ default?: T; /** Reflect property changes back to attributes */ reflect?: boolean; /** Type hint for automatic conversion (String, Number, Boolean, Object, Array) */ type?: PropertyType; } /** * Context object provided to functional component setup * @template E - The element type * @example * define('my-component', { * attributes: { label: { type: String, default: 'Click me' } }, * properties: { count: 0 }, * styles: ` * :host { display: block; } * button { padding: 8px; } * ` * }, (element, ctx) => { * ctx.onMount(() => console.log('Component mounted')); * ctx.onCleanup(() => console.log('Component cleanup')); * * // Return the render function * return () => button({ * onclick: () => element.setProperty('count', element.count.val + 1) * }, element.label, ': ', element.count); * }) */ export interface SetupContext { /** Disable shadow DOM (use light DOM instead) */ noShadowDOM: () => void; /** Register cleanup function called on disconnect */ onCleanup: (fn: () => void) => void; /** Register mount function called after initial render */ onMount: (fn: () => void) => void; } /** * Property definitions for attributes. * Must use PropertyOptions with at least a type specified. * * @example * { * // Type is required for proper serialization * name: { type: String }, // No default * count: { type: Number, default: 0 }, // With default * active: { type: Boolean, reflect: true }, // With reflection * label: { type: String, attribute: 'aria-label' } // Custom attribute name * } */ export type PropertyDefinitions = { [key: string]: PropertyOptions; }; /** * State definitions are simple key-value pairs */ export type StateDefinitions = Record<string, any>; /** * Options for defining a custom element */ export interface DefineOptions<A extends PropertyDefinitions = PropertyDefinitions, S extends StateDefinitions = StateDefinitions> { /** Properties that sync with DOM attributes (become StateView) */ attributes?: A; /** Internal properties on the instance (become State) */ properties?: S; /** Optional shadow root configuration */ shadowRootOptions?: ShadowRootInit; /** Component styles (scoped to shadow DOM) */ styles?: string | CSSStyleSheet; } export type DefineFunction = <A extends PropertyDefinitions, S extends StateDefinitions>(customElementName: string, options: DefineOptions<A, S>, setup: (element: TypedElementInstance<A, S>, context: SetupContext) => (() => unknown) | void) => VanReactiveElementConstructor; export interface VanRE { VanReactiveElement: VanReactiveElementConstructor; css: (template: TemplateStringsArray, ...values: any[]) => string; define: DefineFunction; } /** * Instance interface for VanReactiveElement */ export interface VanReactiveElement extends HTMLElement { renderRoot: ShadowRoot | HTMLElement | null; dispatchCustomEvent(typeName: string, options?: CustomEventInit): boolean; hasShadowDOM(): boolean; query(selector: string): Element | null; queryAll(selector: string): NodeListOf<Element>; registerDisposer(disposer: () => void): () => void; setProperties(properties: Record<string, unknown>): VanReactiveElement; setProperty(property: string, value: unknown): unknown; } /** * Constructor interface for VanReactiveElement */ export interface VanReactiveElementConstructor { new (): VanReactiveElement; readonly properties: Record<string, PropertyOptions>; readonly shadowRootOptions: ShadowRootInit; readonly styles: string | CSSStyleSheet | null; define(name?: string): any; } /** * Infers type from a property type constructor */ type InferFromType<T> = T extends StringConstructor ? string : T extends NumberConstructor ? number : T extends BooleanConstructor ? boolean : T extends ObjectConstructor ? object : T extends ArrayConstructor ? unknown[] : T extends new (...args: any[]) => infer R ? R : unknown; /** * Infers attribute properties as StateView (read-only) * Handles PropertyOptions with default values, type hints, etc. */ export type InferredAttributeProperties<T> = { [K in keyof T]: T[K] extends { default: infer D; } ? StateView<D> : T[K] extends { type: infer Type; } ? StateView<InferFromType<Type>> : T[K] extends PropertyOptions<infer U> ? StateView<U> : StateView<T[K]>; }; /** * Infers state properties as State (read-write) * State definitions are always simple values, not PropertyOptions */ export type InferredStateProperties<T> = { [K in keyof T]: State<T[K]>; }; /** * Combined type for element instance with reactive properties. * This represents the element passed to the setup function, with all * attributes and properties typed appropriately. */ export type TypedElementInstance<A extends PropertyDefinitions, S extends StateDefinitions> = VanReactiveElement & InferredAttributeProperties<A> & InferredStateProperties<S>; declare const vanRE: (options: VanREOptions) => VanRE; export default vanRE;