UIButton, UIInput & UITextArea
The three elements that take user input. UIButton is the only tappable container — touch
handlers exist solely on UIButton, UIScreen, and UIWidget (see
Pointer events); to make anything clickable (a card, a list row, an icon),
wrap it in a UIButton. UIInput is a single-line text field, UITextArea its multi-line
sibling.
At a glance
let input: UIInput
const form = UIColumn(
input = UIInput()
.style({ height: 40, px: 12, borderRadius: 8, bgColor: "white", color: "black",
placeholder: "Your name", placeholderColor: "#999" })
.onChange(v => console.log("typing:", v)),
UIButton(UIText("Submit").style({ color: "white" }))
.style({ height: 40, justifyContent: "center", bgColor: "#FF4032", borderRadius: 8,
onPressed: { opacity: 0.7 }, rippleColor: "default" })
.onClick(() => console.log("submitted:", input.value)),
).style({ gap: 8 })UIButton
UIButton(...children: (UINodeChild | UINodeChild[])[]): UIButtonA container that defaults to flexDirection: "row" with children centered on both axes
(justifyContent + alignItems "center") — the icon+label button lays out correctly with no
styling. Full container surface: .append / .insert / .remove / .setContent, .style,
.animateTo / .animateFrom, .onLayout. Use flexDirection: "column" for a card-shaped button.
button.onClick(ev => …) // pointer-up over the button; ev: ClickEvent
button.onTouchStart(ev => …) // pointer-down; ev.track({...}) starts a drag gesture
button.onLongPress(ev => …) // finger held ~0.5s; ev.track({...}) to drag; ev: LongPressEvent
button.isPressed(): boolean // true while a finger is currently down on the buttononClick / onTouchStart / onLongPress events carry clientX / clientY / pointerId (logical
px). Drags — ev.track(), claim — work exactly as described in
Pointer events.
onLongPress fires when the finger is held on the button past the long-press threshold (~0.5s)
without sliding away. A handled long-press swallows the click that would otherwise follow the
release, so onClick and onLongPress coexist cleanly. Like onTouchStart, the event can
ev.track({...}) — so "press-and-hold, then drag to move" is a single handler:
button.onLongPress(ev => {
device.vibrate("medium") // confirm the hold registered
ev.track({
claim: true,
onMove: ({ deltaX, deltaY }) => moveElement(deltaX, deltaY),
onEnd: () => commit(),
})
})buttons render no chrome of their own — style them like any container (bgColor,
borderRadius, padding). Give a button an explicit height: on its own it is only as tall as
its text.
Press feedback — opt-in
button.style({
onPressed: { opacity: 0.7, bgColor: "#c22", duration: 150 }, // style while pressed
rippleColor: "default", // Android ripple; or any Color
})Nothing happens visually on press unless you ask. onPressed accepts drawable/base styles
(bgColor, opacity, border*, transform, …) applied while the finger is down, plus an
optional transition duration (ms). rippleColor is Android-only and replaces the
onPressed visual there — so setting both gives a ripple on Android and the onPressed style on
iOS.
Prefer the reserved class $pressed in new code: same feedback on the button itself, and it
cascades — children (icon, label) can declare their own $pressed blocks and react to the
button's press. onPressed stays the strictly per-element form (it never fires from an ancestor's
press — right for nested interactives) and wins over $pressed when both are declared. See
Style classes.
For a persistent visual state you toggle yourself (selected, checked, active) rather than one the
system drives, declare a style class ($name) and flip it via el.class.name = … — see
Style classes. Press feedback always wins over a class while pressed.
UIInput & UITextArea
UIInput(): UIInput // single-line field
UITextArea(): UITextArea // multi-lineinput.value // get/set the current text (a content property, not a style)
input.onChange(cb: (value: string) => void): this // every edit
input.onFocus(cb: () => void): this
input.onBlur(cb: () => void): this
input.onSubmit(cb: (value: string) => void): this // return key pressed (UIInput only)
input.focus() // focus programmatically (opens the keyboard); no-op before mount
input.blur() // release focus (dismisses the keyboard)Pre-filling a field
value is writable before the screen opens — that's how you build a settings screen or an
edit form: fill the fields while you compose the tree, then open it.
const name = UIInput().style({ placeholder: "Name" })
const bio = UITextArea().style({ maxHeight: 120 })
name.value = user.name // set before open — the field comes up filled
bio.value = user.bio // a textarea comes up already grown to fit
UIScreen(UIColumn(name, bio, UIButton("Save").onClick(() => save(name.value, bio.value)))).open()Reading back gives you the live text while the screen is open, and the last value you set while it
isn't. What the user typed is not carried across a close/open — a re-opened screen is rebuilt,
so it comes up with the values your code set. Use screen.keepAlive() when a half-filled form must
survive navigating away.
Input-specific styles, on top of the usual element + text styles (fontSize, color,
fontFamily, …):
input.style({
placeholder: "Search…",
placeholderColor: "#999",
type: "search", // "text" (default) | "password" | "search" | "phone" | "email"
// | "number" | "decimal" | "url" | "date" | "time"
enterKey: "search", // return-key label: "done" | "go" | "next" | "search" | "send" (UIInput only)
maxLength: 32, // hard cap, enforced by the host while typing/pasting
autocapitalize: "none", // "none" | "words" | "sentences" | "characters"; omitted = platform default
autocorrect: false, // platform autocorrection/suggestions; omitted = platform default
onFocused: { borderColor: "#FF4032", duration: 150 }, // style while focused (like onPressed;
// cascading form: $focused)
})type values are keyboard hints, not validators — number shows the number
pad but does not block pasted letters; validate in onChange yourself. The phone-pad value is
phone (LeCodes has no HTML-style tel).
give inputs an explicit height and a flexGrow: 1 (in a row) or width — an input
collapses to its placeholder's width and its text's height, it does not stretch like a web
<input>.
Date & time pickers (type: "date" | "time")
date and time are picker kinds, not keyboards: focusing the field opens the platform's
native picker in the keyboard slot (iOS wheels, browser calendar) and free typing is disabled —
the value can only come from the picker or from code. The field itself stays a normal, fully
styleable input.
input.valueis always canonical:"YYYY-MM-DD"fordate,"HH:MM"(24-hour) fortime— that's whatonChangereceives and what you set programmatically. What the user sees in the field is a localized string the host formats; never parse the display.- An empty value shows the
placeholder. On iOS, confirming with the Done bar adopts the wheels' current position even if the user didn't spin them (open → Done = today); tapping dead space just dismisses without adopting. - All the usual machinery applies unchanged:
onFocusedstyling,focus()/blur(),keyboardShrink, the dismissal doctrine.
const birthday = UIInput().style({ type: "date", placeholder: "Date of birth" })
birthday.onChange((v) => console.log(v)) // "1990-04-27"
birthday.value = "1990-04-27" // canonical in, localized display outAuto-grow (UITextArea)
A UITextArea with no explicit height measures its own content and grows line by line as
the user types. Clamp the range with minHeight / maxHeight — past maxHeight the text
scrolls inside the field. A fixed height opts out (internal scrolling from the start). This is
the chat-composer recipe:
const composer = UITextArea().style({
placeholder: "Message", fontSize: 16,
maxHeight: 108, // ~5 lines, then it scrolls inside
keyboardDismiss: false, // chat: tapping the transcript doesn't hide the keyboard
// no height — grows with the content; width comes from the wrapper's stretch
})
UIRow(
UIColumn(composer).style({ flexGrow: 1, flexShrink: 1, flexBase: 0, px: 14, py: 11, borderRadius: 22 }),
sendButton,
).style({ alignItems: "flex-end", gap: 10 }) // the send button stays anchored while it growsDon't give the textarea itself flexGrow/flexBase — a flex basis overrides the intrinsic
content height and the field stops growing.
Submit & the form flow
onSubmit fires when the user presses the keyboard's return key (UIInput only — in a
UITextArea Enter is a newline, period). On submit the keyboard dismisses, unless
enterKey: "next" — then you are expected to move focus yourself, and the keyboard stays up:
const name = UIInput().style({ enterKey: "next" }).onSubmit(() => email.focus())
const email = UIInput().style({ type: "email", enterKey: "done" })
.onSubmit(() => submitForm())focus() also covers focus-on-open search screens and focus-the-invalid-field validation.
Keyboard policy: keyboardShrink
input.style({ keyboardShrink: false }) // keyboard overlays the UI — no relayoutWhat the layout does while the keyboard is up is decided by the focused input. true
(default): the layout viewport shrinks to the area above the keyboard (one relayout). false:
the keyboard overlays the UI with no layout recalculation — for chat composers that manage their
own inset and full-screen canvases where a reflow is worse than overlap. In overlay mode, read
app.keyboardHeight / the "keyboard" event to make room yourself:
// chat composer: overlay mode + own inset — pb rides the keyboard
input.style({ keyboardShrink: false })
app.addEventListener("keyboard", (height) => {
composerRow.style.pb = Math.max(24, height) // 24 ≈ the safe-area bottom
})Everything else about the keyboard is the host's job: it scrolls the focused input into view inside its nearest scroll container, and dismissal follows one universal doctrine:
- A tap on a control never dismisses. Tapping a
UIButtonfires it with the keyboard still up; tapping another input just moves focus. (A send button next to a composer works without the keyboard blinking — callinput.blur()from a handler when you do want to dismiss.) - Scrolling never dismisses — unless the scrollable opts in. So a suggestion dropdown or
autocomplete list under a focused input can be scrolled and its options tapped with the
keyboard up — no special casing. On iOS a downward drag that reaches the keyboard itself
dismisses it interactively (the iMessage gesture); ordinary scrolling doesn't. A scrollable
can change its own policy with the
keyboardDismissModestyle (containers.md):"scroll"makes any drag in it dismiss the moment it starts — the right feel for a search-results list — and"none"turns even the interactive drag off. - A tap on a non-interactive area dismisses — background, text, images. This is the one part
an input can opt out of with
keyboardDismiss: false(chat composers — then taps never dismiss and onlyblur(), the return key, and the iOS drag-down gesture remain). - The return key dismisses per
enterKey(except"next"), and iOS shows a Done accessory bar on the return-key-less pads (number/decimal/phone).input.blur()stays the programmatic path.
An app normally ships no keyboard code beyond enterKey: "next" → next.focus() chaining —
plus keyboardDismiss: false on a chat composer.
Pitfalls
// ✗ touch handler on a plain container
UIColumn(...).onTouchStart(cb) // no such method — only UIButton/UIScreen/UIWidget
// ✓ wrap in a UIButton
UIButton(...).onTouchStart(cb)
// ✗ button next to a 40px input shrinks to its text height
UIRow(input.style({ height: 40 }), UIButton(icon))
// ✓ give controls the same explicit height
UIRow(input.style({ height: 40 }), UIButton(icon).style({ height: 40 }))See also
- Pointer events & gestures — event objects,
ev.track(),claim - Styling —
.style(), style states,animateTo - Containers —
UIRow/UIColumnlayout rules a button inherits