vanjs-reactive-element
Version:
A reactive custom element base class for VanJS with any reactivity system
275 lines (274 loc) • 10.1 kB
TypeScript
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;