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.
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))
}import { Input, signal } from "nsh";
function NameField() {
const name = signal("");
return (
<Input
value={name()}
onChange={(text) => name.set(text)}
placeholder="Your name"
autoCapitalize="words"
returnKeyType="done"
style={{ fontSize: 16, padding: 12, borderWidth: 1, borderColor: "#d0d7de", borderRadius: 8 }}
/>
);
}#Controlled and uncontrolled
- Controlled: pass
valueand update it inonChange. The app can rewrite what the user typed (trim it, format it): the text shows the edit at once and is corrected when the newvaluearrives. Each native edit carries a count, and avaluefrom 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 fromonChangeoronSubmit.
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
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
value | .value(..) | string | The text, controlled by the app. | |
defaultValue | .default_value(..) | string | The initial text. | |
placeholder | .placeholder(..) | string | Shown while the input is empty. | |
multiline | .multiline(..) | boolean | false | Several lines; return inserts a line break unless submitBehavior says otherwise. |
maxLength | .max_length(..) | number | The most characters the user can enter. | |
readonly | .readonly(..) | boolean | false | The user can select and copy the text but not change it. |
masked | .masked(..) | boolean | false | The text is hidden (passwords). |
allowFontScaling | boolean | true | Follow the system text size. | |
maxFontSizeMultiplier | number | 0 | The upper bound of the system scale (0 is none). |
#Keyboard
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
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(..) | boolean | true | The system’s corrections. |
autoComplete | .auto_fill(AutoFill::OneTimeCode) | AutoComplete | TextContentType | What 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(..) | boolean | false | Focused when it appears. |
#Caret and selection
| Prop | Rust | Type | Description |
|---|---|---|---|
caret | .caret(..) | boolean | The 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 key | Rust | Class | Description |
|---|---|---|---|
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))<Input placeholder="Search" class="text-zinc-900 placeholder:text-zinc-400 selection:text-blue-600" />
<Input placeholder="Search" style={{ placeholderColor: "#9ca3af", selectionColor: "#2563eb" }} />#iOS
ios key | Rust | Type | Description |
|---|---|---|---|
textContentType | .auto_fill(..) | AutoComplete | TextContentType | iOS’s names for autoComplete ("oneTimeCode", "newPassword", …). |
inputAccessoryViewID | .input_accessory(id) | string | The 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)<Input autoComplete="one-time-code" keyboardType="number-pad" ios={{ textContentType: "oneTimeCode" }} />#Events
| Event | Rust | Payload | When |
|---|---|---|---|
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_blur | none | The 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()const height = signal(40);
<Input
multiline
onContentSizeChange={(e) => height.set(Math.min(120, Math.max(40, e.height + 16)))}
style={{ height: height(), paddingHorizontal: 12, paddingVertical: 8 }}
/>;#Ref methods
| Method | Rust | Description |
|---|---|---|
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))let password!: YNode;
<>
<Input returnKeyType="next" onSubmit={() => password.focus()} />
<Input ref={password} masked autoComplete="current-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. disableddisables the input (no focus) as well as applying the disabled styles; areadonlyinput keeps its focus and selection.inputAccessoryViewIDapplies on iOS, where the keyboard has an accessory bar.