Documentation
Directives
Reusable DOM behavior attached declaratively via HTML attributes.
Directives encapsulate DOM behavior in a self-contained class, then activate it declaratively from HTML — just by adding an attribute. No querying the DOM by hand, no scattered event listeners, no framework buy-in.
Three primitives make up the whole system:
@directive(attributeName)— a class decorator that registers your class against an HTML attribute name.Directive— the abstract base class every directive extends.bootstrapDirectives(root?)— scans a DOM subtree and instantiates the registered class for every matching element. Idempotent andWeakMap-tracked, so it's safe to call repeatedly or on dynamically inserted content.
Quick start
import {directive, Directive} from '@alwatr/flux';
@directive('tooltip')
export class TooltipDirective extends Directive {
protected override init_(): void {
// this.attributeValue → the value of the 'tooltip' attribute
// this.element_ → the bound HTMLElement
// this.logger_ → scoped logger: "directive:tooltip/0"
this.element_.title = this.attributeValue;
this.on_('mouseenter', this.show_);
}
private show_(): void {
/* ... */
}
}<button tooltip="Save your changes">Save</button>Import the module once (its side effect is registration) and call bootstrapDirectives() at app start:
import {bootstrapDirectives} from '@alwatr/flux';
import './directives/tooltip.js'; // registers the directive
bootstrapDirectives();Lifecycle
All hooks are optional and run after a macrotask, so the DOM has always settled by the time they fire:
| Hook | Fires |
|---|---|
init_() | Once, immediately on connect. |
lazyInit_() | Once, the first time the element enters the viewport. |
onVisible_() | Every time the element enters the viewport. |
onHidden_() | Every time the element leaves the viewport. |
Visibility hooks
lazyInit_() falls back to requestIdleCallback, then setTimeout(100ms), if IntersectionObserver is unavailable. onVisible_() falls back to a single immediate call; onHidden_() has no fallback and is simply never called without IntersectionObserver.onVisible_/onHidden_ share one observer — override intersectionOptions_ before init_() completes to customize root/rootMargin/threshold:
@directive('lazy-image')
class LazyImageDirective extends Directive {
protected override intersectionOptions_: IntersectionObserverInit = {
rootMargin: '200px 0px', // pre-load 200px before entering the viewport
};
protected override async lazyInit_(): Promise<void> {
const img = this.element_.querySelector('img')!;
img.src = img.dataset['src']!;
await img.decode();
}
}Auto-cleanup
this.on_(eventType, listener, target?)— registers a DOM listener on the element (or a selector/element you pass) and removes it automatically on destroy. This is the sanctioned place for directaddEventListenercalls — page authors dispatch through the action bus; directives are where scoped, element-local listeners live.this.subscribe_(signal, callback)— subscribes to a signal and unsubscribes automatically on destroy.this.addDestroyHook(fn)— registers any other teardown task.autoDestroy()destroys the instance if its element has been disconnected from the DOM — call it periodically (e.g. from aMutationObserver) viaautoDestructDirectives().
@directive('cart-badge')
class CartBadgeDirective extends Directive {
protected override init_(): void {
this.subscribe_(cartSignal, (cart) => {
this.element_.textContent = String(cart.items.length);
});
}
}The reactive update cycle
Call this.requestUpdate() to schedule a batched re-render for the next macrotask — repeated calls within the same cycle collapse into one:
requestUpdate()
└─ (next macrotask)
├─ shouldUpdate_() ← return false to abort here
├─ update_() ← DOM mutations
└─ updated_() ← post-render hookMost directives never call requestUpdate() directly — a @state()-decorated accessor calls it automatically on every write.
LitDirective
LitDirective extends Directive, pre-wiring update_() to render a lit-html template returned by an abstract render_() hook — useful when a directive's output is more than a single text/attribute write.
import {directive, LitDirective, state, html} from '@alwatr/flux';
@directive('cart-badge')
export class CartBadgeDirective extends LitDirective {
@state() accessor count_: string | null = null;
protected override init_(): void {
this.subscribe_(cartSignal, (cart) => {
this.count_ = String(cart.items.length);
});
}
protected override render_() {
return html`<span class="badge">${this.count_ ?? '0'}</span>`;
}
}Decorators
| Decorator | Purpose |
|---|---|
@state() | Reactive accessor — calls requestUpdate() automatically on every write (equality-checked via Object.is). |
@query(selector, cache?, root?) | Lazily queries and caches a single descendant element. |
@queryAll(selector, cache?, root?) | Same, for a list of descendants. |
@attribute(name, cache?, root?) | Reads an attribute value into an accessor. |
All of the above require the ES2024 accessor keyword and TC39 Stage 3 decorators — do not enable experimentalDecorators in your tsconfig. The method decorator @on(eventType, selector?) also exists but is deprecated (it relies on the not-yet-stable context.addInitializer); use this.on_() inside init_() instead.