reference · 4 of 5

Forms

One method covers every field on the page. .input(signal) reads the element's own type and wires the property that field actually speaks — value, checked, valueAsNumber, the group value, the selected set — so your signal holds a real string, boolean, number or array instead of whatever the DOM would have handed you. Five wirings, no configuration, and no parseInt anywhere.

.input(signal)

Two-way binding between one signal and every element carrying the name. It is sugar over .render plus .on('input'): one tracked effect writes the field when the signal changes, and two listeners — input and change — write the signal when the user edits. The field's own type picks the wiring; nothing else configures it.

.input<T extends string | number | boolean | string[]>(sig: Signal<T>): this
sig
a writable Signal — the value lives here, the field is a view of it. A Computed has no setter, so it is not accepted
returns
the same Handle, so .input(draft).on('keydown', …) chains
input · textarea · select
Signal<string> via value
input[type=checkbox]
Signal<boolean> via checked
input[type=number|range]
Signal<number> via valueAsNumber
input[type=radio]
Signal<string> holding the group value
select[multiple]
Signal<string[]> via selectedOptions
<input data-ae="draft" placeholder="New item…">
<button data-ae="add">Add</button>

const draft = ae.signal('');
const items = ae.signal([]);

ae('draft').input(draft)                       // both directions
  .on('keydown', (el, ev) => { if (ev.key === 'Enter') add(); });

ae('add').press(() => add());

function add() {
  const text = draft.value.trim();             // read the signal, not el.value
  if (!text) return;
  items.value = [...items.value, text];
  draft.value = '';                            // clears the field
}

The binding attaches through the normal mount pipeline, so a field stamped later by .list binds itself, and a field removed from the DOM stops in both directions at once — it no longer receives signal writes and no longer writes to the signal. Every matching element gets its own binding, so several fields sharing one name and one signal mirror each other.

Guarantee — every signal → field write is equality-guarded: Object.is for scalars, element-wise for the array modes. A field you are typing in is never reassigned the value it already holds, so the flush after your keystroke cannot move the caret to the end.
Gotcha — the signal wins at bind time. The first write runs the moment the binding attaches, so a value="…" in your markup is overwritten by sig.value. Seed the signal from your data; treat the attribute as a placeholder for the pre-JS paint.
Gotcha — a target that is not an <input>, <textarea> or <select> logs [ae] .input target is not a form field: with the element and no-ops. Only that one binding drops out — the handle's other bindings still attach.

live · every wiring at once

Edit anything. The readout is the signals themselves, printed with their real types — watch 3 stay a number, the checkbox stay a boolean, and the regions stay an array.

Tier — radio, no name= anywhere

        

input · textarea · select Signal<string>

The default wiring, and the one every field falls back to. Any <input> that is not a checkbox, radio, number or range — text, search, email, password, url, date, color, and anything the browser does not recognise, which it reports as text — plus every <textarea> and every single <select> binds el.value to a Signal<string>.

<input> · <textarea> · <select> ↔ Signal<string> via el.value
reads
el.value on input and change
writes
el.value = String(v), skipped when it already matches
<input data-ae="query" type="search" placeholder="Search…">
<textarea data-ae="note" rows="3"></textarea>
<select data-ae="role">
  <option value="admin">Admin</option>
  <option value="editor">Editor</option>
</select>

const query = ae.signal('');
const note = ae.signal('');
const role = ae.signal('editor');

ae('query').input(query);
ae('note').input(note);
ae('role').input(role);

// derived state, never a second copy of the string
const hits = ae.computed(() =>
  INDEX.filter((row) => row.title.includes(query.value)));
Gotcha — a text field always reads back a string. Bind a Signal<number> to one and the first write shows String(v), but the first keystroke replaces the number with a string and every consumer downstream starts doing string math. If you want numbers, use type=number.
Gotcha — a single <select> can only show a value some <option> carries. Write an unmatched string and the browser leaves the control with nothing selected while the signal keeps the value, so the two disagree until the next edit. Populate the options first, then set the signal.

input[type=checkbox] Signal<boolean>

A checkbox binds el.checked, so the signal is a real boolean you can feed straight into .show, .cls or an .attr without a truthiness dance.

<input type="checkbox"> ↔ Signal<boolean> via el.checked
reads
el.checked on input and change
writes
el.checked = !!v, skipped when it already matches
<label><input data-ae="agree" type="checkbox"> I accept the terms</label>
<button data-ae="continue">Continue</button>

const agree = ae.signal(false);

ae('agree').input(agree);
ae('continue').attr('disabled', () => (agree.value ? null : true));
Gotcha — the value= attribute is ignored here. A checkbox speaks checked, so value="yes" changes nothing and any truthy signal value checks the box. ae never touches indeterminate either — set and clear it yourself.
Gotcha — checkbox mode is boolean-only, so a bank of boxes collecting a set of values is not one Signal<string[]>. Give each box its own boolean signal, or keep the array yourself with .on('change') and replace it — an array-typed signal belongs to <select multiple>.

input[type=number|range] Signal<number>

Both numeric controls bind el.valueAsNumber, so arithmetic downstream is arithmetic — no parseInt, no accidental "3" + 1 === "31". Point a number field and a range at the same name and they drive one signal, each following the other.

<input type="number"> · <input type="range"> ↔ Signal<number> via el.valueAsNumber
reads
el.valueAsNumberNaN while the field is empty or unparseable
writes
el.valueAsNumber = Number(v); writing NaN clears the field
<input data-ae="seats" type="number" min="1" max="50">
<input data-ae="seats" type="range"  min="1" max="50">
<span data-ae="total"></span>

const seats = ae.signal(3);

ae('seats').input(seats);                  // both fields, one signal

// an empty field reads as NaN — guard once, at the edge
const count = ae.computed(() =>
  Number.isNaN(seats.value) ? 0 : seats.value);

ae('total').text(() => `$${count.value * PRICE_PER_SEAT}`);
Gotcha — clear the field and the signal holds NaN, which poisons every sum it touches and prints as "NaN" through .text. Guard it where the value enters your logic, as above. The reverse works too: assigning NaN empties the field, and since Object.is(NaN, NaN) is true, the echo guard leaves an empty field alone instead of rewriting it on every flush.
Gotcha — ae never clamps or validates. min, max, step and :invalid stay the browser's business: a range control keeps itself inside its bounds, but a number input hands you whatever was typed — 999 in a max="50" field reaches the signal unchanged.

input[type=radio] Signal<string>

Give every radio in the group the same data-ae name and an explicit value=: the signal then holds the group's value. Checking a radio writes its own value to the signal; a signal write re-checks the radio whose value matches. Each radio gets its own binding that only ever sets its own checkedness, which is why the group behaves like a group without ae knowing anything about groups.

<input type="radio" value="…"> ↔ Signal<string> via the checked radio's value
reads
the value of the radio that fired, and only while it is checked
writes
per radio: el.checked = (el.value === v)
<label><input data-ae="tier" type="radio" name="tier" value="free">  Free</label>
<label><input data-ae="tier" type="radio" name="tier" value="pro">   Pro</label>
<label><input data-ae="tier" type="radio" name="tier" value="scale"> Scale</label>
<span data-ae="price"></span>

const tier = ae.signal('pro');   // the group's value lives here

ae('tier').input(tier);
ae('price').text(() => `$${PRICE[tier.value] ?? 0}/mo`);

tier.value = '';                 // no radio matches — all of them uncheck
Gotcha — set value= on every radio. A radio without one reports "on", so the whole group would read and write the same string and the signal could never tell them apart.
Guarantee — exclusivity comes from the signal, not from name=. Bound radios stay mutually exclusive with no name attribute at all (the live demo above has none), and a signal value no radio carries unchecks every bound radio; an unbound radio that merely shares a native name is never touched. Keep name= anyway — form submission and native arrow-key navigation still want it. Both input and change fire on the radio you just checked, and both assign the same string — the signal's own Object.is guard makes the second one a no-op, so the pair lands as a single notification. Events from a radio that is not checked are ignored outright.

select[multiple] Signal<string[]>

A multi-select binds the selection to a Signal<string[]>. Reads collect selectedOptions in option order; writes select every option whose value appears in the array and deselect the rest. The array is treated as a set, so duplicate option values toggle together.

<select multiple> ↔ Signal<string[]> via selectedOptions, in option order
reads
a fresh array of the selected values, always in document order
writes
opt.selected = set.has(opt.value) for every option; skipped when the selection already matches element-wise
<select data-ae="regions" multiple size="3">
  <option value="eu">EU</option>
  <option value="us">US</option>
  <option value="apac">APAC</option>
</select>

const regions = ae.signal(['eu']);

ae('regions').input(regions);

// toggling from code: replace the array, never mutate it
const toggle = (v) => {
  regions.value = regions.value.includes(v)
    ? regions.value.filter((r) => r !== v)
    : [...regions.value, v];
};
Gotcha — a new array, or nothing happens. regions.value.push('us') mutates the array in place, the setter sees the same reference, Object.is says equal, and no subscriber is ever notified. Same rule as every other signal write: replace, don't mutate.
Detail — order is the options', not yours. Write ['us', 'eu'] and both options select, but the next read comes back as ['eu', 'us']. Compare selections as sets, not as strings.
Guarantee — one gesture fires input and change with two distinct arrays that Object.is cannot dedupe, so this mode compares element by element in both directions. An unchanged selection therefore never re-notifies and never feeds the flush breaker.

live · mutate vs. replace

Push into the array and nothing moves — not the selection, not the readout. Assign a copy and the same contents suddenly land everywhere.

signal →