Elements

Input

Input is a native text field: a UITextField (single line) or UITextView (multiline) on iOS and Mac Catalyst, an EditText on Android. Typing, the caret, selection, autocorrection and autofill are the platform’s own. Rust makes one with input(), TSX with <Input> from nsh.

el-input.tsx
el-input.tsxLive
import { Input, type JSX, mount, signal, Text, View, type YNode } from "nsh";
import { Inspector, Panel, size, Stage, ui, wide } from "./palette";

function App() {
  const name = signal("");
  const password = signal("");
  const notes = signal("");
  const submitted = signal("");
  let passwordInput!: YNode;
  const signIn = () => submitted.set(`Signed in as ${name() || "nobody"}`);
  // Touch fields on a phone; compact ones, 32 points high, in a desktop layout.
  const field = () =>
    wide()
      ? { ...ui().inset, fontSize: 14, color: ui().text, placeholderColor: ui().faint, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 7 }
      : { ...ui().inset, fontSize: 16, color: ui().text, placeholderColor: ui().faint, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10 };

  const nameField = () => (
    <Input
      style={field()}
      value={name()}
      onChange={(text) => name.set(text)}
      placeholder="Ada Lovelace"
      autoCapitalize="words"
      maxLength={20}
      returnKeyType="next"
      onSubmit={() => passwordInput.focus()}
    />
  );
  const count = () => <Text style={{ fontSize: 12, color: ui().muted }}>{name().length} / 20</Text>;
  const passwordField = () => (
    <Input
      ref={passwordInput}
      style={field()}
      value={password()}
      onChange={(text) => password.set(text)}
      masked
      autoComplete="current-password"
      returnKeyType="done"
      onSubmit={signIn}
    />
  );
  const notesField = () => (
    <Input
      style={{ ...field(), height: wide() ? 80 : 100 }}
      multiline
      value={notes()}
      onChange={(text) => notes.set(text)}
      placeholder="Anything else?"
    />
  );
  const button = () => (
    <View
      onPress={signIn}
      activeStyle={{ opacity: 0.7 }}
      disabled={password().length === 0}
      class="disabled:opacity-40"
      style={
        wide()
          ? { ...ui().primary, alignSelf: "flex-start", borderRadius: 8, height: size.control, paddingHorizontal: 14, justifyContent: "center", marginTop: 4 }
          : { ...ui().primary, borderRadius: 10, padding: 14, alignItems: "center", marginTop: 6 }
      }
    >
      <Text style={{ color: ui().onPrimary, fontSize: wide() ? 13 : 16, fontWeight: "600" }}>Sign in</Text>
    </View>
  );
  /** A label above its field, and what describes it below. */
  const group = (label: string, input: JSX.Element, after?: JSX.Element) => (
    <View style={{ gap: 6 }}>
      <Text style={{ fontSize: 13, color: ui().muted }}>{label}</Text>
      {input}
      {after}
    </View>
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page }}>
      {() =>
        wide() ? (
          <View style={{ flex: 1, padding: 20, paddingTop: 24, gap: 14 }}>
            <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>input</Text>
            <Text style={{ color: ui().muted }}>Tab moves between the fields; Return in the name goes to the password</Text>
            <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
              <Stage width={340} gap={16}>
                {group("Name (controlled, maxLength 20)", nameField(), count())}
                {group("Password", passwordField())}
                {group("Notes (multiline)", notesField())}
                {button()}
              </Stage>
              <Inspector>
                <Panel title="value">
                  <Text style={{ fontSize: 13, color: ui().text }}>name {name() ? `“${name()}”` : "—"}</Text>
                  <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>password {password().length} characters</Text>
                  <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>notes {notes().length} characters</Text>
                </Panel>
                <Panel title="onSubmit" grow>
                  <Text style={{ fontSize: 13, color: ui().up }}>{submitted()}</Text>
                </Panel>
              </Inspector>
            </View>
          </View>
        ) : (
          <View style={{ flex: 1, width: "100%", maxWidth: 480, alignSelf: "center", padding: 20, paddingTop: 24, gap: 10 }}>
            <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>input</Text>

            <Text style={{ color: ui().muted }}>Name (controlled, maxLength 20)</Text>
            {nameField()}
            {count()}

            <Text style={{ color: ui().muted }}>Password</Text>
            {passwordField()}

            <Text style={{ color: ui().muted }}>Notes (multiline)</Text>
            {notesField()}

            {button()}
            <Text style={{ color: ui().up }}>{submitted()}</Text>
          </View>
        )
      }
    </View>
  );
}

mount(App);
use nsh_core::ui::prelude::*;

fn name_field() -> impl IntoElement {
    let name = signal(String::new());
    input()
        .value(name)
        .on_change(move |text: &str| name.set(text.to_string()))
        .placeholder("Your name")
        .auto_capitalize(AutoCapitalize::Words)
        .return_key_type(ReturnKeyType::Done)
        .text_base()
        .p_3()
        .border_1()
        .border_color(rgb(0xd0d7de))
        .rounded(px(8.0))
}

#Controlled and uncontrolled

  • Controlled: pass value and update it in onChange. The app can rewrite what the user typed (trim it, format it): the text shows the edit at once and is corrected when the new value arrives. Each native edit carries a count, and a value from the app is applied when it is based on the latest edit, so newer typing always wins over a slow update.
  • Uncontrolled: pass defaultValue (.default_value(..), or nothing) and read the text from onChange or onSubmit.

Rules that must hold while the user types are props, answered natively: maxLength limits the length and submitBehavior decides what return does. See forms and the keyboard.

#Props

Besides the common props (with style typed as TextStyle: font, color, alignment and the view styles apply):

#Text

PropRustTypeDefaultDescription
value.value(..)stringThe text, controlled by the app.
defaultValue.default_value(..)stringThe initial text.
placeholder.placeholder(..)stringShown while the input is empty.
multiline.multiline(..)booleanfalseSeveral lines; return inserts a line break unless submitBehavior says otherwise.
maxLength.max_length(..)numberThe most characters the user can enter.
readonly.readonly(..)booleanfalseThe user can select and copy the text but not change it.
masked.masked(..)booleanfalseThe text is hidden (passwords).
allowFontScalingbooleantrueFollow the system text size.
maxFontSizeMultipliernumber0The upper bound of the system scale (0 is none).

#Keyboard

PropRustTypeDefaultDescription
keyboardType.keyboard_type(KeyboardType::Email)"default" | "number-pad" | "decimal-pad" | "numeric" | "numbers-and-punctuation" | "email-address" | "phone-pad" | "url""default"The keyboard layout (the values; numbers-and-punctuation has a minus sign on iOS).
returnKeyType.return_key_type(ReturnKeyType::Next)"default" | "done" | "go" | "next" | "search" | "send""default"The label of the return key.
autoCapitalize.auto_capitalize(AutoCapitalize::None)"none" | "sentences" | "words" | "characters""sentences"Automatic capitalization.
autoCorrect.auto_correct(..)booleantrueThe system’s corrections.
autoComplete.auto_fill(AutoFill::OneTimeCode)AutoComplete | TextContentTypeWhat the input holds, for autofill: "username", "current-password", "new-password", "one-time-code", "email", "tel", "postal-code", "cc-number", "off", …
submitBehavior.submit_behavior(SubmitBehavior::Submit)"submit" | "blurAndSubmit" | "newline""blurAndSubmit" (single line), "newline" (multiline)What return does: "submit" reports onSubmit and keeps the focus, "blurAndSubmit" also unfocuses, "newline" inserts a line break (multiline; a single line submits). A multiline input that submits still takes line breaks from Shift+Return or Option / Alt+Return on hardware keyboards. "submit" keeps the keyboard up (e.g. to send several messages).
autoFocus.auto_focus(..)booleanfalseFocused when it appears.

#Caret and selection

PropRustTypeDescription
caret.caret(..)booleanThe caret is drawn (default true; false hides it).
selectedRange.selected_range((start, end)){ start: number; end?: number }The controlled selection, in UTF-16 units; end defaults to start (a caret).

#Colors

The placeholder’s color and the color of the caret and the selection are style keys, with their Tailwind variants, and methods in Rust:

Style keyRustClassDescription
placeholderColor.placeholder_color(..)placeholder:text-*The placeholder’s color.
selectionColor.selection_color(..)selection:text-*The color of the caret and the selection.
input()
    .placeholder("Search")
    .text_color(rgb(0x18181b))
    .placeholder_color(rgb(0x9ca3af))
    .selection_color(rgb(0x2563eb))

#iOS

ios keyRustTypeDescription
textContentType.auto_fill(..)AutoComplete | TextContentTypeiOS’s names for autoComplete ("oneTimeCode", "newPassword", …).
inputAccessoryViewID.input_accessory(id)stringThe nativeID of an InputAccessoryView (nsh/ios) shown over the keyboard while the input has focus.
keyboardAppearance.keyboard_appearance(KeyboardAppearance::Dark)"default" | "light" | "dark"The keyboard’s look; "default" follows the system’s appearance. Android’s keyboards follow the system’s theme.
use nsh_core::ui::AutoFill;

input().auto_fill(AutoFill::OneTimeCode).keyboard_type(KeyboardType::Number)

#Events

EventRustPayloadWhen
onChange.on_change(|text: &str| ..)(text: string)The user changed the text.
onSubmit.on_submit(|text: &str| ..){ text }The return key was pressed in an input that submits (single line, or submitBehavior "submit" / "blurAndSubmit").
onFocus / onBlur.on_focus / .on_blurnoneThe input gained / lost focus.
onSelectionChange.on_selection_change(|s: &Selection| ..){ start, end }The caret or selection moved (UTF-16 units).
onKeyPress.on_key_press(|key: &str| ..){ key }Before the text changes: key is the typed text, "Backspace" or "Enter".
onContentSizeChange.on_content_size_change(|size: &Size<Points>| ..){ width, height }The size of the text changed (at the input’s width when multiline), as it is typed; an empty input reports one empty line, whatever its placeholder.
onCompositionChange.on_composition_change(|e: &Composition| ..){ composing, text }An input method’s composition started, changed or ended: text is what is being composed (pinyin before a character is chosen, kana before conversion), "" once it ends. The committed text reaches onChange before the end.

A multiline input that grows with its text:

let height = signal(px(40.0));

input()
    .multiline(true)
    .on_content_size_change(move |size: &Size<Points>| {
        height.set(px((size.height.0 + 16.0).clamp(40.0, 120.0)));
    })
    .h(move || height.get())
    .px_3()
    .py_2()

#Ref methods

MethodRustDescription
focus()focus()Focuses the input and shows the keyboard.
blur()blur()Unfocuses it.
setSelection(start, end?)set_selection(start, end)Selects start..end (UTF-16 units); a caret when they are equal.

Moving focus to the next field on return:

use nsh_core::ui::AutoFill;

let password = element_ref();

view()
    .child(input().return_key_type(ReturnKeyType::Next).on_submit(move |_: &str| password.focus()))
    .child(input().element_ref(&password).masked(true).auto_fill(AutoFill::Password))

Keyboard.dismiss() from nsh/base (dismiss_keyboard() in Rust) hides the keyboard whichever input has it.

#Platform notes

  • Inputs inside a scroll view are kept clear of the keyboard natively. Elsewhere use KeyboardAvoidingView.
  • disabled disables the input (no focus) as well as applying the disabled styles; a readonly input keeps its focus and selection.
  • inputAccessoryViewID applies on iOS, where the keyboard has an accessory bar.