A base class for enhancing DOM elements with reactive state, similar to Alpine.js and petite-vue, but without loops and conditionals.
npm install @tmbr/componentDefine state inline with a data-state attribute.
<div data-state="{count: 0}">
<button type="button" @click="count--">Remove</button>
<span :text="count"></span>
<button type="button" @click="count++">Add</button>
</div>document.querySelectorAll('[data-state]').forEach(el => {
new Component(el);
});Subclass Component to define reusable components with DOM refs, methods, and lifecycle hooks.
<div id="counter">
<button type="button" @click="dec" :disabled="count <= 0">Remove</button>
<span :text="count"></span>
<button type="button" @click="inc">Add</button>
<span :show="count >= 10">Count is dangerously high</span>
</div>class Counter extends Component {
static state = {
count: 0
};
init() {
console.log('mounted', this.el);
}
inc() {
this.state.count++;
}
dec() {
this.state.count--;
}
update(state) {
console.log('updated', state.count);
}
}
new Counter('#counter');State is deeply reactive so mutations to nested objects and arrays trigger a render.
class Form extends Component {
static state = {
fields: {name: '', email: ''},
errors: {}
};
}<input type="text" :model="fields.name" />
<span :text="errors.name"></span>Directives are expressions evaluated with the current state.
| Directive | Effect |
|---|---|
:text |
sets textContent |
:html |
sets innerHTML |
:show |
toggles display: none |
:value |
sets .value on inputs (one-way) |
:model |
two-way binding for form elements |
:class |
merges classes from a string, array, or {name: bool} object |
:disabled, :hidden, etc. |
boolean attributes — set when truthy, removed when falsy |
:attribute |
setAttribute fallback for any other attribute |
Events are attached with @event.modifier="handler" where handler is an expression or component method.
| Modifier | Effect |
|---|---|
.prevent |
calls preventDefault() |
.stop |
calls stopPropagation() |
.self |
only fires when event.target is the current element |
.once |
auto-removes after first invocation |
.passive |
passive event listener |
.capture |
capture phase listener |
.outside |
fires on events outside the element |
.window |
listens to window |
.document |
listens to document |
Nested components can read and write ancestor state via scope:
<div data-state="{type: null}">
<div data-state="{open: false}" @click.outside="open = false">
<button type="button" aria-controls="example" :aria-expanded="open" @click="open = !open">
<span :text="scope.type ?? 'Select Type'"></span>
</button>
<menu id="example">
<li><button type="button" @click="scope.type = 'standard'">Standard</button></li>
<li><button type="button" @click="scope.type = 'delux'">Delux</button></li>
</menu>
</div>
</div>Parent components must be instantiated before their children. When a parent's state changes, any child that has accessed scope is automatically re-rendered.
Elements with a ref attribute are collected into this.dom. Multiple elements with the same name are grouped into an array. Use bracket syntax ref="[items]" to force an array even with a single element.
<div>
<input ref="input" />
<ul>
<li ref="[items]">one</li>
<li ref="[items]">two</li>
</ul>
</div>Getters on the subclass expose derived values that are accessible in template expressions and the update() hook.
class Example extends Component {
static state = {
name: 'Jane Doe'
};
get firstName() {
return this.state.name.split(' ')[0];
}
}<input type="text" :model="name" />
First name is <span :text="firstName"></span>this.dom.input // input
this.dom.items // [li, li]| Property or Method | Description |
|---|---|
el |
root element |
dom |
child elements collected from ref attributes |
props |
parsed from data-props attribute |
state |
reactive proxy where changes trigger a rerender |
findOne(selector) |
scoped querySelector |
findAll(selector) |
scoped querySelectorAll |
init() |
called after constructor — override in subclass |
update(state) |
called after each render — override in subclass |
on(event, target, fn) |
delegate event listener, cleaned up on .destroy() |
dispatch(type, detail, options) |
dispatches a CustomEvent from .el |
destroy() |
removes all listeners and directives |