Buttons and text fields: UIButton, UIInput, UITextArea

The elements that take user input. UIButton is the only tappable container: wrap a card, a list row or an icon in one to make it clickable. UIInput is a one-line text field, UITextArea a multi-line one. Drags are Pointer events; the keyboard's height, to move UI above it yourself, is app.keyboardHeight.

  • make a card, a list row or an icon tappable (UIButton)
  • show that a button is pressed or under the mouse
  • press and hold, then drag
  • read what the user types into a text field
  • fill an edit form before the screen opens
  • let the user pick a date or a time
  • a chat message box that grows as the user types
  • go to the next field on return, then submit the form
  • open a screen with the keyboard already up
  • keep the keyboard off the field, hide it on a tap outside

Platforms: every host; hover only with a mouse (desktop, the web, the web player), the press ripple only on Android and desktop, no date / time picker on desktop (a plain text field there), and the keyboard rules (keyboardShrink, keyboardDismiss) on iOS and Android, on desktop only with its drawn keyboard — each section says its own.

At a glance

TypeScript
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,
             $pressed: { opacity: 0.7 }, rippleColor: "default" })
    .onClick(() => console.log("submitted:", input.value)),
).style({ gap: 8 })

UIScreen(form).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()

Buttons, tappable cards and rows: UIButton

To make a card, a list row or an icon tappable, wrap it in a UIButton: onClick exists on UIButton alone, a plain UIColumn or UIBox cannot be tapped. A button lays its children out in a centered row (flexDirection: "row", justifyContent and alignItems "center"), so an icon + label needs no styling; flexDirection: "column" makes a card.

TypeScript
const card = UIButton(
  UIText("Ada Lovelace").style({ fontSize: 18, color: "white" }),
  UIText("Counts things").style({ color: "#aaa" }),
).style({ flexDirection: "column", alignItems: "flex-start", gap: 4, p: 16,
          bgColor: "#222", borderRadius: 12 })
  .onClick(ev => console.log("card tapped at", ev.clientX, ev.clientY))

UIScreen(card).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()
Note

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.

Button events: onClick, onTouchStart, onMouseEnter

What a button reports: a tap, a touch-down, a hold and a mouse cursor entering it.

TypeScript
UIButton(...children: (UINodeChild | UINodeChild[])[]): UIButton
button.onClick(ev => …)        // the release over the button; ev: ClickEvent
button.onTouchStart(ev => …)   // pointer-down; ev.track({...}) starts a drag gesture
button.onLongPress(ev => …)    // finger held ~0.5 s; ev: LongPressEvent
button.isPressed: boolean      // true while a finger is down on the button
button.onMouseEnter(ev => …)   // a mouse cursor entered (desktop, the web; never touch)
button.isHovered: boolean      // true while the cursor is over the button

The events carry clientX / clientY (logical px) and target (the button); the click, touch and long-press events also pointerId. A click needs the release over the same button, with no scroll having taken the gesture — a tracked drag that ends on the button still clicks. onTouchStart also exists on UIScreen and UIWidget; drags (ev.track(), claim) work as described in Pointer events. A button has the full container surface: .append / .insert / .remove / .setContent, .style, .animateTo / .animateFrom, .onLayout.

Pressed and hover looks: $pressed, $hovered, rippleColor

Nothing changes on screen when a button is pressed or hovered unless its style asks for it, with the reserved style classes $pressed and $hovered or a ripple:

TypeScript
const save = UIButton(UIText("Save").style({ color: "white", $pressed: { color: "#ffd0cc" } }))
  .style({
    height: 44, px: 20, bgColor: "#FF4032", borderRadius: 8,
    $hovered: { bgColor: "#e8382b" },            // a mouse cursor over it
    $pressed: { opacity: 0.7, duration: 150 },   // while the finger is down
    rippleColor: "default",                      // a ripple instead, on Android and desktop
  })
  .onClick(() => console.log("saved"))
  .named("save")

UIScreen(save).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()

A $pressed block takes any style the button does (bgColor, opacity, border*, transform, …), plus the swap's duration / delay / easing. It cascades — the icon and label inside can declare their own $pressed blocks — and stops at a nested button, which presses on its own (a button inside a clickable card never presses with the card). rippleColor draws a ripple on Android and desktop and replaces the $pressed look there, children's blocks included; iOS and the web draw no ripple and show $pressed, so setting both gives a ripple where there is one and the $pressed look everywhere else.

$hovered cascades like $pressed and loses to it. To act on hover, onMouseEnter fires when the cursor enters; there is no leave event — ev.track({ onMove, onEnd, onCancel }) follows the hover: onEnd when the cursor leaves, onMove while it moves inside, onCancel when the element went away underneath.

Note

hover needs a mouse: desktop, the web and the web player. On iOS and Android onMouseEnter never fires and $hovered never shows.

For a state you toggle yourself (selected, checked, active), declare your own class ($selected) and flip it with el.class.selected = … — see Style classes. The press look wins over a class while pressed.

Press and hold, then drag: onLongPress

onLongPress fires when the finger stays on the button past the long-press threshold (0.5 s; Android: the system's long-press timeout) without sliding away. Like onTouchStart, the event can ev.track({...}) the rest of the gesture, so "hold, then drag to move" is one handler:

TypeScript
let x = 0, y = 0
const tile = UIButton(UIText("Hold, then drag").style({ color: "white" }))
  .style({ width: 180, height: 60, bgColor: "#2b3550", borderRadius: 8 })
  .named("tile")
tile.onLongPress(ev => {
  console.log("held")
  device.vibrate("medium")                   // confirm the hold registered
  ev.track({
    claim: true,
    onMove: ({ deltaX, deltaY }) => {        // movement since the previous move, logical px
      x += deltaX; y += deltaY
      tile.style.transform = `translate(${x}px, ${y}px)`
    },
    onEnd: () => console.log("dropped at", x, y),
  })
})
tile.onClick(() => console.log("tapped"))    // a plain tap; a hold does not click

UIScreen(tile).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()

A long press with a listener swallows the click that would follow the release, so onClick and onLongPress coexist; a button without onLongPress still clicks after a long hold.

Text fields: UIInput and UITextArea

A text field the user types into: value is its text and onChange fires on every edit the user makes. UIInput is one line, UITextArea grows to several.

TypeScript
UIInput(): UIInput          // one-line field
UITextArea(): UITextArea    // multi-line
input.value                          // get / set the text (a content property, not a style)
input.onChange(cb: (value: string) => void): this   // every user edit; a `value` write does not fire it
input.onFocus(cb: () => void): this
input.onBlur(cb: () => void): this
input.onSubmit(cb: (value: string) => void): this   // the return key (UIInput only)
input.focus()                        // focus from code (opens the keyboard) — once it is on screen
input.blur()                         // release the focus (hides the keyboard)

The At a glance block is the whole form: a field, its onChange, and a button that reads input.value.

Note

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>.

Pre-fill an edit form: value

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.

TypeScript
const user = { name: "Ada", bio: "Counts things." }
const save = (name: string, bio: string) => console.log("save", name, bio)

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(UIText("Save")).onClick(() => save(name.value, bio.value)))).open()

Reading value gives the field's text at any time — what the user typed or what your code set last, whether the screen is open or not. A closed or popped screen is detached, not rebuilt: open or push the same screen object again and its fields come back as the user left them.

Keyboard type, password and length: input styles

The keyboard a field asks for, a password field, a length cap and the focused look are styles of the field, on top of the usual element and text styles (fontSize, color, fontFamily, …):

TypeScript
const field = { height: 44, px: 12, borderRadius: 8, bgColor: "white", color: "black",
                placeholderColor: "#999", borderWidth: 1, borderColor: "#ccc" }

const email = UIInput().style({
  ...field,
  placeholder: "Email",
  type: "email",          // "text" (default) | "password" | "search" | "phone" | "email"
                          //   | "number" | "decimal" | "url" | "date" | "time"
  enterKey: "next",       // the return key's label: "done" | "go" | "next" | "search" | "send" (UIInput only)
  maxLength: 64,          // hard cap while typing / pasting
  autocapitalize: "none", // "none" | "words" | "sentences" | "characters"; omitted = platform default
  autocorrect: false,     // autocorrection and suggestions; omitted = platform default
  $focused: { borderColor: "#FF4032", duration: 150 },   // while focused (reserved class)
})
const password = UIInput().style({ ...field, placeholder: "Password", type: "password" })

UIScreen(UIColumn(email, password).style({ gap: 8 })).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()
Warning

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).

Note

iOS, Android and the web apply all of these. Desktop masks password and applies neither maxLength nor autocapitalize / autocorrect; there type and enterKey shape its drawn keyboard (app.json desktop.virtualKeyboard), and enterKey: "next" keeps the focus on submit as everywhere. The web player passes only type to the browser's field. Headless tests set the text as given.

Pick a date or a time: date & time pickers (type: "date" | "time")

Let the user pick a date or a time with type: "date" or "time": on iOS focusing the field opens date or time wheels in the keyboard's place, on Android a date or time dialog, on the web the browser's own date / time field and its picker. The field itself stays a normal, fully styleable input.

TypeScript
const shown = signal("No date yet")
const isDate = (v: string) => /^\d{4}-\d{2}-\d{2}$/.test(v)   // desktop: the field is free text

const birthday = UIInput()
  .style({ type: "date", placeholder: "Date of birth", height: 44, px: 12,
           bgColor: "white", color: "black", borderRadius: 8 })
  .onChange(v => { shown.value = isDate(v) ? `Born ${v}` : "Type the date as YYYY-MM-DD" })
  .named("birthday")
birthday.value = "1990-04-27"    // canonical in; the field shows it in the device's format

UIScreen(UIColumn(birthday, UIText(() => shown.value).style({ color: "white" })).style({ gap: 8 }))
  .style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()
  • value is canonical: "YYYY-MM-DD" for date, "HH:MM" (24-hour) for time — that's what onChange receives and what you set from code. What the user sees in the field is a string the host formats in the device's language; never parse the display.
  • On iOS and Android the field takes no typing: the value comes from the picker or from code.
  • 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. On Android, cancelling the dialog adopts nothing, and the time dialog is 24-hour when the system setting is.
  • $focused styling and focus() / blur() work as on any field; on iOS the wheels take the keyboard's place, so keyboardShrink applies to them too.
Note

desktop (Windows, macOS, Linux) has no picker: a date / time field is a plain text field there, the user types into it and value is whatever was typed — check its form before you use it, as isDate does above. Headless tests also set the text as given.

A message box that grows as you type: UITextArea

A chat composer or a comment box is a UITextArea with no explicit height: it 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).

TypeScript
const composer = UITextArea().style({
  placeholder: "Message", fontSize: 16, color: "white", placeholderColor: "#888",
  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
}).named("composer")
const send = UIButton(UIText("Send").style({ color: "white" }))
  .style({ height: 44, px: 16, bgColor: "#FF4032", borderRadius: 22 })
  .onClick(() => { console.log("send:", composer.value); composer.value = "" })

const bar = UIRow(
  UIColumn(composer).style({ flex: 1, px: 14, py: 11, borderRadius: 22, bgColor: "#222" }),  // the width the button leaves
  send,
).style({ alignItems: "flex-end", gap: 10, p: 12 })   // the send button stays at the bottom while it grows

UIScreen(UIColumn().style({ flex: 1 }), bar).style({ bgColor: "#111", pt: "safe-top" }).open()

Don't give the textarea itself flexGrow / flex — a flex basis overrides the intrinsic content height and the field stops growing.

Note

on the web the growth rides the browser's CSS field-sizing: content; a browser without it keeps the field at one height.

Next field on return, then submit: enterKey + onSubmit

onSubmit fires when the user presses the keyboard's return key (UIInput only — in a UITextArea Enter is a newline). On submit the keyboard goes, unless enterKey: "next" — then you move the focus yourself and the keyboard stays up:

TypeScript
const style = { height: 44, px: 12, bgColor: "white", color: "black", borderRadius: 8,
                borderWidth: 2, borderColor: "white", $focused: { borderColor: "#FF4032" } }

const name = UIInput().style({ ...style, placeholder: "Name", enterKey: "next" })
  .onSubmit(() => email.focus())            // "Next": the keyboard stays, the focus moves on
const email = UIInput().style({ ...style, placeholder: "Email", type: "email", enterKey: "done" })
  .onSubmit(() => console.log("sign up:", name.value, email.value))   // "Done": the keyboard goes
  .named("email")

UIScreen(UIColumn(name, email).style({ gap: 8 })).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()

focus() also covers focusing the invalid field after a failed check.

Open a screen with the keyboard up: autofocus

A screen made for typing — a composer, a chat, a search, a one-field form — opens with its field focused and the keyboard up when the field's style has autofocus: true:

TypeScript
const query = UIInput()
  .style({ placeholder: "Search", type: "search", enterKey: "search", height: 44, px: 12,
           bgColor: "white", color: "black", borderRadius: 8, autofocus: true })
  .onFocus(() => console.log("keyboard up"))
  .onSubmit(q => console.log("search:", q))

UIScreen(query).style({ bgColor: "#111", p: 24, pt: "safe-top" }).open()

autofocus is the SDK's behaviour over focus(), on every host: when the field's screen (or pager page) opens, it takes the focus, once; a screen shown again does not take the keyboard back, and the first field of a long form should not ask for it. One per screen — a second is reported in the console and ignored.

Keep the field above the keyboard: keyboardShrink

When the on-screen keyboard opens, the screen that holds the focused field lays out again in the space above it, and a field inside a scrollable is scrolled into view — no code needed. What the layout does is decided by the focused field: keyboardShrink: true (default) shrinks the layout to the area above the keyboard (one relayout); false lets the keyboard cover the UI with no relayout — for a chat composer that moves itself, or a full-screen canvas where a reflow is worse than overlap. Then move things yourself from the keyboard's height, app.keyboardHeight and the "keyboard" event (app.md):

TypeScript
const input = UITextArea().style({ placeholder: "Message", fontSize: 16, color: "white",
                                   keyboardShrink: false })   // the keyboard covers the screen
const composerRow = UIRow(UIColumn(input).style({ flex: 1 }))
  .style({ px: 12, pt: 8, pb: 24, bgColor: "#222" })

app.addEventListener("keyboard", (height) => {
  composerRow.style.pb = Math.max(24, height)    // pad the row above it; 24 ≈ the safe-area bottom
})

UIScreen(UIColumn().style({ flex: 1 }), composerRow).style({ bgColor: "#111" }).open()
Note

iOS and Android. Desktop shrinks only with its drawn keyboard (app.json desktop.virtualKeyboard); the web leaves the keyboard to the browser and ignores keyboardShrink; the web player and headless tests always lay the focused screen out above the keyboard, under keyboardShrink: false too.

Hide the keyboard: tap outside, scroll, return key (keyboardDismiss)

The keyboard hides on a tap on the background, a text or an image, on the return key and on input.blur(); a tap on a button or another field never hides it, and scrolling hides it only in a scrollable that opts in. An app normally ships no keyboard code beyond enterKey: "next" → next.focus() — plus keyboardDismiss: false on a chat composer.

TypeScript
const search = UIInput().style({ placeholder: "Search", type: "search", height: 44, px: 12,
                                 bgColor: "white", color: "black", borderRadius: 8 })
  .onBlur(() => console.log("keyboard hidden"))
const row = (fruit: string) => UIButton(UIText(fruit).style({ color: "white" }))
  .style({ height: 48, justifyContent: "flex-start" })
  .onClick(() => console.log("picked", fruit))            // the keyboard stays up
const results = UIScrollable(row("Apples"), row("Bananas"), row("Cherries"))
  .style({ flex: 1, keyboardDismissMode: "scroll" })      // a drag in the list hides it

UIScreen(search, results).style({ bgColor: "#111", p: 24, pt: "safe-top", gap: 8 }).open()
  • A tap on a control never hides it. Tapping a UIButton fires it with the keyboard still up; tapping another field just moves the focus. (A send button next to a composer works without the keyboard blinking — call input.blur() from a handler when you do want it gone.)
  • Scrolling never hides it — unless the scrollable opts in. So an autocomplete list under a focused field can be scrolled and its options tapped with the keyboard up. On iOS a downward drag that reaches the keyboard itself hides it interactively (the iMessage gesture). A scrollable changes its own policy with the keyboardDismissMode style (containers.md): "scroll" hides it the moment any drag in it starts (a search-results list), "none" turns even the interactive drag off.
  • A tap on a non-interactive area hides it — background, text, images. This is the one part a field can opt out of with keyboardDismiss: false (chat composers — then taps never hide it, and only blur(), the return key and the iOS drag-down remain).
  • The return key hides it per enterKey (except "next"), and iOS shows a Done bar on the pads without a return key (number / decimal / phone, and the date / time wheels).
Note

iOS and Android. Desktop follows the tap rules only with its drawn keyboard — with a physical keyboard a click anywhere outside the field, a button too, takes the focus from it — and never reads keyboardDismissMode. The web leaves the keyboard and the focus to the browser and ignores keyboardDismiss and keyboardDismissMode. The web player and headless tests follow the tap rules but ignore keyboardDismiss and keyboardDismissMode.

Pitfalls

TypeScript
// ✗ 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