Rendering & events
Eight handle methods that push values into elements and pull events back out. Every one of them attaches per element through the mount pipeline, so elements added later bind themselves, removed ones tear down completely, and you never re-bind by hand.
.render is the primitive: your callback runs inside an auto-tracked effect. .text, .cls, .attr and .show are sugar over it — anything they do you can write by hand inside a render — and the three event methods are .mount plus an addEventListener paired with its removal. All eight append a binding and return the handle, so calls chain, and calling one twice adds a second binding rather than replacing the first.
.render(fn)
Runs fn(el) once per matching element inside its own auto-tracked effect. Every signal read during the run becomes a dependency of that element's effect, and a write re-runs it — batched, so one flush means at most one run. Elements connected later get their own effect when they mount; an element that leaves the DOM has its effect disposed, and a disposed effect never runs again, even if a write had already queued it.
- fn
- the render callback, called with the element; its return value is ignored — the binding's cleanup is the effect disposal, so put teardown in .mount
- returns
- the same
Handle, for chaining
const user = ae.signal({ name: 'Ada', unread: 3 });
ae('inbox').render((el) => {
el.textContent = `${user.value.name} — ${user.value.unread} unread`;
el.classList.toggle('has-unread', user.value.unread > 0);
});
user.value = { ...user.value, unread: 0 }; // re-renders on the next microtask
Reach for .mount when the work is one-time setup, and for .render when the element has to follow a value:
- .mount(fn)
- once per element, tracks nothing, may return a cleanup
- .render(fn)
- once per element and again per change, tracks every signal it reads
console.error instead of hanging the tab — see tracking rules.[ae] binding threw:, a throw on a later run as [ae] effect threw:. Either way the other elements, and the other bindings on this element, keep working.
.text(v)
Sets textContent = String(v) on every element in the handle. Like the other three helpers it takes the Reactive<T> union, so one method covers a constant, a signal and a derived expression.
- v
- a plain value (applied once, at mount), a
Signal/Computed(reactive), or(el) => value(run in an auto-tracked effect, so signals read inside make it reactive) - returns
- the same
Handle, for chaining
const count = ae.signal(0);
const label = ae.computed(() => `${count.value} item${count.value === 1 ? '' : 's'}`);
ae('brand').text('ae'); // plain value — set once, at mount
ae('count').text(count); // signal — reactive
ae('summary').text(label); // computed — reactive
ae('badge').text(() => count.value > 9 ? '9+' : count.value); // function — tracked
textContent replaces every child of the target, so .text is for leaf elements. It never parses markup either: a value containing <b> shows up as those three characters, which is what makes it injection-safe.
.cls(name, on?)
Two methods in one signature. With on supplied it is classList.toggle(name, !!value) over the reactive union — the class follows the value, and any truthy value counts. With on omitted it is a one-shot classList.toggle(name) per element at mount time: no effect, no tracking, no reactivity.
- name
- the class to toggle
- on
- optional; value, signal, or
(el) => boolean. Omit it for the one-shot flip - returns
- the same
Handle, for chaining
const filter = ae.signal('all');
const busy = ae.signal(false);
ae('tab').cls('active', (el) => el.dataset.filter === filter.value); // per element
ae('form').cls('is-busy', busy);
ae('legacy-row').cls('js-enhanced'); // one-shot: flipped once, at mount
.cls('x', true).tab example does.
.attr(name, v)
Sets one attribute from a reactive value, with the absence of the attribute as a first-class state — which is what HTML's boolean attributes actually need.
- name
- the attribute to write
- null undefined false
removeAttribute(name)- true
setAttribute(name, '')— the empty string, as indisabled=""- anything else
setAttribute(name, String(value))- returns
- the same
Handle, for chaining
const busy = ae.signal(false);
const user = ae.signal({ avatar: null });
ae('save')
.attr('disabled', busy) // true → disabled="" , false → removed
.attr('aria-busy', () => String(busy.value)); // ARIA wants the string "true"/"false"
ae('avatar').attr('src', () => user.value.avatar ?? null); // null removes the attribute
.attr('aria-expanded', true) writes aria-expanded="", not the string "true" that assistive technology reads. Map it yourself, as above..attr('value', …) only seeds a field's initial value and stops mattering once the user types; for form state use .input, which writes the property.
.show(on)
Toggles the hidden property — el.hidden = !value. The element stays in the DOM, so it stays mounted and its other bindings keep running; only its rendering is suppressed. To take an element out of the document entirely, drive a .list instead.
- on
- value, signal, or
(el) => boolean; truthy shows, falsy hides - returns
- the same
Handle, for chaining
<ul data-ae="todos">
<li data-ae="empty">Nothing left.</li>
<template><li data-ae="row"></li></template>
</ul>
ae('empty').show(() => todos.value.length === 0);
ae('spinner').show(busy);
hidden hides through the user-agent rule [hidden] { display: none }, and any display your own CSS sets on that element wins over it. An element carrying a flex or grid utility class stays visible while hidden; put the display utility on a wrapper, or add [hidden] { display: none !important } to your stylesheet.live
One signal, four helpers: .text writes the count, .cls tints it once it moves, .attr adds and removes disabled, and .show reveals Reset and the badge.
.press(fn)
Activation, not click. Every element gets a click listener; Enter and Space are synthesized only for elements the browser does not activate on its own. button, a[href], input, select, textarea, summary and elements whose contenteditable is "", true or plaintext-only are excluded, for one of two reasons: on button, summary, a[href] and the button-like inputs the browser already turns those keys into a click, so a second listener would double-fire; in a text field or an editable region the keys mean text entry, and a keydown that calls preventDefault on Space would hijack typing.
- fn
- handler, called with the element and the originating event — a
MouseEventfor a click, aKeyboardEventfor Enter and Space - returns
- the same
Handle, for chaining
The synthesized half copies what a native button does:
- click
- fires for every element, native or not
- Enter
- fires on
keydownand callspreventDefault; a held key repeats, so it fires again per repeat - Space
- fires once on
keyup; itskeydownonly callspreventDefault, to stop the page scrolling - blur
- disarms a pending Space, so moving focus mid-press cancels the activation
- nested keys
- keys whose target is a nested native control or editable text (inherited
contenteditableincluded) are ignored and left un-prevented, so typing inside a pressable card is never hijacked
<div data-ae="tile" role="button" tabindex="0">Archive</div>
<button data-ae="tile">Archive</button>
const pinned = ae.signal(false);
const via = ae.signal('');
ae('tile').press((el, ev) => {
pinned.value = !pinned.value;
via.value = ev.type; // 'click', or 'keydown' (Enter) / 'keyup' (Space)
});
div you make pressable still needs tabindex="0" so it can be focused and a role so it is announced as a control — both written by you. When a real <button> fits, use one and get all of it for free.live
Both controls carry data-ae="b-p-tile", so one .press call drives both. Click them — then press Tab until the div has focus and hit Space or Enter.
.hover(enter, leave)
Binds pointerenter and pointerleave on the element itself. Neither event bubbles, which makes nesting safe by construction: moving the pointer from a card onto a button inside it does not fire the card's leave, and the button's own .hover never reaches the card. Both callbacks are optional — pass undefined for the one you do not need.
- enter
- optional; runs on
pointerenter - leave
- optional; runs on
pointerleave - returns
- the same
Handle, for chaining
const preview = ae.signal(null);
ae('card').hover(
(el) => { preview.value = ae.itemOf(el); },
() => { preview.value = null; },
);
ae('row').hover(undefined, (el) => el.classList.remove('menu-open')); // leave only
:hover is still cheaper. Use .hover when the pointer has to change a signal, not when it only has to change a colour.
.on(type, fn, opts?)
The escape hatch: any DOM event type, attached per element through the mount pipeline and removed on unmount. Because nothing is delegated, non-bubbling events such as focus, blur and pointerenter work, stopPropagation behaves the way the platform says it does, and nested data-ae elements never shadow each other's listeners.
- type
- any event name; a name in
HTMLElementEventMappicks the typed overload - fn
- handler, called with the element and the event
- opts
- a standard
AddEventListenerOptions—once,passive,capture— passed straight through toaddEventListenerand to its removal - returns
- the same
Handle, for chaining
const draft = ae.signal('');
const hint = ae.signal(false);
ae('draft')
.input(draft)
.on('keydown', (el, ev) => { if (ev.key === 'Enter') add(draft.value); })
.on('focus', () => { hint.value = true; }) // focus does not bubble —
.on('blur', () => { hint.value = false; }); // a per-element listener gets it
ae('feed').on('scroll', onScroll, { passive: true });
ae('tour').on('click', () => { seen.value = true; }, { once: true });
ae('picker').on('item:picked', (el, ev) => { chosen.value = ev.detail.id; });
{ once: true } is once per element per mount. The listener is attached fresh every time the element enters the DOM, so an element that is removed and re-inserted — a .list node whose key vanished and came back, for instance — fires again.HTMLElementEventMap. A custom event name falls through to the string overload, where ev is a plain Event; narrow it to CustomEvent yourself to reach detail.live
Focus the field and tab away: focus and blur never bubble, yet the per-element listeners see both. The button next to it is bound with { once: true }.