Skip to content

Text input

`input` is a single-line text field. The engine owns its editing state per

id — text, caret, horizontal scroll — so an app’s render stays a pure description of what it wants on screen, and a keystroke never has to walk back through app state to become a pixel.

Everything here lives in packages/core/src/mjsx.js: the t === 'input' branch of draw(), and UI.key / UI.type / UI._editKey / UI._inputStroke / the focus* family on UI. Snippets are lifted from there and from examples/input/app.jsx.

For the keyboard that usually sits below the field, see keyboards.md.

<input id="name" size={2} placeholder="tap to type" />
The same field page on round glass, unfocused: the corners the layout cannot use.
Round, 240×240. The same field page on round glass, unfocused: the corners the layout cannot use.
Fields at rest: muted border, placeholder text, no caret — nothing is focused.
Portrait, 320×480. Fields at rest: muted border, placeholder text, no caret — nothing is focused.
A focused field on the 172x320 panel — the narrowest glass in the fleet.
Portrait, 172×320. A focused field on the 172x320 panel — the narrowest glass in the fleet.
The focused field: accent border, caret at the insertion point, placeholder gone.
Portrait, 320×480. The focused field: accent border, caret at the insertion point, placeholder gone.

The whole page above is seven nodes:

h('box', { h: gfx.height(), pad: em(1), gap: em(0.75) }, [
h('text', { text: 'PROFILE', size: 2, align: 'center', color: UI.theme.accent }),
h('text', { text: 'NAME', size: 1, color: UI.theme.muted }),
h('input', { id: 'name', size: 2, placeholder: 'tap to type' }),
h('text', { text: 'PIN (password, maxLen 6)', size: 1, color: UI.theme.muted }),
h('input', { id: 'pin', size: 2, password: true, maxLen: 6, placeholder: '******' }),
h('text', { text: 'NOTE (long text scrolls in the field)', size: 1, color: UI.theme.muted }),
h('input', { id: 'note', size: 2, placeholder: 'anything' })
])
A focused field on the 172x320 panel — the narrowest glass in the fleet.
Portrait, 172×320. A focused field on the 172x320 panel — the narrowest glass in the fleet.
The focused field: accent border, caret at the insertion point, placeholder gone.
Portrait, 320×480. The focused field: accent border, caret at the insertion point, placeholder gone.
The same field page on round glass, unfocused: the corners the layout cannot use.
Round, 240×240. The same field page on round glass, unfocused: the corners the layout cannot use.
Fields at rest: muted border, placeholder text, no caret — nothing is focused.
Portrait, 320×480. Fields at rest: muted border, placeholder text, no caret — nothing is focused.
PropDefaultMeaning
id'_input'The key its state is stored under. Two nodes with the same id are the same field.
size1Text size. Drives the field’s whole height.
valuePresent makes the field controlled: the app owns the text.
defaultValue''Initial text for an uncontrolled field, read once.
placeholderMuted text drawn when the field is empty.
passwordfalsyMask every character with *.
maxLenRefuse inserts at this length.
onChange(text)Fired when the text actually changed.
onSubmit(text)Fired by Enter, which then blurs.
labelNot drawn by the field. Carried for the full-display keyboard’s header.
exclusivefalsyAsk the keyboard to take the whole display while this field has focus.
focusabletruefalse keeps the field out of the focus order.
wfillsFixed width, used only when narrower than what is available.
padmax(4, floor((flh(size) + 2) / 3))Inner padding; also sets the field’s height.
bgUI.theme.keyFill.
borderUI.theme.mutedUnfocused border. Focused is always UI.theme.accent.
colorUI.theme.textText colour.

Height is derived, never passed:

var isz = p.size || 1;
var ilh = flh(isz) + 2;
var ipd = p.pad === undefined ? Math.max(4, Math.floor(ilh / 3)) : p.pad;
var ih = ilh + ipd * 2;

The same three lines run in measure(), so a field reports its height without being drawn.

On first draw the field allocates a record in UI._inputs:

var iid = p.id || '_input';
var ist = UI._inputs[iid];
if (!ist) {
ist = UI._inputs[iid] = {
text: p.value !== undefined ? String(p.value)
: (p.defaultValue === undefined ? '' : String(p.defaultValue)),
cur: 1e9, sx: 0, bt: 0, follow: 1
};
}
if (p.value !== undefined) ist.text = String(p.value);
ist.p = p;
  • text — the current string (uncontrolled fields only; a controlled field has it overwritten every render).
  • cur — caret index. Starts at 1e9 and is clamped to the text length on first draw, so a field created with text starts with the caret at the end.
  • sx — horizontal scroll within the field.
  • bt — the blink epoch, reset on every edit so the caret is solid the instant you type.
  • follow — whether the view chases the caret. A deliberate drag-scroll clears it.
  • p — the props from the last draw. This is what lets UI._editKey see maxLen and onSubmit, and what the full-display keyboard’s mirror copies from.

Because the record is keyed by id and not by node identity, two input nodes sharing an id are one field — the mirror depends on exactly this. UI.mount() clears _inputs and _focus, so a pushed bundle starts clean.

Passing value makes the field controlled: the engine stops owning the text and copies yours in on every render. You then need onChange to keep it moving.

<input id="ssid" value={UI.state.ssid}
onChange={function (v) { UI.set({ ssid: v }); }} />

Omit value and the field is uncontrolled — the engine keeps the text, and onChange / onSubmit are notifications rather than a required loop. This is what every example does, because it means typing never dirties app state and never rebuilds the form. From examples/input/app.jsx:

/* The whole form reuses across frames: focus borders, carets and
typed text are all DRAW-time reads, so typing and tabbing never
rebuild it -- only the chip selections and the submit line do. */
var form = UI.memo('inputForm', [kb, pos, UI.state.last], function () { ... });

Read the text of an uncontrolled field at submit time, from the argument:

onSubmit={function (v) { UI.set({ last: p.label + ': ' + v }); }}

A field registers one whole-stroke handler, not a set of gestures. What the stroke meant is classified at the moment the finger leaves the tap slop, by the dominant axisUI._inputStroke:

StrokeModeEffect
Stayed within 6px (DRAG_SLOP)0 → tapFocus the field and place the caret where the finger landed.
Held 400ms, then moved2Focus, then walk the caret under the finger.
Mostly vertical, inside a scroll zone3Hand the stroke to the zone — fling included. The field is not focused.
Mostly horizontal, text overflows1Scroll the field’s own text sideways. follow clears.
Mostly horizontal, text fits2Focus and place the caret.
if (g.mode === 0) {
if (sys.millis() - g.t0 >= 400) { g.mode = 2; self.focus(iid); }
else if (dxs > DRAG_SLOP || dxs < -DRAG_SLOP ||
dys > DRAG_SLOP || dys < -DRAG_SLOP) {
var ax = dxs < 0 ? -dxs : dxs, ay = dys < 0 ? -dys : dys;
if (ay > ax && zn) g.mode = 3;
else if (st.text.length * gm.adv > gm.w - gm.pad * 2) g.mode = 1;
else { g.mode = 2; self.focus(iid); }
}
}

Two consequences worth stating plainly:

  • A form full of fields still scrolls, wherever the finger lands. A vertical drag that starts on a field is a scroll, not a focus.
  • Focus happens on tap or hold only. A scroll passing over a field must not focus it and summon a keyboard.

Placing the caret is arithmetic on the advance width, clamped to the text:

function place() {
var ci = Math.round((lx - gm.pad + st.sx) / gm.adv);
var n = st.text.length;
st.cur = ci < 0 ? 0 : (ci > n ? n : ci);
st.bt = sys.millis();
st.follow = 1;
}

the caret placed inside existing text

The caret five positions back from the end of HELLO WORLD — here by five ArrowLeft presses, identically reachable by tapping between two glyphs. The next character typed is inserted there, not appended.

Overflow, and the view that follows the caret

Section titled “Overflow, and the view that follows the caret”

Text longer than the field scrolls inside it. The view chases the caret while the caret is what last moved:

if (ifoc && ist.follow) {
if (icx - ist.sx > innW - iadv) ist.sx = icx - innW + iadv;
if (icx - ist.sx < 0) ist.sx = icx;
}

text overflowing the field

A sentence longer than the field. The end is shown because the caret is there; the start has scrolled out to the left. A horizontal drag clears follow, so the view is not yanked back the moment you look at the beginning.

Drawing is clipped to the field’s inner rect, and the text is centred on its cap-ink height rather than its line box:

/* odd remainder goes ABOVE the text: caps carry their visual weight
high, so erring low reads as centred while erring high reads as
floating -- and the field's own border makes a high bias obvious */
var ity = y + Math.ceil((ih - fink(isz)) / 2);
<input id="pin" size={2} password={true} maxLen={6} placeholder="******" />

password masks at draw time only — the real string is what onChange and onSubmit see, and the caret tracks the real length:

var shown = ist.text;
if (p.password) {
var msk = '';
for (var mi = 0; mi < shown.length; mi++) msk += '*';
shown = msk;
}

a masked password field

password={true}: four characters typed, four asterisks, caret after them.

maxLen refuses the insert and nothing else — the keystroke is still consumed by the editor, so it does not leak through to UI.onKey:

if (k.length === 1) {
if (!(pp.maxLen && v.length >= pp.maxLen)) {
nv = v.slice(0, c) + k + v.slice(c);
st.cur = c + 1;
}
}

a field at its maxLen

maxLen: 6 after fourteen keystrokes. The field holds six characters; the other eight changed nothing.

placeholder draws in UI.theme.muted, and only when there is nothing to show — note it tests the masked string, so a password field’s placeholder disappears on the first character:

if (!shown.length && p.placeholder) {
gfx.text(x + ipd, ity, isz, UI.theme.muted, p.placeholder);
}

onChange(text) fires only when the text actually changed — caret moves, focus walks and blocked maxLen inserts do not call it:

if (nv !== null) {
st.text = nv;
if (pp.onChange) pp.onChange(nv);
}

onSubmit(text) is Enter, and Enter also blurs:

else if (k === 'Enter') {
if (pp.onSubmit) pp.onSubmit(st.text);
this.blur();
return true;
}

Escape blurs without submitting.

Because a submit is just the Enter key, any control can raise one. From examples/wifi/app.jsx, a JOIN button that submits the field beside it:

<input id="psk" size={2} password={true} placeholder="password"
onSubmit={function (v) { join(picking, v); }} />
<row gap={em(0.5)}>
<Button label="JOIN" size={1} bg={UI.theme.accent}
onTap={function () { UI.key('press', 'Enter'); }} />
<Button label="CANCEL" size={1}
onTap={function () { UI.blur(); UI.set({ picking: null }); }} />
</row>
The same wifi password step on round glass: the same unnamed Keyboard resolves to T9 here, and at this height it takes the whole display and mirrors the field.
Round, 240×240. The same wifi password step on round glass: the same unnamed Keyboard resolves to T9 here, and at this height it takes the whole display and mirrors the field.
examples/wifi after tapping a secured network: the password field appears and the keyboard comes up with no layout named — the app asked for "whatever types best here", and 320px of glass buys QWERTY.
Portrait, 320×480. examples/wifi after tapping a secured network: the password field appears and the keyboard comes up with no layout named — the app asked for "whatever types best here", and 320px of glass buys QWERTY.

Focus: order, Tab, arrows, scroll-into-view

Section titled “Focus: order, Tab, arrows, scroll-into-view”

UI.focus(id), UI.blur() and UI.focused() are the whole app-facing surface. focus() resets the blink, re-enables follow, clamps the caret, honours the field’s exclusive prop and queues a reveal:

focus: function (id) {
if (this._focus === id) return;
this._focus = id;
this._exclusive = false;
var st = this._inputs[id];
if (st) {
st.bt = sys.millis();
st.follow = 1;
if (st.cur > st.text.length) st.cur = st.text.length;
if (st.p && st.p.exclusive) this._exclusive = true;
}
this._reveal = id;
this._dirty = true;
if (this.onFocusChange) this.onFocusChange(id);
}

Order is content order, not paint order. Every field records where it drew, in content coordinates — screen y plus its scroll zone’s offset — so a field scrolled out of sight keeps a stable position in the cycle:

if (p.focusable !== false) {
ist.nav = {
zone: UI._curZone || null,
cy: y + (UI._curZone ? (UI._scroll[UI._curZone] || 0) : 0),
h: ih, seen: UI._frame
};
UI._focusables.push(iid);
}

focusNext(dir) collects every field whose home still exists — a zone-owned field qualifies while its zone does, a fixed one while it drew this frame — sorts by nav.cy and wraps. Tab and shift-Tab step it; ArrowUp/ArrowDown do too. Either can be switched off, and the key then falls through to UI.onKey for the app to use:

/* Which keys walk the focus order while an input holds focus. Both on
by default; an app that needs Tab or the vertical arrows for itself
turns the flag off and the key falls through to UI.onKey instead --
as does ANY key the editor has no meaning for. */
focusNav: { tab: true, arrows: true },

Reaching a field below the fold works, because _revealFocus() runs after the render and solves for the scroll offset that puts the field a small margin inside the visible part of its zone — where “visible” already excludes whatever UI.inset() says an overlay is covering:

var visTop = z.y > this._insetTop() ? z.y : this._insetTop();
var gh = gfx.height() - this._insetBot();
var visBot = z.y + z.h < gh ? z.y + z.h : gh;

examples/input/app.jsx has a field labelled exactly for this:

<Field id="tag" label="TAG (tab reaches me below the fold)" />
examples/input — Text input, every way in at once.
Round, 240×240. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Portrait, 172×320. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Landscape, 280×240. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Portrait, 320×480. examples/input — Text input, every way in at once. ▶ Run it

focusable={false} opts a field out of the cycle entirely. The full-display keyboard’s mirror uses it, so a takeover does not double every field in the order.

exclusive on a field asks the keyboard to take the whole display while that field has focus:

<Field id="full" label="FULL (exclusive: keyboard takes the display)" exclusive={true} />
The auto-exclusive state: on a 172x320 panel a docked keyboard's keys land under 30px, so the keyboard takes the whole display and mirrors the focused field above the keys (the x key closes it).
Portrait, 172×320. The auto-exclusive state: on a 172x320 panel a docked keyboard's keys land under 30px, so the keyboard takes the whole display and mirrors the focused field above the keys (the x key closes it).
exclusive={true} asks for the same takeover on a panel that had room to dock: the field is mirrored above the keys and the page behind is gone.
Portrait, 320×480. exclusive={true} asks for the same takeover on a panel that had room to dock: the field is mirrored above the keys and the page behind is gone.

The engine also sets the flag itself, in two situations, both of which mean no amount of scrolling can help:

/* A fixed field is visible where it is or not at all: if an overlay
covers it, the only way to type into it is the exclusive
full-display keyboard with its mirror. */
if (st.nav.cy < this._insetTop() ||
st.nav.cy + st.nav.h > gfx.height() - this._insetBot()) {
if (!this._exclusive) { this._exclusive = true; this._dirty = true; }
}

and, for a field inside a zone, when the overlay leaves less viewport than the field is tall. (The keyboard has a third trigger of its own — keys under 30px — described in keyboards.md.)

UI.exclusive() reports the result, so a custom keyboard can honour it too:

exclusive: function () { return !!(this._exclusive && this._focus); },

The mirror is not a copy. The takeover draws a second input node with the same id, which by the rule at the top of this page makes it the same field — same text, same caret, same handlers, pulled from the props the original stashed in ist.p:

h('input', { id: UI._focus, size: xsz, password: xp.password,
maxLen: xp.maxLen, placeholder: xp.placeholder, label: xp.label,
value: xp.value, onChange: xp.onChange, onSubmit: xp.onSubmit,
focusable: false })

text typed into the mirror

Typed into the mirror; the original already has it. There is no synchronisation step to get wrong because there are not two states.

a password field mirrored

password travels with it, so a masked field stays masked in the takeover.

label exists for this path. The field itself never draws it — it is carried so the keyboard’s opt-in header has something to title the takeover with, falling back to placeholder.

Every keystroke reaches a field the same way, whatever produced it: a physical keyboard, the built-in Keyboard, a host’s native OSK outside the JS VM, or an app’s own JSX. There is one entry point, and it is not input-specific:

key: function (type, key) {
/* A focused input consumes the keyboard: presses edit it, and none of
the stroke reaches UI.onKey -- an app shortcut must not fire off a
character someone was typing into a field. */
if (this._focus && this._inputs[this._focus]) {
if (type === 'press' && !this._editKey(key) && this.onKey) this.onKey(type, key);
return;
}
if (this.onKey) this.onKey(type, key);
}

So: while a field is focused, down and up are swallowed outright, and a press goes to the editor first. Only if the editor has no meaning for that key does it reach UI.onKey — which is how an app keeps its own shortcuts without stealing characters from someone mid-word.

What the editor claims:

KeyEffect
any single characterInsert at the caret, unless maxLen is reached.
BackspaceDelete before the caret.
DeleteDelete after the caret; the caret does not move.
ArrowLeft / ArrowRightMove the caret one character.
Home / EndCaret to start / end.
Tab / ShiftTabNext / previous field, if focusNav.tab.
ArrowUp / ArrowDownNext / previous field, if focusNav.arrows.
EnteronSubmit(text), then blur.
EscapeBlur.
anything elseNot claimed — falls through to UI.onKey.

Key names are a browser’s own KeyboardEvent.key strings. Nothing translates them on the way in; "Enter", "ArrowUp" and "a" arrive unchanged, and mjsx-core does not interpret keys at all beyond the table above.

UI.type(str) is the literal-text shortcut, and it goes straight to the editor — no UI.onKey fallthrough, because a literal character has nothing to fall through to:

type: function (str) {
for (var i = 0; i < str.length; i++) this._editKey(str.charAt(i));
},

Use UI.type("...") for characters and UI.key('press', name) for named keys. That is the entire contract a keyboard has to meet.

PinPad in examples/input/app.jsx is the whole story. It is a component that returns boxes. It registers nothing, implements no interface, and the engine has never heard of it:

/* A user-supplied keyboard: plain JSX, no registration. Digits go in with
UI.type, the rest are the same named keys a physical board sends. */
function PinPad() {
var rows = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['DEL', '0', 'OK']];
var kids = [];
for (var r = 0; r < rows.length; r++) {
var ks = [];
for (var c = 0; c < 3; c++) {
ks.push(h(Button, {
label: rows[r][c], size: 2, pad: em(0.75),
bg: rows[r][c] === 'OK' ? UI.theme.accent
: rows[r][c] === 'DEL' ? UI.theme.panel : UI.theme.key,
onTap: (function (k) {
return function () {
if (k === 'DEL') UI.key('press', 'Backspace');
else if (k === 'OK') UI.key('press', 'Enter');
else UI.type(k);
};
})(rows[r][c])
}));
}
kids.push(h('row', { gap: 2 }, ks));
}
return h('box', { bg: UI.theme.panel, pad: 4, gap: 2 }, kids);
}

The app picks it per field, on focus:

if (focused) {
kids.push(focused === 'pin' ? h(PinPad, {})
: h(Keyboard, { layout: kb, position: pos, height: Math.floor(gfx.height() / 2.6) }));
}

Everything still works because nothing was special-cased: DEL repeats the same Backspace a physical board sends, OK fires the field’s onSubmit by sending Enter, and the digits insert at the caret and respect the field’s maxLen: 6.

examples/input — Text input, every way in at once.
Round, 240×240. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Portrait, 172×320. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Landscape, 280×240. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Portrait, 320×480. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Round, 240×240. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Portrait, 172×320. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Landscape, 280×240. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Portrait, 320×480. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Round, 240×240. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Portrait, 172×320. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Landscape, 280×240. examples/input — Text input, every way in at once. ▶ Run it
examples/input — Text input, every way in at once.
Portrait, 320×480. examples/input — Text input, every way in at once. ▶ Run it

If a custom keyboard overlays the page rather than flowing inline, it should do the two things the built-in one does: set shield: true on its panel so a press between keys does not fall through, and call UI.inset('bottom', px) while it is on screen so scroll-into-view keeps revealed fields clear of it.

UI.onFocusChange is called with the focused field’s id, or null on blur:

/* Host hook: called with the focused input's id (or null on blur). This
is how a host that can present its own keyboard -- a browser focusing
a real <input> to summon the phone's, native code outside the JS VM
drawing one -- knows when to show and hide it. */
onFocusChange: null,

A host that presents its own keyboard wires this and then feeds characters back through UI.key('press', ...). From the field’s point of view nothing is different — which is the point of there being one road in.

All in packages/core/src/mjsx.js:

BehaviourWhere
Drawing, geometry, placeholder, mask, caretthe t === 'input' branch of draw()
Height without drawingthe t === 'input' branch of measure()
Per-id state recordsUI._inputs
Tap / drag / hold-drag classificationUI._inputStroke()
Key semanticsUI._editKey()
The single key entry pointUI.key(), UI.type()
Focus and the focus orderUI.focus(), UI.blur(), UI.focused(), UI.focusNext(), UI.focusPrev(), UI.focusNav
Scroll-into-view and auto-exclusiveUI._revealFocus(), UI.exclusive()
Overlay bookkeepingUI.inset(), UI._insetTop(), UI._insetBot()
Host notificationUI.onFocusChange