vanjs-reactive-element
Version:
A reactive custom element base class for VanJS with any reactivity system
837 lines (670 loc) • 26.1 kB
Markdown
# Van Reactive Element
A lightweight web components library that seamlessly integrates [VanJS](https://vanjs.org/) with custom elements. Build reactive web components with minimal boilerplate and maximum flexibility.
## Features
- **Seamless VanJS Integration** - Use VanJS state and reactivity within web components
- **Two Paradigms** - Choose between class or functional component styles inspired by [Lit](https://lit.dev) and [Solid Element](https://github.com/solidjs/solid/tree/main/packages/solid-element)
- **Built-in Styling** - Scoped CSS support with shadow DOM encapsulation and adopted stylesheets
- **Reactive by Design** - Automatic UI updates when state changes
- **Attribute Syncing** - Automatic attribute to property conversion with type coercion
- **Property Reflection** - Optionally reflect property changes back to attributes
- **Developer Friendly** - TypeScript support, comprehensive lifecycle hooks
## Installation
```bash
npm install vanjs-reactive-element vanjs-core
```
## Quick Links
- [Examples](#examples) - Todo list from [VanJS](https://vanjs.org/demo#todo-list) implemented in both class or functional component styles
- [API Reference](#api) - Complete API documentation
- [Reactivity Patterns](#reactivity-patterns) - Learn about rendering patterns
- [Attributes & Properties](#attributes-and-properties) - Property configuration and type conversion
- [Slots & Content](#slots-and-content-distribution) - Content distribution with slots
## Usage
### Examples
#### Counter Example - Class Component
A simple counter component demonstrating basic state management:
[Try on CodePen](https://codepen.io/VoidedClouds/pen/jEPKwbL)
```javascript
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van });
class CounterElement extends VanReactiveElement {
static properties = {
count: { attribute: false, default: 0 }
};
static get styles() {
return css`
button {
margin: 0 5px;
}
`;
}
render() {
const { button, p } = van.tags;
return [
p('Count: ', this.count),
button({ onclick: () => this.count.val-- }, 'Decrement'),
button({ onclick: () => this.count.val++ }, 'Increment')
];
}
}
// Register the custom element
CounterElement.define();
// Use it
van.add(document.body, van.tags['counter-element']());
```
#### Counter Example - Functional Component
The same counter using the functional approach:
[Try on CodePen](https://codepen.io/VoidedClouds/pen/myJKwVJ)
```javascript
const CounterElement = define(
'counter-element',
{
properties: {
count: 0
},
styles: css`
button {
margin: 0 5px;
}
`
},
(element, { noShadowDOM, onCleanup, onMount }) => {
return () => {
const { button, p } = van.tags;
return [
p('Count: ', element.count),
button({ onclick: () => element.count.val-- }, 'Decrement'),
button({ onclick: () => element.count.val++ }, 'Increment')
];
};
}
);
// Use it
van.add(document.body, van.tags['counter-element']());
```
#### Todo List Example - Class Component
A more complex example implementing todo functionality from [VanJS](https://vanjs.org/demo#todo-list):
[Try on CodePen](https://codepen.io/VoidedClouds/pen/pvJRBME)
```javascript
// Extract VanReactiveElement and css helper from vanRE
const { VanReactiveElement, css, define } = vanRE({ van });
// Define a custom element <todo-list> by extending VanReactiveElement
class TodoList extends VanReactiveElement {
// Props with default values and types
static properties = {
title: { default: 'Todo List', type: String },
titleAttributePostfix: 'Will be replaced',
titlePropertyPostfix: { attribute: false, default: 'Will also be replaced' }
};
// Define component styles
static get styles() {
return css`
a {
cursor: pointer;
}
`;
}
// Factory method to create a reactive todo item
createTodoItem(text) {
const done = van.state(false); // Track if the todo is done
const deleted = van.state(false); // Track if the todo is deleted
const { div, input, span, del, a } = van.tags;
// Return a function that reactively renders the todo item
return () =>
deleted.val
? null // If deleted, render nothing
: div(
{ class: 'todo-item' },
input({
type: 'checkbox',
checked: done,
onclick: (e) => (done.val = e.target.checked) // Toggle done state
}),
() => (done.val ? del : span)(text), // Strike-through if done
a({ onclick: () => (deleted.val = true) }, '❌') // Delete button
);
}
// Render method for the main todo list UI
render() {
const { div, h2, input, button } = van.tags;
const inputDom = input({ type: 'text' }); // Input for new todo text
const count = van.state(0); // Track number of add clicks
const derived = van.derive(() => count.val * 2); // Derived state
// Main DOM structure
const dom = div(
h2(this.title, ' - Attribute: ', this.titleAttributePostfix, ' - Property: ', this.titlePropertyPostfix),
div('Add Click Count: ', count, ' * 2 = ', derived),
inputDom,
button(
{
onclick: () => (
count.val++, // Increment count
van.add(dom, this.createTodoItem(inputDom.value)) // Add new todo item
)
},
'Add'
)
);
return dom;
}
}
// Register the custom element as <todo-list>
TodoList.define();
// State for the property postfix, updates every second
const titlePropertyPostfix = van.state(new Date().toLocaleTimeString());
// Create the todo-list element and set its attribute postfix
const todoList = van.tags['todo-list']({
'title-attribute-postfix': 'Attribute Postfix',
titlePropertyPostfix
});
// Update postfixes every second
const intervalId = setInterval(() => {
const timeString = new Date().toLocaleTimeString();
titlePropertyPostfix.val = timeString; // Update property postfix
todoList.setAttribute('title-attribute-postfix', timeString); // Update attribute postfix
}, 1000);
// Mount the todo list to the document body
van.add(document.body, todoList);
```
> **Tip:** You can always use the native [`customElements.define`](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define) method to register your component classes if you prefer. The provided `define` method is a convenience, but not required.
#### Todo List Example - Functional Component
[Try on CodePen](https://codepen.io/VoidedClouds/pen/JodEQBG)
```javascript
// Define a custom element "todo-list"
const TodoList = define(
'todo-list',
{
// Attributes (reactive, read-only, settable via setProperty/setProperties)
attributes: {
title: { type: String, default: 'Todo List' },
titleAttributePostfix: { type: String, default: 'Will be replaced' }
},
// Properties (reactive, read-write directly, settable via setProperty/setProperties)
properties: {
titlePropertyPostfix: 'Will also be replaced'
},
// Component styles
styles: css`
a {
cursor: pointer;
}
`
},
(element, { noShadowDOM, onCleanup, onMount }) => {
// Factory for creating a todo item component
const createTodoItem = (text) => {
const done = van.state(false); // Track if todo is done
const deleted = van.state(false); // Track if todo is deleted
const { div, input, span, del, a } = van.tags;
// Return a function that renders the todo item reactively
return () =>
deleted.val
? null // If deleted, render nothing
: div(
{ class: 'todo-item' },
input({
type: 'checkbox',
checked: done,
onclick: (e) => (done.val = e.target.checked)
}),
() => (done.val ? del : span)(text), // Strike-through if done
a({ onclick: () => (deleted.val = true) }, '❌') // Delete button
);
};
// Return the render function for the todo list
return () => {
const { div, h2, input, button } = van.tags;
const inputDom = input({ type: 'text' }); // Input for new todos
const count = van.state(0); // Count of add clicks
const derived = van.derive(() => count.val * 2); // Derived state
// Main DOM structure
const dom = div(
h2(element.title, ' - Attribute: ', element.titleAttributePostfix, ' - Property: ', element.titlePropertyPostfix),
div('Add Click Count: ', count, ' * 2 = ', derived),
inputDom,
button(
{
onclick: () => (
count.val++, // Increment count
van.add(dom, createTodoItem(inputDom.value)) // Add new todo item
)
},
'Add'
)
);
return dom;
};
}
);
// Note: The `define` function returns a custom element class, which can be subclassed or registered manually if needed.
// State for the property postfix, updates every second
const titlePropertyPostfix = van.state(new Date().toLocaleTimeString());
// Create the todo-list element, set attribute postfix
const todoList = van.tags['todo-list']({
'title-attribute-postfix': 'Attribute Postfix',
titlePropertyPostfix
});
// Update postfixes every second
const intervalId = setInterval(() => {
const timeString = new Date().toLocaleTimeString();
titlePropertyPostfix.val = timeString; // Update property postfix
todoList.setAttribute('title-attribute-postfix', timeString); // Update attribute postfix
}, 1000);
// Mount the todo list to the document body
van.add(document.body, todoList);
```
**Note:** When using the `define` function, you must provide the full tag name (including a hyphen) as required by the web components spec.
## API
### `vanRE(options)`
Initialize the VanJS Reactive Element library.
**Parameters:**
- `options.rxScope` - Optional, reactive scope function for managing component lifecycle (typically `effectScope` from your reactivity library)
- `options.van` - Required, VanJS instance with required `add` and `state` methods
**Returns:**
- `VanReactiveElement` - Base class for creating class components
- `css` - Tagged template literal for component styles
- `define` - Function for creating functional components
### `VanReactiveElement` Class
Base class for creating web components with VanJS integration.
#### Static Properties
- `properties` - Component property definitions with configuration:
- `attribute` - Enable attribute binding (true by default) or specify custom attribute name
- `converter` - Custom converter with `fromAttribute` and `toAttribute` methods
- `default` - Default property value
- `reflect` - Reflect property changes back to attributes
- `type` - Property type (String, Number, Boolean, Object, Array) for automatic conversion
- `shadowRootOptions` - Shadow root creation options (default: `{ mode: 'open' }`)
- `styles` - Component CSS styles (scoped to shadow DOM). Use `css` tagged template literal or CSSStyleSheet for adopted stylesheets
#### Instance Methods
- `createRenderRoot()` - Create the render root (override to return `this` for light DOM)
- `dispatchCustomEvent(name, options)` - Dispatch a custom event
- `hasShadowDOM()` - Check if the component uses shadow DOM
- `onCleanup()` - Called when the component is disconnected from DOM
- `onMount()` - Called after the component connects to DOM
- `query(selector)` - Query single element within render root
- `queryAll(selector)` - Query all elements within render root
- `registerDisposer(fn)` - Register a cleanup function
- `render()` - Define the component's content. Returns either:
- Content directly with state objects for automatic reactivity: `div(this.myState)`
- A function returning content for computed values or when using `.val`: `() => display.val ? div(this.myState) : ''`
- `setProperty(name, value)` - Set a single property value (works for both attributes and properties)
- `setProperties(properties)` - Set multiple properties at once using an object (works for both attributes and properties)
#### Static Methods
- `define(name?)` - Register the component as a custom element
Defines a custom element using the provided name. The name must include a hyphen (per custom elements spec).
If no `name` is provided, the class name of the component will be used to generate the custom element name by converting from PascalCase/camelCase to kebab-case.
### `define(customElementName, options, setup)`
Create a functional component.
**Parameters:**
- `customElementName` - Custom element name (must include a hyphen)
- `options` - Component configuration object:
- `attributes` - Attribute property definitions (become StateView - read-only)
- `properties` - Internal property definitions (become State - read-write)
- `styles` - Component styles (use `css` tagged template literal or CSSStyleSheet)
- `shadowRootOptions` - Shadow root configuration (default: `{ mode: 'open' }`)
- `setup` - Setup function called once per instance that returns the render function
**Returns:**
A custom element class, which can be subclassed or registered manually if needed.
**Setup Function Parameters:**
- `element` - The custom element instance with typed reactive properties:
- **Attributes** (read-only): `element.attributeName` is a StateView
- Reading: `element.attributeName.val` or direct binding `element.attributeName` in templates
- Setting: Use `element.setProperty('attributeName', 'new value')` and `element.setProperties(properties)`
- **Properties** (read-write): `element.propertyName` is a State
- Reading: `element.propertyName.val` or direct binding `element.propertyName` in templates
- Setting: `element.propertyName.val = { new: 'value' }` to update the State value or `element.setProperty('attributeName', 'new value')` and `element.setProperties(properties)` to update the State reference or value
- **Universal setters**: `element.setProperty('name', value)` and `element.setProperties(properties)` work for both attributes and properties
- `context` - Component context object:
- `noShadowDOM()` - Disable shadow DOM
- `onCleanup(fn)` - Set cleanup callback
- `onMount(fn)` - Set mount callback
**Setup Function Returns:**
The setup function must return a render function that defines the component's content, or nothing (void) if no rendering is needed.
### Important Notes
1. **Reactivity**: Properties are not reactive by default. Use `van.state()` or `van.derive()` for reactive properties.
2. **Element Naming**: You must provide the full custom element name (with hyphen) when calling `define`.
3. **Property Binding**: Properties with attribute binding use `van.state` internally for reactivity.
4. **Element-based API in Functional Components**:
- Attributes are read-only (`StateView`) - use `element.setProperty('attrName', value)` and `element.setProperties(properties)` to update
- Internal properties are read-write (`State`) - can be updated directly with `element.propName.val = value`
#### Example: Element API Usage
```javascript
define(
'my-component',
{
attributes: {
// These become StateView (read-only)
name: { type: String, default: 'World' },
count: { type: Number, default: 0 }
},
properties: {
// These become State (read-write)
data: { foo: 'bar' },
items: []
}
},
(element) => {
// Reading attributes (StateView)
console.log(element.name.val); // "World"
// Setting attributes via setProperties method
element.setProperties({ count: 42, name: 'Hello' });
// Reading/writing properties
element.data.val = { foo: 'updated' }; // ✅ Updates value properly
element.items.val.push('new item'); // ✅ Modifying array contents
// Using setProperty method (works for both attributes and properties)
element.setProperty('data', { foo: 'via setter' }); // ✅ Type-safe value update
// Return render function
return () => div('Name: ', element.name, ' Count: ', element.count, ' Data: ', () => JSON.stringify(element.data.val));
}
);
```
### Reactivity Patterns
VanJS Reactive Element supports two patterns for reactive rendering:
1. **Direct State Binding** (Recommended for simple cases):
```javascript
render() {
const {div} = van.tags;
// Pass derive/state objects directly - VanJS handles reactivity
return div('Count: ', this.count);
}
```
2. **Function-based Rendering** (Required for computed values or `.val` access):
```javascript
render() {
const {div} = van.tags;
// Return a function when using computed values or .val
return () => div(
div('Count: ', this.count),
div('Doubled: ', () => this.count.val * 2)
);
}
```
Use direct derive/state binding when possible for cleaner code. Use function-based rendering when you need to access `.val` use computed properties that aren't van.derive states.
#### Example: When to Use Each Pattern
```javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class MyComponent extends VanReactiveElement {
// State
count = van.state(0);
multiplier = van.state(2);
// Computed getter (not a van.derive state)
get doubled() {
return this.count.val * 2;
}
render() {
const { div } = van.tags;
// ❌ Won't be reactive - getter is called once
// return div('Doubled: ', this.doubled);
// ✅ Use van.derive state or function wrapper for computed values
return div('Doubled: ', () => this.doubled);
}
}
```
### Adopted Stylesheets
VanJS Reactive Element supports modern CSS features like adopted stylesheets for better performance:
```javascript
class MyComponent extends VanReactiveElement {
// Create a reusable stylesheet
static stylesheet = new CSSStyleSheet();
static {
// Populate the stylesheet
this.stylesheet.replaceSync(`
:host {
display: block;
padding: 20px;
}
.title {
color: #333;
font-size: 24px;
}
`);
}
// Return the stylesheet instead of a string
static get styles() {
return this.stylesheet;
}
render() {
const { h1 } = van.tags;
return h1({ class: 'title' }, 'Hello World');
}
}
```
Adopted stylesheets are more efficient for components that are used multiple times, as the browser can share the same stylesheet across all instances.
### Attributes and Properties
VanJS Reactive Element provides automatic attribute to property syncing with type conversion:
#### Property Configuration
```javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class MyElement extends VanReactiveElement {
static properties = {
// Simple property with default value
name: { default: 'Anonymous' },
// Property with type converter
count: {
type: Number,
default: 0
},
// Property with custom attribute name
isActive: {
type: Boolean,
default: false,
attribute: 'active' // Maps to 'active' attribute instead of 'is-active'
},
// Property that reflects changes back to attribute
status: {
type: String,
default: 'pending',
reflect: true
},
// Property with custom converter
data: {
type: Object,
converter: {
fromAttribute: (value) => (value ? JSON.parse(value) : null),
toAttribute: (value) => (value ? JSON.stringify(value) : null)
}
},
// Property without attribute binding
internal: {
default: null,
attribute: false
}
};
}
```
#### Built-in Type Converters
- **String**: Pass-through (default)
- **Number**: Converts to/from numeric values
- **Boolean**: Presence = true, absence = false
- **Object/Array**: JSON serialization
#### Usage Example
```javascript
// HTML
<my-element name="John Doe" count="42" active data='{"role": "admin"}'></my-element>;
// JavaScript
const element = document.querySelector('my-element');
// Properties are automatically synced from attributes
console.log(element.name); // "John Doe"
console.log(element.count); // 42 (number)
console.log(element.isActive); // true (boolean)
console.log(element.data); // { role: "admin" } (object)
// Update properties
element.count = 100;
element.status = 'completed';
// Reflected properties update attributes
console.log(element.getAttribute('status')); // "completed"
```
#### Functional Component Attributes
```javascript
const ToggleButton = define(
'toggle-button',
{
attributes: {
pressed: {
type: Boolean,
default: false,
reflect: true,
attribute: 'aria-pressed' // Property with custom ARIA attribute name
},
label: {
type: String,
default: 'Toggle'
}
}
},
(element, {}) => {
return () => {
const { button } = van.tags;
return button(
{
'aria-pressed': element.pressed,
onclick: () => element.setProperty('pressed', !element.pressed.val)
},
element.label
);
};
}
);
```
### Slots and Content Distribution
VanJS Reactive Element fully supports Web Components slots for content distribution.
Note: Use native slot APIs such as `slot.assignedNodes()` and `slot.assignedElements()` for slot management:
#### Shadow DOM Slots
```javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class CardComponent extends VanReactiveElement {
static get styles() {
return css`
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.header {
background: #f5f5f5;
padding: 16px;
font-weight: bold;
}
.body {
padding: 16px;
}
.footer {
padding: 16px;
border-top: 1px solid #eee;
}
`;
}
render() {
const { div, slot } = van.tags;
return div(
{ class: 'card' },
div({ class: 'header' }, slot({ name: 'header' }, 'Default Header')),
div({ class: 'body' }, slot('Default Content')),
div({ class: 'footer' }, slot({ name: 'footer' }))
);
}
}
// Usage
<card-component>
<h2 slot="header">Card Title</h2>
<p>This is the card content</p>
<p>Multiple elements can go in the default slot</p>
<button slot="footer">Action</button>
</card-component>;
```
#### Working with Slots
```javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class SlotAwareComponent extends VanReactiveElement {
onMount() {
// Access slots directly using standard Web Component APIs
const slots = this.renderRoot.querySelectorAll('slot');
slots.forEach((slot) => {
// Get assigned nodes for each slot
const assignedNodes = slot.assignedNodes();
const assignedElements = slot.assignedElements();
// Listen for slot changes
slot.addEventListener('slotchange', (e) => {
const newNodes = e.target.assignedNodes();
console.log(`Slot "${slot.name || 'default'}" changed:`, newNodes);
});
});
// Check specific slot content
const footerSlot = this.renderRoot.querySelector('slot[name="footer"]');
if (footerSlot && footerSlot.assignedNodes().length === 0) {
console.log('No footer content provided');
}
}
}
```
#### Light DOM Content Organization
For components using light DOM (no shadow DOM), override `createRenderRoot()` to return `this`:
```javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class LightDomLayout extends VanReactiveElement {
createRenderRoot() {
return this; // Use light DOM
}
render() {
const { div, header, main, footer } = van.tags;
return div({ class: 'layout' }, header({ class: 'header-area' }), main({ class: 'content-area' }), footer({ class: 'footer-area' }));
}
onMount() {
// Organize children by slot attribute
Array.from(this.children).forEach((child) => {
const slot = child.getAttribute('slot');
if (slot === 'header') {
this.querySelector('.header-area')?.appendChild(child);
} else if (slot === 'footer') {
this.querySelector('.footer-area')?.appendChild(child);
} else {
this.querySelector('.content-area')?.appendChild(child);
}
});
}
}
```
#### Conditional Slot Rendering
```javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class ConditionalSlots extends VanReactiveElement {
hasIcon = van.state(false);
render() {
const { div, span, slot } = van.tags;
return () =>
div(
{ class: 'button-wrapper' },
this.hasIcon.val && span({ class: 'icon' }, slot({ name: 'icon' })),
span({ class: 'label' }, slot('Button'))
);
}
onMount() {
// Check if icon slot has content
const iconSlot = this.renderRoot.querySelector('slot[name="icon"]');
if (iconSlot) {
this.hasIcon.val = iconSlot.assignedNodes().length > 0;
}
}
}
```
## License
MIT License