Skip to content

Latest commit

 

History

History
323 lines (260 loc) · 15.9 KB

File metadata and controls

323 lines (260 loc) · 15.9 KB

ae — API v1

Tiny attribute-based behavior + reactivity lib. No virtual DOM, no hydration: HTML is the source of truth, ae attaches behavior to it.

Written in TypeScript (src/, entry src/ae.ts), ships as an ES module with type declarations. npm run builddist/*.js + declarations (entry dist/ae.js), npm test runs the jsdom smoke suite, npm run test:browser runs the Playwright suite, npm run serve hosts the site at site/index.html.

This file is the prose spec. The same surface, member by member, with worked examples and live demos, is the reference site: site/reference.html (handles & lifecycle), site/reactivity.html, site/bindings.html, site/forms.html, site/lists.html — deployed at https://nft.github.io/ae/reference.html.

<button data-ae="save">Save</button>
<span data-ae="status"></span>
import { ae } from '@aeroapp/ae';

const count = ae.signal(0);

ae('status').render(el => el.textContent = `${count.value} items`);

ae('save')
  .press(() => count.value++)
  .hover(el => el.classList.add('hot'), el => el.classList.remove('hot'))
  .mount(el => console.log('save button appeared', el));

Markup

One attribute: data-ae="name". Names are free-form strings; the same name may appear on any number of elements.

Global API

ae(name) → Handle

Returns a live handle for all elements with data-ae="name" — current ones and any added to the DOM later. Handles are cached: ae('x') === ae('x').

ae(name, root) → Handle (scoped)

Same live handle, but matching, mounting, and .els only consider descendants of root (the root itself is not a match, mirroring ae.parts). Global and scoped handles compose: an element inside root gets bindings from ae('x') and ae('x', root). Nested scopes stack the same way (innermost first). Scoped handles are cached per (root, name).

The intended use is per-list behavior without global name collisions — scope to the container, recover the item in the handler with ae.itemOf:

ae('todos').list(todos, (li, todo) => {
  ae.parts(li).title.textContent = todo.text;
}, (todo) => todo.id);

const container = ae('todos').els[0];
ae('remove', container).press((btn) => {
  const todo = ae.itemOf(btn);
  todos.value = todos.value.filter((t) => t.id !== todo.id);
});

When the root itself can come and go — stamped by .list, or removed and re-added — do the setup inside .scope instead of grabbing the element imperatively:

ae('column').scope((colEl) => {
  ae('cards', colEl).list(...);
  ae('card-del', colEl).press(...);
});

.scope runs once per root and retires the scoped handles it created when the root unmounts, so a remount rebuilds them cleanly.

Two caveats, both consequences of handles being cached and append-only:

  • Set a scoped handle up once per root. Re-running the same setup for the same root stacks duplicate bindings on the cached handle. In particular, never create scoped handles inside a .list render callback (it re-runs) or a bare .mount callback of a removable root — that's exactly what .scope is for.
  • Moving an element out of the scope does not unbind it. Net-state lifecycle means moves never remount; a bound element reparented outside root keeps its scoped bindings until it actually leaves the DOM (or is renamed). Remove-and-reinsert if you need a rebind.

ae.signal(initial) → Signal

Reactive value.

const n = ae.signal(0);
n.value        // read (tracked inside render/effect/computed)
n.value = 5    // write — schedules dependents
n.value++      // works

Writes are batched: multiple writes in the same tick trigger one re-render, on the next microtask. Setting an identical value (Object.is) is a no-op.

ae.computed(fn) → Computed (read-only)

Derived value. Lazy until first read; after that a dependency write marks it stale and it re-evaluates once per flush (or on the next read — reading right after a write is always fresh). Dependents are notified only when the computed value actually changed (Object.is) — unchanged results cause no re-renders, even in diamond-shaped dependency graphs.

const total = ae.computed(() => price.value * qty.value);

fn must be pure: it can run when upstream values turn out unchanged, and a computed nobody currently subscribes to skips re-evaluation entirely until it is read again. If fn throws during a flush, the error is logged, subscribers keep the last good value, and the computed retries on the next dependency write or read; a throw during an explicit .value read propagates to the reader.

ae.parts(root) → { name: element }

Named lookup of data-ae descendants of root (the root itself is not a part). First match wins on duplicate names. Cached per root — intended for template-stamped nodes, whose structure is static; don't use it on subtrees you restructure. Part elements still participate in global handles (ae('title') binds them all; parts is just scoped access).

ae.itemOf(el) → item | undefined

The current .list item for the stamped node containing el — walks up to the nearest stamped node, so nested lists resolve to the innermost. Returns undefined outside any stamped node, or once the item has been removed. The read is tracked: calling it inside an effect/render re-runs when the item is replaced by key. This is how event handlers recover "which item was clicked" without stamping keys into the DOM:

ae('remove', container).press((btn) => {
  const todo = ae.itemOf(btn);
  todos.value = todos.value.filter((t) => t.id !== todo.id);
});

ae.isSignal(v) → boolean

True for Signal and Computed instances. The imperative helpers use this to decide between one-shot and reactive application.

ae.transition(fn) → ViewTransition | undefined

Runs fn inside document.startViewTransition, so every DOM change its signal writes cause — list stamps, removals, reorders, text — is animated by the browser. The "new" snapshot is taken only after ae has settled (flush and mount pipeline, including cascading writes from mount bindings). Where View Transitions are unsupported, fn runs plainly and undefined is returned — a no-op enhancement, not a requirement.

ae.transition(() => { cards.value = next; });

Style the animation in pure CSS (::view-transition-old/new/group). Give elements a unique view-transition-name and they morph — including across .list containers, which visually erases the cross-container remount (see Lists). Respect prefers-reduced-motion in your CSS; the browser does not do it for you.

ae.settled() → Promise<void>

Resolves once pending signal writes and everything they cascade into (list stamping, mounts, effects scheduled by mounts) have drained. What ae.transition awaits internally; also handy in tests instead of hand-rolled setTimeout ticks.

ae.effect(fn) → dispose

Auto-tracked side effect not tied to an element (logging, storage, fetch triggers). Runs immediately, re-runs when its signals change. Returns a function that stops it.

ae.observe(shadowRoot) → dispose

Extends liveness into a shadow tree — the document observer cannot pierce shadow boundaries, and neither can this (nested shadow roots each need their own call). Marked content already inside mounts immediately when the host is connected; removing the host unmounts the subtree and re-inserting it remounts; moving an element from an observed shadow root into an unobserved one unmounts it. Observation is refcounted per root: each call returns its own idempotent disposer, and the observer disconnects (and the subtree unmounts) when the last one runs.

const shadow = host.attachShadow({ mode: 'open' });
const stop = ae.observe(shadow);

Handle API

All methods return the handle — everything chains. Callbacks receive the element first: fn(el, ...) — so one handle with many elements just works.

Lifecycle

method behavior
.mount(fn) Runs fn(el) once per matching element — immediately for existing ones, and for any element added later (via one shared MutationObserver). If fn returns a function, it runs as cleanup when the element is removed from the DOM.
.scope(fn) Like .mount, but for per-root setup that creates scoped handles. Scoped handles first created inside fn (synchronously) are retired when the root unmounts, so a remount re-runs fn against fresh handles instead of stacking duplicate bindings onto cached ones. Use it whenever the root can come and go — e.g. nodes stamped by .list. A returned function runs first at teardown.

Rendering (reactive)

method behavior
.render(fn) Runs fn(el) per element, auto-tracking every signal read inside. Re-runs (for every element) when any of them changes. Also runs for elements added later. Tracking is per-run: only signals read on the last run are dependencies.

Events

Listeners are attached per element through the mount pipeline (and removed on unmount), so elements added later still need no manual re-binding — the shared MutationObserver binds them. Per-element listeners mean non-bubbling events (focus, blur, …) work, stopPropagation behaves normally, and nested data-ae elements never shadow each other. Handlers get (el, event).

method behavior
.press(fn) Activation: click for every element. Native-like Enter/Space is synthesized only for elements the browser does not natively activate (e.g. div[tabindex], [role=button]) — native buttons/links already turn Enter/Space into click (adding our own would double-fire). Enter fires on keydown (key repeats included); Space fires once on keyup, its keydown only prevents page scroll, and moving focus mid-press cancels. Keys originating in nested native controls or editable text (including inherited contenteditable) are ignored, so typing is never hijacked.
.hover(enter, leave?) pointerenter / pointerleave on the element itself — nesting-safe by construction.
.on(type, fn, opts?) Escape hatch for any DOM event type. opts is a standard AddEventListenerOptions (once, passive, capture, …) and is passed through.

Imperative helpers

Apply to every element in the handle. Sugar — everything is also doable inside .render()/.mount(). Each accepts a Reactive<T> value:

  • plain value → applied once;
  • signal / computed → applied reactively (.text(msgSignal));
  • function (el) => value → run inside an auto-tracked effect, so signals read inside make it reactive (.cls('empty', () => count.value === 0)).
method behavior
.text(v) textContent = String(v)
.cls(name, on?) classList.toggle(name, on); omit on for a one-shot plain toggle
.attr(name, v) set attribute; null/undefined/false removes it, true sets it empty
.show(on) toggle hidden

Forms

method behavior
.input(signal) Two-way binding, wired by field type: text-like inputs / <textarea> / <select>Signal<string> via value; type=checkboxSignal<boolean> via checked; type=number|rangeSignal<number> via valueAsNumber (empty field reads as NaN); type=radioSignal<string> holding the group value — give radios explicit value= attributes; the signal enforces exclusivity across all bound radios even without name= (an unbound radio sharing a native name is untouched), and an unmatched signal value unchecks all bound radios; <select multiple>Signal<string[]> — values read in option order, writes select the wanted set of values (duplicate option values toggle together), and you must write a new array (in-place mutation never notifies). Signal → field is reactive; field → signal on input/change. Writes are equality-guarded (element-wise for arrays), so echoes never move the caret. Several fields bound to one signal mirror each other. Non-form elements log an error and no-op.

Lists

<ul data-ae="todos">
  <template><li><b data-ae="title"></b> <i data-ae="due"></i></li></template>
</ul>
ae('todos').list(todos, (el, todo, i) => {
  const p = ae.parts(el);
  p.title.textContent = `${i + 1}. ${todo.text}`;
  p.due.textContent = todo.due;
}, (todo) => todo.id);
method behavior
.list(items, render, key?) Keyed list stamping. items is a Reactive<T[]> (signal/computed → reactive, function → auto-tracked, plain array → stamped once). The container's <template> (exactly one root element) is the item prototype; stamped nodes are kept at the end of the container in item order. render(el, item, index) runs per node in its own effect — re-runs when the item is replaced by key, the index moves, or any signal read inside changes. key defaults to item identity; duplicate keys log an error and fall back to a fresh node.

Reconciliation guarantees: reused keys keep their DOM node (unchanged items — same reference, same index — don't re-render); reordering moves nodes without remounting data-ae bindings inside them; vanished keys remove the node and dispose its effect; container unmount disposes everything. data-ae elements inside the template participate in the global lifecycle as usual.

These guarantees hold within one container. An item that moves between two .list containers (e.g. a kanban card changing columns) is a removal in one list and a fresh stamp in the other — a new DOM node, so transient state on the old node (focus, CSS transitions, scroll) does not travel with it. Visually this is fixable: wrap the mutation in ae.transition and give the stamped nodes a unique view-transition-name — the browser morphs the old node into the new one.

Escape hatches

member behavior
.els Plain array of currently matching elements.
.each(fn) Run fn(el) over current elements once (not reactive, not for future ones).

Semantics & guarantees

  • Liveness: one MutationObserver on document.body powers everything — mount/cleanup, late elements, and data-ae attributes that are added, removed, or renamed after insertion — plus one per shadow root opted in via ae.observe. No per-handle observers.
  • Batching: signal writes coalesce per microtask; each affected render/ effect runs at most once per flush.
  • Disposal: when an element leaves the DOM, its mount cleanups run, its render effects are disposed, and its event listeners are removed. Re-adding the element re-binds everything. Disposal is absolute: a disposed effect never runs again, even if it was already queued for the current flush. No manual unbinding, no leaks.
  • Net-state lifecycle: mount/cleanup reflect the net DOM change per task, not intermediate mutations. Moving a connected element to another parent does not remount it; renaming data-ae a→b→a is a no-op; multiple renames in one task bind the final name exactly once.
  • Fault isolation: a throwing binding, cleanup, or effect re-run is logged via console.error and does not prevent the others from running. The one exception: an ae.effect whose initial run throws propagates the error synchronously to the caller and leaves no trace (nothing subscribed, nothing queued). Element bindings (.render/.mount) are always isolated, including their first run. A computed that throws during a flush is logged too — its subscribers keep the last good value and it recovers on the next dependency write or read; only an explicit .value read surfaces the error to the reader.
  • Runaway guard: an effect that writes a signal it also reads trips a circuit breaker after 100 flush cycles (with a console.error) instead of hanging the tab.
  • Templating stays native: .list() stamps from a real <template> element — no template syntax, no virtual DOM; reconciliation is keyed node reuse.