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 and WeakMap-tracked, so it's safe to call repeatedly or on dynamically inserted content.

Quick start

directives/tooltip.ts
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 {
    /* ... */
  }
}
html
<button tooltip="Save your changes">Save</button>

Import the module once (its side effect is registration) and call bootstrapDirectives() at app start:

app.ts
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:

HookFires
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:

ts
@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 direct addEventListener calls — 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 a MutationObserver) via autoDestructDirectives().
ts
@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:

text
requestUpdate()
  └─ (next macrotask)
       ├─ shouldUpdate_()   ← return false to abort here
       ├─ update_()         ← DOM mutations
       └─ updated_()        ← post-render hook

Most 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.

ts
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

DecoratorPurpose
@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.
Warning

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.