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.
- 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>viavalue- input[type=checkbox]
Signal<boolean>viachecked- input[type=number|range]
Signal<number>viavalueAsNumber- input[type=radio]
Signal<string>holding the group value- select[multiple]
Signal<string[]>viaselectedOptions
<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.
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.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.<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.
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>.
- reads
el.valueoninputandchange- 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)));
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.<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.
- reads
el.checkedoninputandchange- 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));
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.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.
- reads
el.valueAsNumber—NaNwhile the field is empty or unparseable- writes
el.valueAsNumber = Number(v); writingNaNclears 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}`);
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.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.
- 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
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.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.
- 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];
};
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.['us', 'eu'] and both options select, but the next read comes back as
['eu', 'us']. Compare selections as sets, not as strings.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 →