npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@alwatr/directive

v9.26.0

Published

Connect your TypeScript classes to the DOM, declaratively.

Downloads

1,829

Readme

@alwatr/directive

Declarative DOM behavior — without a framework.

@alwatr/directive is a tiny, zero-dependency TypeScript library that lets you attach rich, reusable behaviors to DOM elements using plain HTML attributes. No virtual DOM. No build-time magic. No framework lock-in.


Why Directives?

Modern web apps constantly need to enrich DOM elements: tooltips, lazy loaders, copy buttons, form validators, infinite scrollers, and more. The typical approaches all have trade-offs:

| Approach | Problem | | ---------------------------------------- | ----------------------------------------------------------------------- | | Inline querySelector + event listeners | Scattered, hard to reuse, breaks on dynamic content | | Full framework (React, Vue, Angular) | Heavy, opinionated, requires full buy-in | | Web Components | Verbose, requires custom element registration, no plain-HTML activation | | @alwatr/directive | ✅ Lightweight, declarative, reusable, SPA-friendly |

The Directive Pattern solves this by letting you encapsulate any DOM behavior in a self-contained class, then activate it declaratively from HTML — just by adding an attribute.

Key advantages

  • Declarative activation — behavior is triggered by HTML attributes, not imperative JS calls
  • Zero coupling — directives don't know about each other; HTML is the only contract
  • Idempotent bootstrap — safely re-run on dynamic content; already-initialized elements are skipped
  • Async-safe initializationinit_() runs after a macrotask so the DOM is always settled; visibility hooks (lazyInit_(), onVisible_()) are initialized after a macrotask and fire when the element becomes visible
  • Automatic cleanup — destroy hooks and autoDestroy() prevent memory leaks
  • Progressive enhancement — works on any existing HTML without restructuring your markup
  • Tiny footprint — no runtime overhead beyond what your directive actually does

Installation

bun add @alwatr/directive
# or
npm i @alwatr/directive
# or
yarn add @alwatr/directive
# or
pnpm i @alwatr/directive

Core Concepts

The library is built around three primitives:

1. @directive(attributeName)

A class decorator that registers your class against an HTML attribute name. When bootstrapDirectives() runs, any element with that attribute gets an instance of your class.

2. Directive

The abstract base class your directives extend. It wires up the element, a scoped logger, the attribute value, and the lifecycle hooks — so you only write the logic that matters.

3. bootstrapDirectives(root?)

Scans a DOM subtree, finds all elements matching registered attribute names, and instantiates the corresponding directive class for each one. Idempotent — safe to call multiple times or on overlapping subtrees.


Quick Start

1. Create a directive

// src/directives/copy-button.ts
import {directive, Directive} from '@alwatr/directive';

@directive('copy-button')
export class CopyButtonDirective extends Directive {
  private originalText_!: string;

  protected override async init_(): Promise<void> {
    // this.attributeValue  → value of the 'copy-button' attribute
    // this.element_        → the bound HTMLElement
    // this.logger_         → scoped logger: "directive:copy-button/0"

    this.originalText_ = this.element_.textContent ?? 'Copy';
    this.element_.addEventListener('click', () => this.handleClick_());
  }

  private async handleClick_(): Promise<void> {
    const text = this.attributeValue || this.element_.dataset.copyText || '';

    try {
      await navigator.clipboard.writeText(text);
      this.element_.textContent = 'Copied!';
    } catch {
      this.element_.textContent = 'Failed!';
    }

    setTimeout(() => {
      this.element_.textContent = this.originalText_;
    }, 2000);
  }
}

2. Bootstrap on page load

// src/main.ts
import {bootstrapDirectives} from '@alwatr/directive';
import './directives/copy-button.js'; // importing registers the directive

// Safe to call at any point — if the DOM isn't ready yet,
// bootstrapDirectives() will automatically defer until DOMContentLoaded.
bootstrapDirectives();

3. Activate from HTML

<button copy-button="Hello, world!">Copy</button>

That's it. No getElementById. No manual wiring. The attribute is the contract.


Reading the Attribute Value

Every directive automatically receives the attribute's value via this.attributeValue:

<div show-tooltip="This is a helpful hint">Hover me</div>
@directive('show-tooltip')
class TooltipDirective extends Directive {
  protected override init_(): void {
    // this.attributeValue === 'This is a helpful hint'
    this.element_.title = this.attributeValue;
  }
}

Tip: init_() is optional. If you only need @on decorators or visibility hooks, you don't have to define it at all.


Dynamic Content (SPA-friendly)

bootstrapDirectives is idempotent. You can call it as many times as you want — already-initialized elements are tracked internally via a WeakMap and are never touched again.

// After fetching and injecting new HTML into the page:
const container = document.querySelector('#dynamic-region')!;
container.innerHTML = await fetchSomeHtml();

// Only the new elements will be initialized
bootstrapDirectives(container);

This makes @alwatr/directive a natural fit for SPAs, server-side rendered pages with client-side hydration, and any app that loads content dynamically.


Lifecycle

new Directive(element, attributeName)
  │
  ├─ constructor runs synchronously
  │    sets: attributeName, attributeValue, element_, logger_, index
  │
  └─ after one macrotask (delay.nextMacrotask)
       ├─ init_()?       ← optional — runs once (setup, event listeners, signal subscriptions)
       ├─ lazyInit_()?   ← optional — runs once, when element first enters viewport
       ├─ onVisible_()?  ← optional — runs every time element enters viewport
       └─ onHidden_()?   ← optional — runs every time element leaves viewport

state change (via @state accessor or StateSignal subscription)
  │
  └─ requestUpdate()   ← ignored if disableUpdate_ is true; batched otherwise
       ├─ shouldUpdate_()?  ← return false to abort (skip update_/updated_ entirely)
       ├─ update_()     ← DOM mutations / lit-html render() [LitDirective]
       └─ updated_()    ← post-render hook

The macrotask delay ensures the full DOM subtree is painted and settled before your directive runs — no race conditions with sibling elements or CSS.

All four hooks are optional. You only define the ones you need — a directive that only uses @on decorators doesn't need any hook at all.


Visibility Hooks

Directive provides two optional lifecycle hooks for viewport-aware behavior. Both are powered by IntersectionObserver and include automatic cleanup on destroy().

lazyInit_()

Runs exactly once — the first time the element enters the viewport. Ideal for expensive one-time operations you want to defer until the element is actually visible.

Fallback chain (when IntersectionObserver is unavailable):

  1. IntersectionObserver — fires on first intersection, then disconnects
  2. requestIdleCallback — schedules execution during browser idle time
  3. setTimeout(100ms) — last resort for environments with neither API
@directive('product-image')
class ProductImageDirective extends Directive {
  protected override init_(): void {
    this.element_.classList.add('loading-skeleton');
  }

  protected override async lazyInit_(): Promise<void> {
    // Only runs once, when the image scrolls into view
    const img = this.element_.querySelector('img')!;
    img.src = img.dataset['src']!;
    await img.decode();
    this.element_.classList.remove('loading-skeleton');
  }
}
<div product-image="product-123">
  <img data-src="https://cdn.example.com/product-123.jpg" />
</div>

onVisible_()

Runs every time the element enters the viewport. Ideal for impression tracking, restarting animations, or refreshing dynamic data on each appearance.

Fallback (when IntersectionObserver is unavailable): onVisible_() is scheduled once via setTimeout(100ms) at setup time, so critical visibility logic is never silently skipped while avoiding a performance hit at startup.

@directive('track-impression')
class ImpressionTrackerDirective extends Directive {
  protected override onVisible_(): void {
    // Fires each time this element scrolls into view
    analytics.trackImpression(this.attributeValue);
  }
}
<div track-impression="banner-hero">...</div>

onHidden_()

Runs every time the element leaves the viewport. The counterpart to onVisible_().

Fallback: no fallback — if IntersectionObserver is unavailable, this hook is never called. Design your directive to work correctly without it.

@directive('auto-pause-video')
class AutoPauseVideoDirective extends Directive {
  private video_!: HTMLVideoElement;

  protected override init_(): void {
    this.video_ = this.element_.querySelector('video')!;
  }

  protected override onVisible_(): void {
    void this.video_.play();
  }

  protected override onHidden_(): void {
    this.video_.pause();
  }
}
<div auto-pause-video>
  <video src="clip.mp4"></video>
</div>

Note: onVisible_ and onHidden_ share a single IntersectionObserver instance — no duplicate observers are created when both are defined.

Customizing the IntersectionObserverintersectionOptions_

By default, all three visibility hooks (lazyInit_, onVisible_, onHidden_) use the browser's default IntersectionObserver settings: the viewport as the root, no margin, and a 0 threshold (fires as soon as a single pixel is visible).

Override intersectionOptions_ in your subclass to change this behaviour. The same options object is shared by every observer created for that directive instance.

protected override intersectionOptions_: IntersectionObserverInit = {
  rootMargin: '200px 0px', // pre-load 200 px before the element enters the viewport
  threshold: 0,
};

Common recipes

Pre-load before the element is visible — useful for images and heavy components:

@directive('lazy-image')
class LazyImageDirective extends Directive {
  protected override intersectionOptions_: IntersectionObserverInit = {
    rootMargin: '200px 0px', // start loading 200 px early
  };

  protected override async lazyInit_(): Promise<void> {
    const img = this.element_.querySelector('img')!;
    img.src = img.dataset['src']!;
    await img.decode();
  }
}

Fire only when the element is at least 50 % visible — useful for impression tracking:

@directive('track-impression')
class ImpressionTrackerDirective extends Directive {
  protected override intersectionOptions_: IntersectionObserverInit = {
    threshold: 0.5, // at least half the element must be visible
  };

  protected override onVisible_(): void {
    analytics.trackImpression(this.attributeValue);
  }
}

Observe within a scrollable container — useful for sticky headers or virtualised lists:

@directive('sticky-header')
class StickyHeaderDirective extends Directive {
  protected override intersectionOptions_: IntersectionObserverInit = {
    root: document.querySelector('#scroll-container'),
    rootMargin: '-64px 0px 0px 0px', // account for a 64 px top bar
    threshold: 0,
  };

  protected override onHidden_(): void {
    this.element_.classList.add('is-sticky');
  }

  protected override onVisible_(): void {
    this.element_.classList.remove('is-sticky');
  }
}

Tip: intersectionOptions_ must be set before init_() completes, because the observers are created during initializeLifecycle_() which runs right after init_(). The safest place is a class field initializer or the constructor.

Using both hooks together

@directive('product-card')
class ProductCardDirective extends Directive {
  // Runs once at setup — attach event listeners
  protected override init_(): void {
    this.element_.addEventListener('click', () => this.handleClick_());
  }

  // Runs once when card first scrolls into view — fetch data
  protected override async lazyInit_(): Promise<void> {
    const data = await fetchProductData(this.attributeValue);
    this.element_.querySelector('.price')!.textContent = data.price;
  }

  // Runs every time card scrolls into view — track impressions
  protected override onVisible_(): void {
    analytics.trackImpression(this.attributeValue);
  }

  private handleClick_() {
    /* ... */
  }
}

Cleanup & destroy()

All visibility hooks register their IntersectionObserver in destroyHookList__ automatically. onVisible_ and onHidden_ share a single observer, so only one entry is added. When destroy() is called:

  • The lazyInit_ observer is disconnected — if the element hasn't entered the viewport yet, lazyInit_() will not run.
  • The shared onVisible_/onHidden_ observer is disconnected — neither hook will fire after destruction.

No manual cleanup is needed. No memory leaks.

Hook comparison

| | init_()? | lazyInit_()? | onVisible_()? | onHidden_()? | | ------------------ | ---------------------- | ------------------------------------ | -------------------------------------- | --------------------------------- | | When | After next macrotask | First viewport entry | Every viewport entry | Every viewport exit | | Times | Once | Once | Unlimited | Unlimited | | Good for | Event listeners, setup | Lazy loading, data fetch | Impression tracking, animation restart | Pause video, cancel work, hide UI | | Auto cleanup | — | ✅ observer disconnected on destroy | ✅ shared observer, disconnected | ✅ shared observer, disconnected | | Error handling | — | ✅ logged, never re-thrown | ✅ logged, never re-thrown | ✅ logged, never re-thrown | | Fallback | — | requestIdleCallbacksetTimeout | Called once via setTimeout(100ms) | None — silently skipped | | Custom options | — | ✅ via intersectionOptions_ | ✅ via intersectionOptions_ | ✅ via intersectionOptions_ |


Cleanup & Memory Management

addDestroyHook(task)

Register cleanup callbacks that run when destroy() is called. Use this to remove global event listeners, cancel timers, or unsubscribe from signals.

@directive('live-clock')
class LiveClockDirective extends Directive {
  protected override init_(): void {
    const intervalId = setInterval(() => {
      this.element_.textContent = new Date().toLocaleTimeString();
    }, 1000);

    // Registered cleanup — runs automatically on destroy()
    this.addDestroyHook(() => clearInterval(intervalId));
  }
}

destroy()

Runs all registered destroy hooks in order, then nullifies the internal element reference to aid garbage collection.

autoDestroy()

Checks whether this.element_ is still connected to the DOM. If not, calls destroy() and returns true. Use this with autoDestructDirectives() for periodic cleanup.

autoDestructDirectives()

Iterates over all live directive instances and calls autoDestroy() on each. Pair with a MutationObserver or a periodic interval for automatic memory management in long-running SPAs.

import {autoDestructDirectives} from '@alwatr/directive';

// Clean up disconnected directives every 30 seconds
setInterval(autoDestructDirectives, 30_000);

Dispatching Events

Use dispatch() to fire a bubbling CustomEvent from the directive's element — a clean way to communicate upward without tight coupling.

@directive('submit-form')
class SubmitFormDirective extends Directive {
  protected override init_(): void {
    this.element_.addEventListener('click', () => {
      this.dispatch('form-submitted', {formId: this.attributeValue});
    });
  }
}

// Anywhere in the app_
document.addEventListener('form-submitted', (e: CustomEvent) => {
  console.log('Form submitted:', e.detail.formId);
});

Reactive Rendering — requestUpdate(), shouldUpdate_(), update_(), updated_()

Directive includes a lightweight batched update cycle for directives that need to re-render their DOM in response to state changes.

The update cycle

state change
  │
  └─ requestUpdate()     ← ignored if disableUpdate_ is true; batched otherwise
       │
       ├─ shouldUpdate_()?  ← return false to abort (update_/updated_ are skipped)
       ├─ update_()         ← perform DOM mutations / call lit-html render()
       └─ updated_()        ← post-render hook (focus, measure, dispatch events)

Triggering an update

There are three idiomatic ways to start the cycle:

1. @state accessor (most common — local state)

Setting a @state-decorated accessor automatically calls requestUpdate():

@directive('like-button')
class LikeButtonDirective extends Directive {
  @state()
  accessor liked_: string | null = null;

  protected override init_(): void {
    this.liked_ = 'false'; // triggers first update
    this.on_('click', () => {
      this.liked_ = this.liked_ === 'true' ? 'false' : 'true'; // triggers re-render
    });
  }

  protected override update_(): void {
    this.element_.classList.toggle('liked', this.liked_ === 'true');
  }
}

2. StateSignal subscription (shared application state)

Subscribe to a signal in init_() and call requestUpdate() from the callback. Use the built-in subscribe_() helper to avoid the manual addDestroyHook boilerplate:

@directive('cart-badge')
class CartBadgeDirective extends Directive {
  private count_ = 0;

  protected override init_(): void {
    // subscribe_() automatically unsubscribes on destroy() — no addDestroyHook needed
    this.subscribe_(cartSignal, (cart) => {
      this.count_ = cart.items.length;
      this.requestUpdate();
    });
  }

  protected override update_(): void {
    this.element_.textContent = String(this.count_);
  }
}

3. Manual call (imperative mutations)

Call requestUpdate() directly after mutating non-@state internal state:

protected override init_(): void {
  this.on_('click', () => {
    this.count_++;
    this.requestUpdate();
  });
}

shouldUpdate_()

Override to conditionally abort an update cycle before update_() runs. Return false (strict boolean) to skip the render entirely; return true or void to proceed normally.

The disableUpdate_ flag is cleared even when shouldUpdate_() returns false, so a future requestUpdate() will schedule a new cycle normally.

@directive('data-table')
class DataTableDirective extends LitDirective {
  private loading_ = true;

  // Suppress all renders until data has been fetched
  protected override shouldUpdate_(): boolean | void {
    if (this.loading_) return false;
  }

  protected override async lazyInit_(): Promise<void> {
    this.rows_ = await fetchRows();
    this.loading_ = false;
    this.requestUpdate(); // shouldUpdate_() now returns void → render proceeds
  }

  protected override render_() {
    return html`
      ${this.rows_.map(
        (r) => html`
          <tr><td>${r.name}</td></tr>
        `,
      )}
    `;
  }
}

Another common pattern — skip renders while the element is inside a hidden panel:

protected override shouldUpdate_(): boolean | void {
  if (this.element_.closest('[hidden]')) return false;
}

disableUpdate_

A protected boolean flag that controls whether requestUpdate() is allowed to schedule a new render cycle. It serves two roles:

1. Pending-update guard (automatic) Set to true by requestUpdate() and cleared back to false by performUpdate__() once the cycle finishes (or is aborted by shouldUpdate_()). This collapses multiple requestUpdate() calls within the same macrotask into a single render.

2. Manual render suppression (opt-in) Subclasses can set disableUpdate_ = true at any time to pause rendering indefinitely — for example, while performing a multi-step async setup or while the element is off-screen. Set it back to false and call requestUpdate() to resume.

disableUpdate_ vs shouldUpdate_()

| | disableUpdate_ = true | shouldUpdate_() returning false | | -------------------- | ------------------------------------------ | ------------------------------------------- | | Scope | Prevents all future cycles until reset | Aborts only the current in-flight cycle | | Resets automatically | No — you must reset it manually | Yes — cleared after each aborted cycle | | Use when | Sustained pause (loading, off-screen) | Per-cycle condition (data not ready yet) |

@directive('live-feed')
class LiveFeedDirective extends LitDirective {
  // Pause all renders while the element is scrolled out of view
  protected override onHidden_(): void {
    this.disableUpdate_ = true;
  }

  protected override onVisible_(): void {
    this.disableUpdate_ = false;
    this.requestUpdate(); // catch up with any missed state changes
  }

  protected override render_() {
    return html`
      <ul>
        ${this.items_.map(
          (i) => html`
            <li>${i}</li>
          `,
        )}
      </ul>
    `;
  }
}
@directive('async-card')
class AsyncCardDirective extends LitDirective {
  protected override async init_(): Promise<void> {
    // Block signal-triggered renders until initial data is ready
    this.disableUpdate_ = true;

    this.subscribe_(dataSignal, (data) => {
      this.data_ = data;
      this.requestUpdate(); // silently ignored while disableUpdate_ is true
    });

    await this.loadInitialData_();

    this.disableUpdate_ = false; // re-enable
    this.requestUpdate(); // first render with fully loaded state
  }
}

update_()

Override to perform DOM mutations. Called once per scheduled cycle, synchronously inside the macrotask. LitDirective overrides this to call lit-html's render().

updated_()

Override to run post-render logic — measuring layout, focusing an element, or dispatching a CustomEvent:

protected override updated_(): void {
  this.element_.querySelector<HTMLElement>('.active-item')?.focus();
}

LitDirective — Declarative Templates with lit-html

LitDirective extends Directive and adds a render_() hook that integrates lit-html. Use it when you want to describe a directive's DOM output as a declarative template instead of imperative DOM mutations.

import {directive, LitDirective, state} from '@alwatr/directive';
import {html} from 'lit-html';

@directive('cart-badge')
export class CartBadgeDirective extends LitDirective {
  @state()
  accessor count_: string | null = null;

  protected override init_(): void {
    // StateSignal.subscribe() calls the callback immediately with the current value,
    // so the first render happens right after init_() without any extra trigger.
    const sub = cartSignal.subscribe((cart) => {
      this.count_ = String(cart.items.length); // @state setter calls requestUpdate()
    });
    this.addDestroyHook(() => sub.unsubscribe());
  }

  protected override render_() {
    return html`
      <span class="badge">${this.count_ ?? '0'}</span>
    `;
  }
}
<div cart-badge></div>

render_()

The only method you must implement. Return any value accepted by lit-html's render() — typically an html tagged template literal. Keep it pure and side-effect-free; all side effects belong in update_() or updated_().

rootElement_

By default lit-html renders into this.element_. Override rootElement_ to redirect rendering into a different container:

@directive('shadow-card')
class ShadowCardDirective extends LitDirective {
  protected override rootElement_ = this.element_.attachShadow({mode: 'open'}) as unknown as HTMLElement;

  protected override render_() {
    return html`
      <slot></slot>
    `;
  }
}

When to use LitDirective vs Directive

| Scenario | Use | | ---------------------------------------------------- | -------------- | | Simple DOM mutations, event listeners, class toggles | Directive | | Complex, data-driven templates that change shape | LitDirective | | Subscribing to signals and re-rendering a template | LitDirective |


@state — Reactive Local State

@state marks an accessor as reactive. Every time the accessor is set, requestUpdate() is called automatically — no manual trigger needed.

@state()
accessor count_: string | null = null;

Rules:

  • Requires the accessor keyword (ES2024 auto-accessor feature)
  • Primitive equality check — if the new value is identical to the current value (via Object.is) and is a primitive or null, the update is skipped. For objects and arrays, every set schedules an update regardless of reference equality.
  • For shared state, use a StateSignal subscription instead (see above)

Combining @state with StateSignal is the standard pattern for reactive directives:

@directive('user-avatar')
class UserAvatarDirective extends LitDirective {
  @state()
  accessor avatarUrl_: string | null = null;

  protected override init_(): void {
    const sub = userSignal.subscribe((user) => {
      this.avatarUrl_ = user.avatarUrl; // @state triggers re-render on each emission
    });
    this.addDestroyHook(() => sub.unsubscribe());
  }

  protected override render_() {
    return html`
      <img
        src=${this.avatarUrl_ ?? '/default-avatar.png'}
        alt="avatar"
      />
    `;
  }
}

These TC39 Stage 3 accessor decorators reduce boilerplate for common patterns inside directives. They require the accessor keyword.

@query(selector, cache?, root?)

Lazily queries a single child element. Cached by default.

@directive('my-card')
class CardDirective extends Directive {
  @query('.card-title')
  accessor titleEl!: HTMLElement | null;

  @query('.card-body', false) // cache=false → re-queries on every access
  accessor bodyEl!: HTMLElement | null;

  protected override init_(): void {
    if (this.titleEl) {
      this.titleEl.textContent = 'Hello!';
    }
  }
}

@queryAll(selector, cache?, root?)

Lazily queries all matching child elements. Cached by default.

@directive('my-tabs')
class TabsDirective extends Directive {
  @queryAll('.tab-item')
  accessor tabItems!: NodeListOf<HTMLElement>;

  protected override init_(): void {
    this.tabItems.forEach((tab, i) => {
      tab.addEventListener('click', () => this.activateTab_(i));
    });
  }

  private activateTab_(index: number): void {
    /* ... */
  }
}

@attribute(name, cache?, root?)

Lazily reads an attribute value from the element. Cached by default.

@directive('user-card')
class UserCardDirective extends Directive {
  @attribute('user-id')
  accessor userId!: string | null;

  @attribute('user-role')
  accessor userRole!: string | null;

  protected override async init_(): Promise<void> {
    if (!this.userId) return;
    const user = await fetchUser(this.userId);
    this.element_.querySelector('.name')!.textContent = user.name;
  }
}

@on(eventType, selector?, options?)

⚠️ Deprecated: This decorator relies on context.addInitializer, which is not yet stable across all JS environments. Use the on_() protected method inside init_() instead — it provides identical functionality with full stability.

Registers a DOM event listener on this.element_ (or a matching child element) and automatically removes it when the directive is destroyed — no manual addEventListener / removeEventListener needed.

@directive('my-form')
class MyFormDirective extends Directive {
  // Basic: listen on this.element_
  @on('click')
  protected onClick_(event: Event): void {
    console.log('clicked', event);
  }

  // Selector-based: listen on a child element
  @on('input', '.search-input')
  protected onInput_(event: Event): void {
    console.log('input', (event.target as HTMLInputElement).value);
  }

  // With options (e.g. passive scroll listener)
  @on('scroll', undefined, {passive: true})
  protected onScroll_(event: Event): void {
    /* ... */
  }
}

The listener is bound to the directive instance, so this inside the method always refers to the directive. Cleanup is registered automatically via addDestroyHook — when destroy() is called, all @on listeners are removed.

Since init_() is optional, a directive that only uses @on decorators doesn't need to define any lifecycle hook:

@directive('close-dialog')
class CloseDialogDirective extends Directive {
  @on('click')
  protected onClick_(): void {
    this.element_.closest('dialog')?.close();
  }
}

Warning: If selector is provided but this.element_.querySelector(selector) returns null, a warning is logged and the listener is silently skipped — no error is thrown.


Full API Reference

directive(attributeName: string)

Class decorator. Registers the decorated class in the global directive registry.

  • attributeName — the HTML attribute that activates this directive (e.g. 'show-tooltip')
  • Throws if used on a non-class target
  • Logs a warning and skips silently if the same attribute name is registered twice

Directive (abstract class)

| Member | Type | Description | | ---------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | attributeName | readonly string | The attribute name this directive is bound to | | attributeValue | readonly string | The value of the attribute at construction time | | index | readonly number | Per-attribute instance counter (0, 1, 2, …) | | element_ | protected readonly HTMLElement | The bound DOM element | | logger_ | protected readonly | Scoped logger: directive:{attributeName}/{index} | | intersectionOptions_ | protected IntersectionObserverInit \| undefined | Optional options forwarded to every IntersectionObserver created for this directive (lazyInit_, onVisible_, onHidden_). Must be set before init_() completes. | | init_()? | protected | Optional — runs once after next macrotask (setup, event listeners) | | lazyInit_()? | protected | Optional — runs once when element first enters the viewport | | onVisible_()? | protected | Optional — runs every time element enters the viewport | | onHidden_()? | protected | Optional — runs every time element leaves the viewport | | requestUpdate() | public | Schedules a batched update_() + updated_() call for the next macrotask. Ignored if disableUpdate_ is true. Multiple calls within the same cycle collapse into one. | | disableUpdate_ | protected boolean | When true, requestUpdate() is a no-op. Set manually to pause rendering; reset to false and call requestUpdate() to resume. Also used internally as the pending-update guard. | | shouldUpdate_() | protected | Called before update_() in each cycle. Return false to abort the cycle (skips update_() and updated_()). Return true or void to proceed. Base returns void. | | update_() | protected | Called once per update cycle — override to perform DOM mutations. LitDirective overrides this to call lit-html's render(). | | updated_() | protected | Called immediately after update_() — override for post-render logic (focus, measure, dispatch events). | | dispatch(event, detail?) | public | Fires a bubbling CustomEvent from element_ | | addDestroyHook(task) | public | Registers an async cleanup callback | | subscribe_(signal, callback, options?) | protected | Subscribes to a read-only signal and automatically unsubscribes on destroy(). Idiomatic replacement for signal.subscribe() + addDestroyHook(). | | destroy() | public async | Runs all destroy hooks, then nullifies element_ | | autoDestroy() | public | Destroys if element is disconnected; returns true if destroyed |


LitDirective (abstract class, extends Directive)

| Member | Type | Description | | ----------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | rootElement_ | protected HTMLElement \| undefined | The container lit-html renders into. Defaults to element_. Override to redirect rendering (e.g. Shadow DOM root). | | shouldUpdate_() | protected override | Inherited guard — return false to skip render_() entirely for the current cycle. Return true or void to proceed. Useful for suppressing renders while data is loading. | | render_() | protected abstract | Must implement. Returns the lit-html template for each update cycle. Keep it pure — no side effects. | | update_() | protected override | Calls render_() and passes the result to lit-html's render(). Do not call directly — use requestUpdate(). |


bootstrapDirectives(root?: Element | Document)

Scans root (default: document.body) for elements matching registered attribute names and instantiates their directive classes.

  • Safe to call before DOM is ready — defers automatically via DOMContentLoaded
  • Idempotent — uses a WeakMap to skip already-initialized elements
  • Scoped — pass any element to limit the scan to a subtree

autoDestructDirectives()

Iterates all live directive instances and calls autoDestroy() on each. Removes destroyed instances from the internal registry.


state()

Accessor decorator. Marks the accessor as reactive local state — every set call automatically schedules a re-render via requestUpdate().

  • Primitive equality check — if the new value is identical to the current value (via Object.is) and is a primitive or null, the update is skipped. For objects and arrays, every set schedules an update regardless of reference equality.
  • Requires accessor keyword
  • For shared application state, use a StateSignal subscription instead

query<T>(selector, cache?, root?)

Accessor decorator. Lazily queries element_.querySelector<T>(selector).

  • cache (default true) — caches result after first access
  • root — override the query root (defaults to element_)
  • Requires accessor keyword

queryAll<T>(selector, cache?, root?)

Accessor decorator. Lazily queries element_.querySelectorAll<T>(selector).

  • Same options as @query
  • Requires accessor keyword

attribute(name, cache?, root?)

Accessor decorator. Lazily reads element_.getAttribute(name).

  • cache (default true) — caches result after first access
  • root — override the element to read from (defaults to element_)
  • Requires accessor keyword

on(eventType, selector?, options?) (deprecated)

⚠️ Deprecated: Relies on context.addInitializer, which is not yet stable. Use on_() inside init_() instead.

Method decorator. Registers a DOM event listener and removes it automatically on destroy().

  • eventTypekeyof HTMLElementEventMap | string — the event to listen for (e.g. 'click', 'input')
  • selector — optional CSS selector; when provided, the listener is registered on this.element_.querySelector(selector) instead of this.element_
  • options — optional AddEventListenerOptions | boolean passed directly to addEventListener
  • The decorated method is bound to the directive instance (this is always the directive)
  • When selector is provided but matches no element, a warning is logged and registration is skipped silently
  • Throws if applied to a non-method class member

TypeScript Configuration

Directives use TC39 Stage 3 decorators. Make sure your tsconfig.json does not use experimentalDecorators:

{
  "compilerOptions": {
    // Do NOT set "experimentalDecorators": true
    // Stage 3 decorators are enabled by default in TypeScript 5+
  },
}


🌊 Part of Alwatr Flux

@alwatr/directive is the View Layer of the Alwatr Flux architecture — a complete Unidirectional Data Flow system for building scalable Progressive Web Applications.

View (@alwatr/directive — declarative DOM behaviors)
  ↓
Action Layer (@alwatr/action — global event delegation)
  ↓
Controller (business logic)
  ↓
State Layer (@alwatr/signal — fine-grained reactivity)
  ↓
View (re-render via signal subscriptions)

Directives are the presentation layer of the Flux architecture. They attach rich behaviors to DOM elements declaratively, subscribe to signals for reactive updates, and dispatch actions upward through the action bus — never touching state directly.

The full Flux bundle (@alwatr/flux) includes directives, signals, actions, page-ready, and storage — everything you need to build a complete reactive application from a single import.

// Use @alwatr/flux for the complete architecture
import {Directive, directive, bootstrapDirectives, createStateSignal, onAction} from '@alwatr/flux';

// Or use @alwatr/directive standalone for just the directive system
import {Directive, directive, bootstrapDirectives} from '@alwatr/directive';

View the complete Flux documentation


Contributing

Contributions are welcome! Please read our contribution guidelines before submitting a pull request.