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 nsh_base::input(), TSX with <Input> from nsh/base.
import { type InputRef, type JSX, mount, signal, Text, View } from "nsh";
import { Input } from "nsh/base";
import { button, buttonText, field, Inspector, Panel, Stage, ui, wide } from "./palette";
function App() {
const name = signal("");
const password = signal("");
const notes = signal("");
const submitted = signal("");
let passwordInput!: InputRef;
const signIn = () => submitted.set(`Signed in as ${name() || "nobody"}`);
const nameField = () => (
<Input
{...field()}
value={name()}
onChange={({ value }) => name.set(value)}
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}
{...field()}
value={password()}
onChange={({ value }) => password.set(value)}
masked
autoComplete="current-password"
returnKeyType="done"
onSubmit={signIn}
/>
);
const notesField = () => (
<Input
{...field({ style: { height: wide() ? 80 : 100 } })}
multiline
value={notes()}
onChange={({ value }) => notes.set(value)}
placeholder="Anything else?"
/>
);
const signInButton = () => (
<View onPress={signIn} disabled={password().length === 0} {...button("primary", { style: { marginTop: 4 } })}>
<Text style={buttonText("primary")}>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())}
{signInButton()}
</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()}
{signInButton()}
<Text style={{ color: ui().up }}>{submitted()}</Text>
</View>
)
}
</View>
);
}
mount(App);use nsh_base::input;
use nsh_core::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 { signal } from "nsh";
import { Input } from "nsh/base";
function NameField() {
const name = signal("");
return (
<Input
value={name()}
onChange={({ value }) => name.set(value)}
placeholder="Your name"
autoCapitalize="words"
returnKeyType="done"
style={{ fontSize: 16, padding: 12, borderWidth: 1, borderColor: "#d0d7de", borderRadius: 8 }}
/>
);
}With no width from its style or its parent, an input is as wide as twenty digits in its font, as an HTML <input> is: what it holds does not change its width.
#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. |
scrollbar | .scrollbar(..) | boolean | ScrollbarTheme | true | A multiline input’s scroll bar: the platform’s indicator, or the app’s built-in bar once it set one (Scrollbar); an object is a theme of its own, false none. |
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 on iOS, Android and the web: "username", "current-password", "new-password", "one-time-code", "email", "tel", "postal-code", "cc-number", "off", … | |
pasteBehavior | .paste_behavior(PasteBehavior::Report) | "insert" | "report" | "insert" | What a paste does: "insert" inserts the pasted text, "report" inserts nothing, for the app to handle what onPaste reports. Images and files are never inserted. |
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). |
The caret blinks while its window is shown and active. In GPUI windows on Linux it stays shown after ten seconds without typing or moving it, as in GTK apps, so a focused field left alone costs no frames.
#Colors
The colors of the placeholder, the caret and the selection highlight are style keys, with their Tailwind classes, and methods in Rust (UIKit tints the caret and the selection with one color, the caret’s when set):
| Style key | Rust | Class | Description |
|---|---|---|---|
placeholderColor | .placeholder_color(..) | placeholder:text-* | The placeholder’s color. |
caretColor | .caret_color(..) | caret-* | The caret’s color. |
selectionColor | .selection_color(..) | selection:text-* | The selection highlight’s color. |
input()
.placeholder("Search")
.text_color(rgb(0x18181b))
.placeholder_color(rgb(0x9ca3af))
.caret_color(rgb(0x2563eb))
.selection_color(rgb(0x93c5fd))<Input placeholder="Search" class="text-zinc-900 placeholder:text-zinc-400 selection:text-blue-600" />
<Input placeholder="Search" style={{ placeholderColor: "#9ca3af", caretColor: "#2563eb", selectionColor: "#93c5fd" }} />#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 (InputAccessoryView) 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::element::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| ..) | { value } | The user changed the text: value is the text now. |
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). |
onPaste | .on_paste(|e: &PasteEvent| ..) | { text, html, files, uris } | The user pasted: the text, its formatting as HTML (Linux, web), images (a screenshot) as Files, local files’ file: URLs. Before the onChange of the text it inserts (forms and keyboard). |
onCaretMove | .on_caret_move(|e: &CaretEvent| ..) | { bounds, pageBounds } | The caret moved, with the selection or as the text scrolled or wrapped: bounds is where it is in the input, pageBounds in the window, an Overlay’s anchor (forms and keyboard). |
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>| ..) | { size } | 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={({ size }) => height.set(Math.min(120, Math.max(40, size.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::element::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!: InputRef;
<>
<Input returnKeyType="next" onSubmit={() => password.focus()} />
<Input ref={password} masked autoComplete="current-password" />
</>;Keyboard.dismiss() from nsh (dismiss_keyboard() in Rust) hides the keyboard whichever input has it.
#Platform notes
- On macOS, single-line text, placeholders and carets share the native field’s font metrics and are centered together
inside the content box.
lineHeightparticipates in layout; multiline editors also use it for paragraph spacing. - Inputs inside a scroll view are kept clear of the keyboard natively. Elsewhere use
a bottom padding or margin of
env(keyboard-inset-height). 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.