npm package: @klt/dom-js
Description: A lightweight, modular DOM manipulation library with chainable API, zero dependencies, and modern ES modules.
Author: Dmitrij Kiltau
Latest version: 1.6.38

# @klt/dom-js

A lightweight, modular DOM manipulation library with chainable API, zero dependencies, and modern ES modules.

[![npm version](https://badge.fury.io/js/@dmitrijkiltau%2F@klt/dom-js.svg)](https://www.npmjs.com/package/@klt/dom-js)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

## Table of Contents

- [@klt/dom-js](#kltdom-js)
  - [Table of Contents](#table-of-contents)
  - [Features](#features)
  - [Install](#install)
  - [Quick Start](#quick-start)
  - [Import Options](#import-options)
  - [Core API Highlights](#core-api-highlights)
  - [Templates](#templates)
  - [Forms](#forms)
  - [Events](#events)
  - [HTTP](#http)
  - [Motion](#motion)
  - [Utilities \& Observers](#utilities--observers)
  - [SSR (Server‑Safe)](#ssr-serversafe)
    - [Hydration (client)](#hydration-client)
  - [TypeScript](#typescript)
    - [Custom Event Typing](#custom-event-typing)
    - [Typed Serialization](#typed-serialization)
  - [Plugin System](#plugin-system)
  - [Advanced Recipes](#advanced-recipes)
    - [HTTP interceptors: auth header + refresh](#http-interceptors-auth-header--refresh)
    - [Templates: keyed lists (stable DOM for reorder/patch)](#templates-keyed-lists-stable-dom-for-reorderpatch)
    - [Plugins with module augmentation (TypeScript)](#plugins-with-module-augmentation-typescript)
  - [Browser Support](#browser-support)
  - [Links](#links)
  - [Security](#security)
  - [Contributing](#contributing)
  - [License](#license)

## Features

- 🚀 **Lightweight**: modular architecture, import only what you need
- ⛓️ **Chainable API**: intuitive method chaining for DOM ops
- 📦 **Modern ESM**: built for ES2020+ browsers
- 🎯 **TypeScript**: full type definitions and event typing
- 🏗️ **Templates**: HTML templates with data binding
- 📝 **Forms**: serialization, population, validation, submit helpers
- 🌐 **HTTP**: fetch wrapper with baseUrl, query, retries, interceptors, caching
- 🎬 **Motion**: Web Animations API helpers (awaitable, queued, reduced‑motion aware)
- 🔧 **Plugins**: extend functionality safely
- 🆔 **Zero Deps**: no external runtime dependencies

## Install

```bash
npm install @klt/dom-js
```

ESM:

```js
import dom from "@klt/dom-js";
```

CDN (pinned):

```js
import dom from "https://unpkg.com/@klt/dom-js@1.6.38/dist/index.js";
```

CommonJS:

```js
const dom = require("@klt/dom-js");
```

## Quick Start

```js
import dom from "@klt/dom-js";

dom(".my-items")
  .addClass("active")
  .on("click", (ev, el) => console.log("Clicked", el));
```

## Import Options

Choose what you need:

```js
// 1) Full bundle (everything)
import dom from "@klt/dom-js";

// 2) Core only (DOM + events)
import dom from "@klt/dom-js/core";

// 3) Modular (cherry-pick)
import dom from "@klt/dom-js/core";
import { http } from "@klt/dom-js/http";
import { renderTemplate, useTemplate, tpl } from "@klt/dom-js/template";
import { onSubmit, serializeForm, toFormData, toQueryString, setForm, resetForm, validateForm } from "@klt/dom-js/forms";
import { animate, animations } from "@klt/dom-js/motion";
import { debounce, throttle, nextTick, raf, rafThrottle } from "@klt/dom-js/utils";
import { onIntersect, inView, onResize, onMutation } from "@klt/dom-js/observers";
import { scrollIntoView, scrollIntoViewIfNeeded, lockScroll, unlockScroll } from "@klt/dom-js/scroll";
```

## Core API Highlights

Selection & creation:

```js
dom(".items");          // selector
dom(element);            // wrap element
dom([el1, el2]);         // wrap list
dom(".child", root);     // scoped selection
dom('<li class="row">X</li>');
dom.fromHTML("<div>…</div>");
dom.create("button", { type: "button" }, "Click");
```

Common operations (chaining):

```js
dom(".items")
  .addClass("active")
  .css({ opacity: 0.9 })
  .on("mouseenter", (e, el) => dom(el).toggleClass("hover"))
  .append("<span>Hi</span>")
  // Per-node HTML transform
  .html((el, i) => i === 0 ? '<b>First</b>' : (() => { const n = document.createElement('i'); n.textContent = 'Second'; return n; })());
```

Highlights by category:
- Traversal: `find`, `children`, `parent`, `parents`, `siblings`, `closest`, `first`, `last`, `eq`, `get`, `slice`, `map`, `index`
- Content & attrs: `text`, `html`, `append`, `prepend`, `before`, `after`, `replaceWith`, `wrap*`, `empty`, `clone`, `attr/attrs`, `prop`, `val`, `data`
  - `.dataset(map)` to set multiple `data-*` attrs; `.aria(name[, value])` and `.aria(map)` helpers
- Classes & CSS: `addClass/removeClass/toggleClass/hasClass`, `css`, `cssVar/cssVars`, `computed`
  - `toggleClass(name[, force])` or `toggleClass({ name: boolean, ... })` for batch toggling
- Visibility & layout: `show/hide/toggle/isVisible`, `width/height/inner*/outer*`, `offset/position/offsetParent`, `scrollTop/scrollLeft`, `rect`
- Events: `on/once/off/trigger`, shortcuts like `click`, `focus`, `blur`, `hover`, pointer/touch helpers
  - `.trigger(type[, init])` accepts `EventInit | CustomEventInit` and defaults to `{ bubbles: true }`; non‑object becomes `{ detail }`.
  - Delegation supported at collection and top‑level: `on(el|document|window, 'click', 'a.item', handler)`.
- Forms: `serialize`, `toFormData`, `setForm`, `reset`

## Templates

HTML templates with data binding, conditionals, loops, includes, safe escaping, event args, and incremental updates.

```html
<template id="row">
  <li data-if="visible" data-show="active">
    <a data-text="title" data-attr-href="url" data-on-click="handleClick"></a>
    <span data-hide="editing" data-text="description"></span>
  </li>
  <!-- data-elseif / data-else supported on sibling chain -->
</template>
``` 

```js
import { renderTemplate, useTemplate, tpl, escapeHTML, unsafeHTML, isUnsafeHTML } from "@klt/dom-js/template";

const data = {
  title: "Docs",
  url: "/docs",
  description: "Demo",
  visible: true,
  active: false,
  editing: false,
  handleClick: (e) => console.log("Clicked!", e),
};

// One-shot
dom("#list").append(renderTemplate("#row", data));

// Reusable renderer + incremental update
const render = useTemplate("#row");
const instance = render.mount(data);
dom("#list").append(instance.el);
instance.update({ ...data, active: true });

// Safe HTML helpers
escapeHTML("<b>x</b>"); // escape to text
unsafeHTML("<b>trusted</b>"); // wrap as explicitly unsafe
isUnsafeHTML("<i>also trusted</i>"); // alias for readability
```

Bindings overview:
- `data-text`, `data-html`, `data-safe-html`
- `data-attr-*`, `data-on-*`
- `data-show`, `data-hide`
- `data-class-<name>` toggle classes by truthy expr
- `data-style-<prop>` set/remove inline styles
- `data-on-<type>="handler(args)"` (event is first arg implicitly)
- `data-each="items as item, i [by key]"` (keyed loops supported)
- `data-include="#tplId"` or renderer ref + optional `data-with`

Performance notes:
- `useTemplate(ref)` caches a compiled plan per `HTMLTemplateElement` so repeated renders/mounts skip re‑parsing.
- Structural directives (`data-if`/`elseif`/`else`, `data-each`, `data-include`) are precompiled; includes reuse cached plans.
- Event handler specs (`data-on-*`) are parsed once; arguments support literals, paths, and `$event`.

Diagnostics (dev):
- Enable dev logging for template binding errors and invalid expressions:
  `import { setTemplateDevMode } from '@klt/dom-js/template'; setTemplateDevMode(true);`
- Logs include event handler exceptions, non-function handlers, include ref issues, and safe destroy/hydrate errors.

## Forms

```js
import { onSubmit, serializeForm, toQueryString, toFormData, setForm, resetForm, validateForm } from "@klt/dom-js/forms";

onSubmit("#contact", async (data, ev) => {
  console.log("submit", data);
});

const data = serializeForm("#my-form");
const fd = toFormData(data);
const query = toQueryString(data);

setForm("#my-form", { user: { name: "Alice" }, tags: ["js", "css"] });
resetForm("#my-form");

const { valid, errors } = validateForm("#my-form");
```

## Events

Top‑level helpers work with `window`, `document`, Elements, and DOMCollections; collection methods are chainable.

```js
import dom, { on, once, off, ready } from "@klt/dom-js";

// Multiple types + namespaces
const stop = on(window, "scroll resize.ns", handler, { passive: true });
stop(); // unbind

dom(".btn").on("click focus", (e, el) => {/* ... */}, { passive: true }).once("click", () => {/* ... */});
dom("#list").on("click", "a.item", (e, link, i) => {/* delegated */});

// Namespace management
// - Remove by type+namespace
off(window, "resize.ns");
// - Remove by namespace only (all types with .ns)
off(window, ".ns");

// Delegated off() on collections
const $wrap = dom("#wrap");
$wrap.on("click.nav", "a.alpha", alphaHandler);
$wrap.on("click.nav", "a.beta", betaHandler);
// Remove only alpha delegated handler
$wrap.off("click.nav", "a.alpha");

// Abort with { signal }
const c = new AbortController();
on(document, "click.debug", handler, { signal: c.signal });
// later
c.abort(); // auto‑unbinds and prunes internal store

// Trigger can take a ready Event instance, dispatched as-is
const ev = new Event('custom', { bubbles: false });
dom('#btn').trigger(ev);
```

## HTTP

Fetch wrapper with baseUrl, default query/headers, retries, interceptors, cache, progress, and throw‑on‑error.

```js
import { http } from "@klt/dom-js/http";

const api = http
  .withBaseUrl("/api")
  .withHeaders({ Authorization: "Bearer token" })
  .withRetry({ retries: 2, retryOn: http.retryOnStatus([429, [500, 599]]) })
  .withRetryAfter()
  .withThrowOnError();

const r = await api.get("/users", { query: { page: 1 } });
const users = await r.json();

// Smart JSON: plain objects are serialized and Content-Type set automatically
await api.post('/users', { name: 'Ada' }); // sends JSON with Content-Type: application/json

// JSON-focused client (adds Accept: application/json by default)
const apiJson = api.withJSON();
const created = await apiJson.post('/users', { name: 'Lin' }).then(r => r.json());

// Opt-in caching for non‑GET methods (includes body hash in key)
const apiCached = api.withCache({ enabled: true, methods: ['GET', 'POST'] });
await apiCached.post('/users', { name: 'Mia' }, { cacheTtl: 5_000 });

// Download and upload progress
await api.get('/big-file', {
  onDownloadProgress: ({ loaded, total, percent }) => {
    if (total != null) console.log(`Downloaded ${Math.round(percent)}%`);
    else console.log(`Downloaded ${loaded} bytes`);
  }
});

await api.post('/upload', new Blob([/* ... */]), {
  onUploadProgress: ({ loaded, total, percent }) => {
    if (total != null) console.log(`Uploaded ${Math.round(percent)}%`);
    else console.log(`Uploaded ${loaded} bytes`);
  }
});
```

## Motion

```js
import { animate, animations, sequence, stagger } from "@klt/dom-js/motion";

const [kf, op] = animations.fadeIn(250);
await animate(dom(".box").el(), kf, op).finished;

// Sequence multiple animations on the same element
await dom('#card').sequence([
  animations.slideDown(200),
  100, // wait 100ms
  animations.pulse(400)
]);

// Stagger across a list
await dom('.item').stagger(100, (el, i) =>
  dom(el).animate([
    { opacity: 0, transform: 'translateY(8px)' },
    { opacity: 1, transform: 'translateY(0px)' }
  ], { duration: 300, fill: 'forwards' })
);

// Ensure visibility before animating (e.g., with reduced motion)
await dom('.panel').withVisible().fadeIn(250);
// Custom display value
await dom('#row').withVisible('flex').slideDown(200);
```

## Utilities & Observers

```js
import { debounce, throttle, nextTick, raf, rafThrottle } from "@klt/dom-js/utils";
import { onIntersect, onResize, onMutation } from "@klt/dom-js/observers";
import { scrollIntoView, scrollIntoViewIfNeeded, lockScroll, unlockScroll } from "@klt/dom-js/scroll";

const onType = debounce(() => {/* ... */}, 150);
await nextTick(); await raf();

const stop1 = onIntersect(".watch", (entry, el) => {/* ... */}, { threshold: 0.1 });

// In‑view sugar: subscribe to enter/leave
const io = inView('.card', { threshold: 0.25, once: true });
const unEnter = io.enter((el, entry) => {
  // Called once per element when it reaches 25% visibility
  el.classList.add('reveal');
});
io.leave((el) => {
  // Called when it drops below 25% (not called if once:true already unobserved)
});
// Later: io.stop(); // disconnects observer and clears handlers
scrollIntoView("#details", { behavior: "smooth", block: "start" });

// Lock page scroll while a modal is open
lockScroll();
// ... open modal ...
unlockScroll();

// Lock a specific scrollable container
lockScroll('#panel');
// ...
unlockScroll('#panel');
```

## SSR (Server‑Safe)

```js
import dom from "@klt/dom-js/server";

dom(".x");              // -> empty collection on server
dom.on(window, "x");    // -> no-op unbinder on server
dom.ready(() => {});     // -> runs immediately on server

await dom.http.get("/api");
await dom.nextTick();
await dom.raf(); // setTimeout fallback
```

Environment safeguards: no `window`/`document` access at import time; `ready()` runs immediately; RAF helpers fallback; scroll/motion early‑return without DOM; `fromHTML()` returns empty collection; `create()` throws on server to surface misuse.

### Hydration (client)

If your HTML was rendered using @klt/dom-js templates (so it contains the `if/each/include` anchor comments), you can hydrate the existing DOM instead of re‑creating it. This wires event listeners and bindings while preserving the server‑rendered markup.

```html
<template id="user">
  <div id="root">
    <button data-on-click="onClick($event)"><span data-text="count"></span></button>
    <ul>
      <li data-if="showA">A</li>
      <li data-else>B</li>
    </ul>
    <ol>
      <li data-each="items as item, i">
        <span data-text="item"></span>#<em data-text="i"></em>
      </li>
    </ol>
  </div>
  <!-- When rendered by @klt/dom-js, if/each/include are wrapped with comment anchors like `if:start`/`if:end`. -->
  <!-- Those anchors enable fast, reliable hydration. -->
  <!-- On the server, pre-render using the same template so anchors are present in the HTML. -->
</template>
```

```js
import { hydrateTemplate } from '@klt/dom-js/template';

const root = document.querySelector('#root');
const state = { count: 1, onClick: () => console.log('clicked'), showA: true, items: ['A','B'] };

const inst = hydrateTemplate('#user', root, state);
inst.update({ count: 2, showA: false, items: ['C','D','E'] });
// later
inst.destroy();
```

Notes:
- Hydration expects anchor comments (`if:start/end`, `each:start/end`, `include:start/end`) present in the HTML between the right nodes. These are inserted when the same template is used to render.
- Dynamic `data-include` may instantiate on first update if it can’t match a static include.
- Event handlers from `data-on-*` are wired during hydration; `update()` refreshes bound values without replacing nodes.

## TypeScript

```ts
const $btn = dom<HTMLButtonElement>("#save");
$btn.on("click", (e, btn) => (btn.disabled = true));

// Delegated with typing
dom('#list').on('click', 'a.item', (e, link) => link.focus());
```

- `DOMCollection<T>` preserves element type through chains
- Event names map to DOM event types (top‑level and collection)
- Module augmentation supported for plugins

### Custom Event Typing

Augment `CustomEventMap` to type your custom events by name. Overloads on `dom.on`, `dom().on`, and `dom().once` pick up these types.

```ts
declare module '@klt/dom-js' {
  interface CustomEventMap {
    'user:login': { id: string };
    'cart:updated': { items: number };
  }
}

// Top-level targets
dom.on(document, 'user:login', (e) => {
  // e is CustomEvent<{ id: string }>
  console.log(e.detail.id);
});

// Collections
dom('#btn').on<'cart:updated'>('cart:updated', (e, el) => {
  // e.detail.items is number
  el.textContent = String(e.detail.items);
});
```

### Typed Serialization

Hint the expected shape when serializing forms for stronger types.

```ts
type FormShape = {
  user: { name: string; email?: string };
  tags: string[];
};

// Collection helper
const data1 = dom('form').serialize<FormShape>();

// Forms module
import { serializeForm, onSubmit } from '@klt/dom-js/forms';
const data2 = serializeForm<FormShape>(document.querySelector('form')!);

onSubmit<FormShape>('form', (data, ev) => {
  // data is FormShape here
  console.log(data.user.name);
});
```

## Plugin System

```js
import dom, { use } from "@klt/dom-js";

use((api) => {
  api.flash = (selector) => api(selector).animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150 });
  api.DOMCollection.prototype.highlight = function () { return this.addClass("highlight"); };
});

await dom.flash(".message");
dom(".items").highlight();
```

## Advanced Recipes

### HTTP interceptors: auth header + refresh

Inject an access token on every request, and transparently refresh on 401. Uses `withInterceptors`, `withThrowOnError`, and optional `withRetryAfter`.

```ts
import { http } from '@klt/dom-js/http'

let accessToken: string | null = null
const getToken = () => accessToken
const setToken = (t: string | null) => (accessToken = t)

let refreshing: Promise<string> | null = null
function refreshOnce() {
  if (!refreshing) {
    refreshing = (async () => {
      const r = await http.post('/auth/refresh', { /* refresh_token, cookies, etc. */ })
      const data = await r.json<{ access_token: string }>()
      setToken(data.access_token)
      return data.access_token
    })().finally(() => (refreshing = null))
  }
  return refreshing!
}

const api = http
  .withBaseUrl('/api')
  .withThrowOnError()
  .withRetryAfter()
  .withRetry({ retries: 1 })
  .withInterceptors({
    async onRequest(ctx) {
      const token = getToken()
      if (!token) return ctx
      const headers = new Headers(ctx.init.headers || {})
      headers.set('Authorization', `Bearer ${token}`)
      return { ...ctx, init: { ...ctx.init, headers } }
    },
    async onError(ctx) {
      const err = ctx.error
      // Only handle unauthorized for non-refresh endpoints; avoid loops via marker
      const httpErr = err as any
      if (!(httpErr && httpErr.status === 401) || /\/auth\/refresh/.test(ctx.url)) return
      const headers = new Headers(ctx.init.headers || {})
      if (headers.get('X-Retried') === '1') return // already retried once
      const newToken = await refreshOnce()
      headers.set('Authorization', `Bearer ${newToken}`)
      headers.set('X-Retried', '1')
      const res = await fetch(ctx.url, { ...ctx.init, headers, method: ctx.method })
      return { method: ctx.method, url: ctx.url, init: { ...ctx.init, headers }, response: res }
    }
  })

// Usage
const users = await api.get('/users').then(r => r.json())
```

Notes:
- Prefer single‑flight refresh to avoid the thundering herd. Share the refresh promise and replay waiting requests after it resolves.
- Guard against infinite loops (e.g., mark retried requests with a header, or skip refresh endpoint).
- Combine with `http.retryOnStatus([429, [500, 599]])` if you want status‑based retries.

### Templates: keyed lists (stable DOM for reorder/patch)

Use keys to preserve elements when reordering or updating items. You can specify the key inline with `by` or via `data-key`.

```html
<template id="todos-tpl">
  <ul>
    <!-- Inline key expression -->
    <li data-each="todos as t by t.id">
      <input type="checkbox" data-prop-checked="t.done">
      <span data-text="t.title"></span>
    </li>
  </ul>
</template>
```

```js
import { renderTemplate } from '@klt/dom-js/template'

const view = renderTemplate('#todos-tpl', {
  todos: [
    { id: 1, title: 'A', done: false },
    { id: 2, title: 'B', done: true }
  ]
})
document.querySelector('#app').appendChild(view.node)

// Reorder + update one item — DOM nodes with the same key are preserved
view.update({
  todos: [
    { id: 2, title: 'B!', done: false }, // same key (2): text/checked update in place
    { id: 1, title: 'A', done: false }
  ]
})
```

Alternative with `data-key`:

```html
<template id="todos-tpl2">
  <ul>
    <li data-each="todos as t" data-key="t.id">
      <span data-text="t.title"></span>
    </li>
  </ul>
  <!-- data-key is equivalent to `by t.id` in data-each -->
  <!-- Use stable keys; avoid array indexes to enable efficient diffing. -->
</template>
```

### Plugins with module augmentation (TypeScript)

Add new methods to `dom` and `DOMCollection` with type‑safe augmentation. Ship plugins as side‑effect‑free ESM.

```ts
// types.d.ts — in your app or plugin package
declare module '@klt/dom-js' {
  interface Dom {
    flash(selector: string, ms?: number): Promise<void>
  }
  interface DOMCollection<T extends Element = Element> {
    highlight(): DOMCollection<T>
  }
}
```

```ts
// plugin.ts
import dom, { use } from '@klt/dom-js'

use((api) => {
  api.flash = async (selector: string, ms = 150) => {
    const anim = api(selector).animate([{ opacity: 0 }, { opacity: 1 }], { duration: ms })
    await anim.finished
  }
  api.DOMCollection.prototype.highlight = function () {
    return this.addClass('highlight')
  }
})

// usage
await dom.flash('.message')
dom('.items').highlight()
```

## Browser Support

- Target: ES2020+ (modern evergreen browsers)
- Dependencies: Zero
- Focus: modular builds let bundlers shake out unused code

## Links

- Reference: `REFERENCE.md` (full API/exports overview)
- Architecture: `ARCHITECTURE.md`
- Docs (local): `docs/README.md` (run `npm run docs:dev`)

## Security

- Prefer `data-text` and `data-safe-html` for untrusted data
- Raw HTML setters (`data-html`, `dom().html()`) only with trusted/sanitized content.
  To make intent explicit, use one of:
  - `data-html="unsafe(expr)"` in templates
  - `data-html="pathReturningWrapper"` where code uses `unsafeHTML(x)` / `isUnsafeHTML(x)`

## Contributing

PRs welcome. Keep modules single‑purpose, SSR‑safe, and side‑effect free at import time.

## License

MIT © Dmitrij Kiltau