Docs
Forms and the keyboard
Text entry is the input element, input() in Rust and <Input> in TSX: a UITextField or UITextView on iOS, an EditText on Android. Typing, the caret, selection, autocorrection and autofill are the platform’s own; your app gets the text through events and can control it. NSH also keeps focused inputs above the software keyboard on its own, and gives you the keyboard’s height as a reactive value for the cases it cannot handle by itself.
import { Input, type JSX, mount, Scroll, signal, Text, View, type YNode } from "nsh";
import { desktop, Inspector, Panel, size, Stage, ui, wide } from "./palette";
const field = () => ({ ...ui().inset, fontSize: 16, color: ui().text, placeholderColor: ui().faint, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10 });
const label = () => ({ fontSize: 13, color: ui().muted });
// A desktop field: 32 points high, the text a step smaller.
const desk = () => ({ ...field(), fontSize: size.label, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minHeight: size.control });
function App() {
const name = signal("");
const email = signal("");
const code = signal("");
const notes = signal("");
const notesHeight = signal(0);
const focused = signal("nothing");
const sent = signal("");
let emailInput!: YNode;
let passwordInput!: YNode;
let notesInput!: YNode;
let tries = 0;
const submit = () => sent.set(`#${++tries}: signed up ${name() || "nobody"} <${email() || "no e-mail"}>`);
type Style = () => ReturnType<typeof field>;
const nameInput = (style: Style) => (
<Input
style={style()}
value={name()}
onChange={(t) => name.set(t)}
placeholder="Ada Lovelace"
autoCapitalize="words"
autoComplete="name"
returnKeyType="next"
submitBehavior="submit"
onSubmit={() => emailInput.focus()}
onFocus={() => focused.set("name")}
/>
);
const emailField = (style: Style) => (
<Input
ref={emailInput}
style={style()}
value={email()}
onChange={(t) => email.set(t.trim().toLowerCase())}
placeholder="ada@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
returnKeyType="next"
submitBehavior="submit"
onSubmit={() => passwordInput.focus()}
onFocus={() => focused.set("e-mail")}
/>
);
const passwordField = (style: Style) => (
<Input
ref={passwordInput}
style={style()}
masked
autoComplete="new-password"
placeholder="At least 8 characters"
returnKeyType="next"
submitBehavior="submit"
onSubmit={() => notesInput.focus()}
onFocus={() => focused.set("password")}
/>
);
const codeField = (style: Style) => (
<Input
style={style()}
value={code()}
onChange={(t) => code.set(t.replace(/[^0-9]/g, ""))}
keyboardType="number-pad"
autoComplete="one-time-code"
maxLength={6}
onFocus={() => focused.set("code")}
/>
);
const notesField = (style: Style) => (
<Input
ref={notesInput}
multiline
maxLength={200}
value={notes()}
onChange={(t) => notes.set(t)}
onContentSizeChange={(e) => notesHeight.set(Math.min(e.height, 120))}
placeholder="Return adds a line"
style={style()}
onFocus={() => focused.set("notes")}
onBlur={() => focused.set("nothing")}
/>
);
// A phone: one scrolling column, full-width fields and button.
const column = () => (
<Scroll style={{ flex: 1, backgroundColor: ui().page }} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled">
<View style={{ padding: 16, paddingTop: 24, gap: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>Sign up</Text>
<Text style={label()}>Focused: {focused()}</Text>
<Text style={label()}>Name (controlled, return goes to the next field)</Text>
{nameInput(field)}
<Text style={label()}>E-mail</Text>
{emailField(field)}
<Text style={label()}>Password (uncontrolled)</Text>
{passwordField(field)}
<Text style={label()}>Code (number pad, 6 digits)</Text>
{codeField(() => ({ ...field(), letterSpacing: 8, textAlign: "center", fontSize: 22 }))}
<Text style={label()}>About you (grows with its text, 200 max)</Text>
{notesField(() => ({ ...field(), height: Math.max(notesHeight(), 22) + 20 }))}
<Text style={{ ...label(), textAlign: "right" }}>{`${notes().length}/200`}</Text>
<View
onPress={submit}
activeStyle={{ opacity: 0.6 }}
style={{
padding: 14,
borderRadius: 10,
...ui().primary,
alignItems: "center",
}}
>
<Text style={{ color: ui().onPrimary, fontWeight: "600" }}>Sign up</Text>
</View>
<Text style={{ color: ui().up }}>{sent()}</Text>
</View>
</Scroll>
);
// A wide window: a 360-point form, each label above its field, and its
// state beside it.
const group = (title: string, input: JSX.Element, help?: JSX.Element) => (
<View style={{ gap: 6 }}>
<Text style={{ fontSize: 13, fontWeight: "500", color: ui().text }}>{title}</Text>
{input}
{help}
</View>
);
const form = () => (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>Sign up</Text>
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage width={360} gap={14}>
{group("Name", nameInput(desk), <Text style={label()}>Controlled; Return goes to the next field</Text>)}
{group("E-mail", emailField(desk))}
{group("Password", passwordField(desk), <Text style={label()}>Uncontrolled</Text>)}
{group("Code", codeField(() => ({ ...desk(), width: 160, letterSpacing: 6, textAlign: "center", fontSize: 16 })), <Text style={label()}>6 digits</Text>)}
{group(
"About you",
notesField(() => ({ ...desk(), height: Math.max(notesHeight(), 20) + 14 })),
<Text style={{ ...label(), textAlign: "right", fontVariant: ["tabular-nums"] }}>{`${notes().length}/200`}</Text>,
)}
<View
onPress={submit}
activeStyle={{ opacity: 0.6 }}
style={{ ...ui().primary, alignSelf: "flex-start", height: size.control, paddingHorizontal: 14, borderRadius: 8, justifyContent: "center" }}
>
<Text style={{ fontSize: 13, color: ui().onPrimary, fontWeight: "600" }}>Sign up</Text>
</View>
</Stage>
<Inspector>
<Panel title="onFocus · onBlur">
<Text style={{ fontSize: 13, color: ui().text }}>Focused: {focused()}</Text>
</Panel>
<Panel title="onSubmit" minHeight={64}>
<Text style={{ fontSize: 13, color: ui().up }}>{sent()}</Text>
</Panel>
<Text style={{ fontSize: 12, color: ui().faint }}>
{desktop ? "Tab moves between fields; Return in a field goes to the next one." : "Return goes to the next field."}
</Text>
</Inspector>
</View>
</View>
);
return <>{() => (wide() ? form() : column())}</>;
}
mount(App);An input takes styles like any view (typed style methods in Rust, styles and Tailwind classes in TSX), so the fields of a form have your app’s own look. A labeled field is a short component of your own:
use nsh_core::ui::prelude::*;
fn field(label: &'static str, value: Signal<String>, masked: bool) -> impl IntoElement {
let focused = signal(false);
view()
.gap_1()
.child(text(label).text_sm().text_color(rgb(0x737373)))
.child(
input()
.value(move || value.get())
.on_change(move |text: &str| value.set(text.to_owned()))
.masked(masked)
.on_focus(move || focused.set(true))
.on_blur(move || focused.set(false))
.rounded_lg()
.border_1()
.px_3()
.py_2()
.text_base()
.border_color(move || if focused.get() { rgb(0x0969da) } else { rgb(0xd0d7de) }),
)
}import { Input, signal, Text, View } from "nsh";
function Field(props: { label: string; value: string; onChange: (text: string) => void; masked?: boolean }) {
const focused = signal(false);
return (
<View class="gap-1">
<Text class="text-sm text-neutral-500">{props.label}</Text>
<Input
value={props.value}
onChange={props.onChange}
masked={props.masked}
onFocus={() => focused.set(true)}
onBlur={() => focused.set(false)}
class="rounded-lg border px-3 py-2 text-base"
style={{ borderColor: focused() ? "#0969da" : "#d0d7de" }}
/>
</View>
);
}#Controlled and uncontrolled inputs
An input is controlled when you pass its value (.value(..), TSX value): it shows what your signal holds, and you update the signal from the change event (.on_change(..), TSX onChange).
use nsh_core::ui::prelude::*;
#[component]
pub fn App() -> impl IntoElement {
let name = signal(String::new());
view()
.flex_1()
.p(px(16.0))
.pt(px(60.0))
.gap(px(8.0))
.child(
input()
.value(move || name.get())
.on_change(move |text: &str| name.set(text.to_owned()))
.placeholder("Your name")
.text_size(px(17.0))
.border_1()
.border_color(rgb(0xd0d7de))
.rounded(px(10.0))
.p(px(12.0)),
)
.child(text(move || {
let name = name.get();
format!("Hello, {}", if name.is_empty() { "stranger" } else { &name })
}))
}import { Input, mount, signal, Text, View } from "nsh";
function App() {
const name = signal("");
return (
<View style={{ flex: 1, padding: 16, paddingTop: 60, gap: 8 }}>
<Input
value={name()}
onChange={(text) => name.set(text)}
placeholder="Your name"
style={{ fontSize: 17, borderWidth: 1, borderColor: "#d0d7de", borderRadius: 10, padding: 12 }}
/>
<Text>Hello, {name() || "stranger"}</Text>
</View>
);
}
mount(App);Because you control the value, you can transform what the user types: .on_change(move |t: &str| tag.set(t.to_uppercase())) uppercases, onChange={(t) => code.set(t.replace(/[^0-9]/g, ""))} keeps digits only.
#Input methods
Chinese, Japanese and Korean input methods compose text before committing it: pinyin turns into 腾 once a character is chosen. onCompositionChange (Rust .on_composition_change(..)) reports the composition as it goes, { composing: true, text: "teng" } while the user types and { composing: false, text: "" } when it ends; the committed text reaches onChange before the end. On iOS, Android, macOS and the web, onChange also reports the text being composed, as those systems keep it in the input; on Windows, Linux and GPUI it reports the committed text. A controlled input keeps the composition going whatever the app does with the value: a value set while the user composes is shown once the composition ends, so an input that uppercases or filters its text takes pinyin as typed.
A search box that searches the committed text only:
let query = signal(String::new());
let composing = signal(false);
input()
.value(move || query.get())
.on_change(move |text: &str| {
query.set(text.to_owned());
if !composing.get() {
search(text);
}
})
.on_composition_change(move |e: &Composition| {
composing.set(e.composing);
if !e.composing {
search(&query.get());
}
})const query = signal("");
let composing = false;
<Input
value={query()}
onChange={(text) => {
query.set(text);
if (!composing) search(text);
}}
onCompositionChange={(e) => {
composing = e.composing;
if (!e.composing) search(query());
}}
/>An input is uncontrolled without a value: it keeps its own text, starting at its default value (.default_value(..), TSX defaultValue), and you read it from the change or submit event when you need it. Use this for fields you only read on submit, such as a password.
#Props
In Rust, each prop is a method of input() named in snake case (.placeholder(..), .masked(true), .max_length(6), .return_key_type(ReturnKeyType::Next), .selected_range((0, 4))), with these names of their own: .keyboard_type(KeyboardType::Email) and the other KeyboardType variants, .auto_fill(AutoFill::Email) for autoComplete, .keyboard_appearance(..) and .input_accessory(..) for the ios={{ … }} props. Each event is an on_ method: .on_change(|text: &str| ..), .on_submit(|text: &str| ..), .on_focus(..), .on_blur(..), .on_selection_change(|s: &Selection| ..), .on_key_press(|key: &str| ..), .on_content_size_change(|size: &Size<Points>| ..).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | The text, controlled by JS. | |
defaultValue | string | The initial text of an uncontrolled input. | |
placeholder | string | Shown while the input is empty. | |
masked | boolean | false | Hides the text (passwords). |
keyboardType | KeyboardType | "default" | The keyboard layout. |
returnKeyType | ReturnKeyType | "default" | The return key’s label. |
autoCapitalize | "none" | "sentences" | "words" | "characters" | "sentences" | Automatic capitalization. |
autoCorrect | boolean | true | The system’s corrections. |
autoComplete | AutoComplete | What the input holds, for autofill. | |
multiline | boolean | false | Several lines; return inserts a line break unless submitBehavior says otherwise. |
maxLength | number | The most characters the user can enter. | |
readonly | boolean | false | The user can select and copy the text but not change it. |
autoFocus | boolean | false | Focused when it appears. |
submitBehavior | "submit" | "blurAndSubmit" | "newline" | single line "blurAndSubmit", multiline "newline" | What return does: submit and keep the focus, submit and unfocus, or insert a line break (multiline only). |
caret | boolean | true | The caret is drawn; false hides it. |
ios={{ keyboardAppearance }} | "default" | "light" | "dark" | "default" | The keyboard’s look (iOS). |
selectedRange | { start: number; end?: number } | The controlled selection, in UTF-16 units. | |
allowFontScaling | boolean | true | Follow the system text size. |
maxFontSizeMultiplier | number | The upper bound of the system scale (0: none). | |
ios={{ inputAccessoryViewID }} | string | iOS: the bar shown over the keyboard (see below). | |
ios={{ textContentType }} | TextContentType | iOS’s names for autoComplete values. |
Events:
| Event | Payload | When |
|---|---|---|
onChange | text: string | The text changed. |
onSubmit | { text } | The return key was pressed in an input that submits (single line, or submitBehavior "submit" / "blurAndSubmit"). |
onFocus / onBlur | The input gained / lost focus. | |
onSelectionChange | { start, end } | The caret or selection moved (UTF-16 units). |
onKeyPress | { key } | Before the text changes: key is the typed text, "Backspace" or "Enter". |
onContentSizeChange | { width, height } | The text’s size changed (at the input’s width when multiline), as it is typed; an empty input reports one empty line, whatever its placeholder. |
onCompositionChange | { composing, text } | An input method’s composition started, changed or ended (see Input methods). |
An input is styled like text: fontSize, color, fontFamily, textAlign, letterSpacing and the rest of text styles, plus borders, padding and backgrounds. The focus style (.focus(|s| ..) in Rust, the focus: Tailwind variant or focusStyle in TSX) applies while the input has focus. The placeholder’s color is .placeholder_color(..) (the
placeholderColor style key, placeholder:text-*), and the caret and selection’s color is .selection_color(..) (selectionColor,
selection:text-*):
input()
.placeholder("Search")
.rounded_lg()
.border_1()
.border_color(rgb(0xd4d4d8))
.px_3()
.py_2()
.text_base()
.focus(|s| s.border_color(rgb(0x3b82f6)))
.placeholder_color(rgb(0xa1a1aa))
.selection_color(rgb(0x2563eb))<Input
class="rounded-lg border border-zinc-300 px-3 py-2 text-base focus:border-blue-500 placeholder:text-zinc-400 selection:text-blue-600"
placeholder="Search"
/>#Keyboards and autofill
The keyboard type (.keyboard_type(..), TSX keyboardType) picks the layout:
| Rust | TSX | Keyboard |
|---|---|---|
KeyboardType::Default | "default" | Text. |
KeyboardType::Email | "email-address" | Text with @ and . at hand. |
KeyboardType::Url | "url" | Text with / and .com. |
KeyboardType::Number | "number-pad" | Digits only: no sign, no decimal separator (codes, PINs). |
KeyboardType::Decimal | "decimal-pad" | Digits and a decimal separator. |
KeyboardType::Numeric | "numeric" | As React Native: iOS’s decimal pad; on Android digits with a sign and a decimal separator. |
KeyboardType::NumbersAndPunctuation | "numbers-and-punctuation" | Digits, a minus sign and punctuation (iOS’s numbers and punctuation keyboard; on Android digits with a sign and a decimal separator). Use it for negative numbers on iOS. |
KeyboardType::Phone | "phone-pad" | A phone dial pad. |
On iOS, .keyboard_appearance(KeyboardAppearance::Dark) (TSX ios={{ keyboardAppearance: "dark" }}; or light, default) gives the keyboard a light or dark look regardless of the system’s appearance, for a dark app in light mode. Android’s keyboards always follow the system’s theme.
Autofill (.auto_fill(AutoFill::Email), TSX autoComplete) tells the system what the field holds, so it can offer saved names, addresses, passwords and one-time codes from messages. Rust’s AutoFill has a variant per kind (Name, GivenName, FamilyName, Email, Tel, Username, Password, NewPassword, OneTimeCode, StreetAddress, PostalCode, Country, CreditCardNumber, Url, Birthdate, …, and Off). TSX takes these values: "name", "given-name", "family-name", "email", "tel", "username", "current-password", "new-password", "one-time-code", "street-address", "postal-code", "country", "cc-number", "cc-exp", "cc-csc", "url", "birthdate-full" and others, or "off"; iOS’s own names ("emailAddress", "oneTimeCode", …) are accepted too, in autoComplete or ios={{ textContentType }}.
use nsh_core::ui::AutoFill;
input()
.keyboard_type(KeyboardType::Email)
.auto_capitalize(AutoCapitalize::None)
.auto_correct(false)
.auto_fill(AutoFill::Email)
.placeholder("E-mail")
input().masked(true).auto_fill(AutoFill::NewPassword).placeholder("Password")
input()
.keyboard_type(KeyboardType::Number)
.auto_fill(AutoFill::OneTimeCode)
.max_length(6)
.placeholder("Code")<Input keyboardType="email-address" autoCapitalize="none" autoCorrect={false} autoComplete="email" placeholder="E-mail" />
<Input masked autoComplete="new-password" placeholder="Password" />
<Input keyboardType="number-pad" autoComplete="one-time-code" maxLength={6} placeholder="Code" />#The return key and moving between fields
The return key type (.return_key_type(ReturnKeyType::Next), TSX returnKeyType) labels the return key: default, done, go, next, search or send. The submit behavior (.submit_behavior(..), TSX submitBehavior) says what pressing it does:
| Rust | TSX | Return | Default for |
|---|---|---|---|
SubmitBehavior::BlurAndSubmit | "blurAndSubmit" | fires the submit event, then the input loses focus | single-line inputs |
SubmitBehavior::Submit | "submit" | fires the submit event; the input keeps focus | |
SubmitBehavior::Newline | "newline" | inserts a line break (multiline inputs only) | multiline inputs |
A multiline input with "submit" or "blurAndSubmit" sends on return, as chat composers do, and still wraps its lines; on a hardware keyboard (desktops, a Mac, a tablet’s keyboard) Shift+Return or Option / Alt+Return inserts a line break. A return that commits an input method’s composition (Chinese or Japanese input, say) only commits it and never submits.
To move to the next field, keep refs to the inputs (element_ref() and .element_ref(&r) in Rust, ref in TSX) and call focus():
use nsh_core::ui::prelude::*;
pub fn sign_in() -> impl IntoElement {
let email = signal(String::new());
let password = element_ref();
let submit = move || log::info!("sign in {}", email.get());
view()
.flex_1()
.p(px(16.0))
.pt(px(60.0))
.gap(px(12.0))
.child(
input()
.value(move || email.get())
.on_change(move |t: &str| email.set(t.to_owned()))
.keyboard_type(KeyboardType::Email)
.auto_capitalize(AutoCapitalize::None)
.return_key_type(ReturnKeyType::Next)
.submit_behavior(SubmitBehavior::Submit)
.on_submit(move |_: &str| password.focus())
.placeholder("E-mail"),
)
.child(
input()
.element_ref(&password)
.masked(true)
.return_key_type(ReturnKeyType::Go)
.on_submit(move |_: &str| submit())
.placeholder("Password"),
)
}import { Input, mount, signal, View, type YNode } from "nsh";
function SignIn() {
const email = signal("");
let password!: YNode;
const submit = () => console.log("sign in", email());
return (
<View style={{ flex: 1, padding: 16, paddingTop: 60, gap: 12 }}>
<Input
value={email()}
onChange={(t) => email.set(t)}
keyboardType="email-address"
autoCapitalize="none"
returnKeyType="next"
submitBehavior="submit"
onSubmit={() => password.focus()}
placeholder="E-mail"
/>
<Input ref={password} masked returnKeyType="go" onSubmit={submit} placeholder="Password" />
</View>
);
}
mount(SignIn);Submitting without blurring (SubmitBehavior::Submit, submitBehavior="submit") on the first field keeps the keyboard up while focus moves, so it does not drop and rise again. A chat input that sends on return and stays focused submits the same way (see the composer below).
Both the maximum length and the submit behavior are enforced natively: the platform asks “may this character be added?” and “should return end editing?” as the user types, and NSH answers from the props without waiting for JS.
#Focus from code
An input’s ref has:
| Rust | TSX | Description |
|---|---|---|
focus() | focus() | Focuses the input and shows the keyboard. |
blur() | blur() | Unfocuses it. |
set_selection(start, end) | setSelection(start, end?) | Selects start..end (UTF-16 units); a caret when they are equal (TSX: without end). |
dismiss_keyboard() in Rust and Keyboard.dismiss() from nsh/base in TSX unfocus whichever input has focus:
view().on_press(|| dismiss_keyboard())import { View } from "nsh";
import { Keyboard } from "nsh/base";
<View onPress={() => Keyboard.dismiss()}>…</View>#Selection
The selection event (.on_selection_change(..), TSX onSelectionChange) reports the caret and selection; the ref’s set_selection or the selected range (.selected_range(..), TSX selectedRange) set them. Positions are UTF-16 code units, as JavaScript string indices are, so in TSX text.slice(start, end) is the selected text; in Rust, slice the text’s UTF-16 units.
/// `start..end` of `text` in UTF-16 units, as inputs count.
fn utf16_slice(text: &str, start: u32, end: u32) -> String {
let units: Vec<u16> = text.encode_utf16().collect();
let end = (end as usize).min(units.len());
String::from_utf16_lossy(&units[(start as usize).min(end)..end])
}
let content = signal("Select part of this text".to_owned());
let range = signal(Selection::default());
let field = element_ref();
view()
.child(
input()
.element_ref(&field)
.value(move || content.get())
.on_change(move |t: &str| content.set(t.to_owned()))
.on_selection_change(move |r: &Selection| range.set(*r)),
)
.child(text(move || {
let r = range.get();
format!("Selected: \"{}\"", content.with(|t| utf16_slice(t, r.start, r.end)))
}))
.child(
view()
.on_press(move || {
let len = content.with(|t| t.encode_utf16().count()) as u32;
field.set_selection(0, len);
})
.child(text("Select all")),
)const text = signal("Select part of this text");
const range = signal({ start: 0, end: 0 });
const selection = signal<{ start: number; end?: number } | undefined>(undefined);
<Input
value={text()}
onChange={(t) => text.set(t)}
selectedRange={selection()}
onSelectionChange={(r) => range.set(r)}
/>
<Text>Selected: "{text().slice(range().start, range().end)}"</Text>
<View onPress={() => selection.set({ start: 0, end: text().length })}><Text>Select all</Text></View>#Multiline inputs that grow
A multiline input (.multiline(true), TSX multiline) scrolls its text inside its own height. To grow it with its content, feed the content size event’s height (.on_content_size_change(..), TSX onContentSizeChange) back into its style, with a limit:
let height = signal(px(0.0));
input()
.multiline(true)
.placeholder("Write a comment")
.on_content_size_change(move |e: &Size<Points>| height.set(e.height.min(px(120.0))))
.text_size(px(16.0))
.p(px(10.0))
.border_1()
.border_color(rgb(0xd0d7de))
.rounded(px(10.0))
.h(move || height.get().max(px(20.0)) + px(20.0))const height = signal(0);
<Input
multiline
placeholder="Write a comment"
onContentSizeChange={(e) => height.set(Math.min(e.height, 120))}
style={{ fontSize: 16, padding: 10, borderWidth: 1, borderColor: "#d0d7de", borderRadius: 10, height: Math.max(height(), 20) + 20 }}
/>The reported height is the text’s (at the input’s width), so add the vertical padding yourself, as above.
#Keeping inputs above the keyboard
#Inside scroll views: automatic
When the keyboard appears over a focused input that is inside a scroll view or a list, NSH gives the scroll view a bottom inset as tall as the keyboard and scrolls the input into view. This is native and needs no code. A long form is simply a scroll view of inputs:
scroll()
.flex_1()
.keyboard_dismiss_mode(KeyboardDismissMode::OnDrag)
.keyboard_taps(KeyboardTaps::Handled)
.child(view().p(px(16.0)).gap(px(12.0)) /* inputs */)<Scroll style={{ flex: 1 }} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled">
<View style={{ padding: 16, gap: 12 }}>
{/* inputs */}
</View>
</Scroll>The dismiss mode (.keyboard_dismiss_mode(..), TSX keyboardDismissMode: none, on drag, interactive) lets dragging the form hide the keyboard; handled taps (.keyboard_taps(KeyboardTaps::Handled), TSX keyboardShouldPersistTaps="handled") let a tap on a button go through while the keyboard is up instead of only dismissing it. See Scrolling.
#Content outside scroll views: KeyboardAvoidingView
A message bar pinned to the bottom of a screen is not in a scroll view, so nothing moves it. Wrap the screen in KeyboardAvoidingView from nsh_base (TSX: nsh/base): it makes room at the bottom as tall as the keyboard, animated with the keyboard.
use nsh_base::keyboard_avoiding_view;
use nsh_core::ui::prelude::*;
pub fn chat_screen() -> impl IntoElement {
let draft = signal(String::new());
let send = move || {
let text = draft.get();
if !text.trim().is_empty() {
log::info!("send {text}");
}
draft.set(String::new());
};
keyboard_avoiding_view()
.flex_1()
.child(scroll().flex_1().keyboard_dismiss_mode(KeyboardDismissMode::Interactive) /* messages */)
.child(
view()
.flex_row()
.p(px(8.0))
// Above the home indicator, unless the keyboard covers it.
.pb(move || {
px(8.0) + if keyboard_height() > px(0.0) { px(0.0) } else { safe_area().bottom }
})
.child(
input()
.value(move || draft.get())
.on_change(move |t: &str| draft.set(t.to_owned()))
.multiline(true)
.submit_behavior(SubmitBehavior::Submit)
.return_key_type(ReturnKeyType::Send)
.on_submit(move |_: &str| send())
.placeholder("Message")
.flex_1()
.max_h(px(120.0)),
),
)
}import { Input, Scroll, signal, View } from "nsh";
import { keyboardFrame, KeyboardAvoidingView, safeAreaInsets } from "nsh/base";
function ChatScreen() {
const insets = safeAreaInsets();
const draft = signal("");
const send = () => {
if (draft().trim()) console.log("send", draft());
draft.set("");
};
return (
<KeyboardAvoidingView style={{ flex: 1 }}>
<Scroll style={{ flex: 1 }} keyboardDismissMode="interactive">
{/* messages */}
</Scroll>
<View
style={{
flexDirection: "row",
padding: 8,
// Above the home indicator, unless the keyboard covers it.
paddingBottom: 8 + (keyboardFrame().height > 0 ? 0 : insets().bottom),
}}
>
<Input
value={draft()}
onChange={(t) => draft.set(t)}
multiline
submitBehavior="submit"
returnKeyType="send"
onSubmit={send}
placeholder="Message"
style={{ flex: 1, maxHeight: 120 }}
/>
</View>
</KeyboardAvoidingView>
);
}In Rust each prop is a setter of keyboard_avoiding_view() (.behavior(AvoidBehavior::Height), .offset(px(49.0)), .enabled(move || ..)):
| Prop | Type | Default | Description |
|---|---|---|---|
behavior | "padding" | "height" | "padding" | padding pads the bottom by the keyboard’s height; height shrinks the view (its background ends at the keyboard). |
enabled | boolean | true | Avoid the keyboard. |
offset | number | 0 | Added to the keyboard’s height while it shows (a tab bar below the view, say). |
It assumes it reaches the bottom of the surface, as a screen’s content does. On an Android host whose window resizes for the keyboard, the window itself shrinks and KeyboardAvoidingView has nothing to do. See KeyboardAvoidingView.
#The keyboard accessor
keyboard_height() and keyboard_duration() in Rust, and keyboardFrame() from nsh/base in TSX (a { height, duration }), are reactive: how far the keyboard reaches into the surface (0 when hidden; the bottom safe area under it included) and its animation’s duration in seconds. Read them for anything custom: hide a floating button while typing, or size a panel.
view().child(when(
|| keyboard_height() == px(0.0),
|| view().absolute().right(px(16.0)).bottom(px(16.0)), /* … */
))import { View } from "nsh";
import { keyboardFrame } from "nsh/base";
<If when={keyboardFrame().height === 0}>
<View style={{ position: "absolute", right: 16, bottom: 16 }}>…</View>
</If>#A bar over the keyboard (iOS): InputAccessoryView
input_accessory_view(id) in Rust and InputAccessoryView from nsh/ios in TSX show a bar attached to the top of the keyboard while an input that names it has focus: iOS’s inputAccessoryView. It moves with the keyboard natively, including during interactive dismissal. It takes no room where it is placed in the tree; inputs refer to it by its id (.input_accessory(id), TSX ios={{ inputAccessoryViewID }} and nativeID):
use nsh_core::ui::prelude::*;
pub fn note() -> impl IntoElement {
let content = signal(String::new());
let field = element_ref();
view()
.p(px(16.0))
.child(
input()
.element_ref(&field)
.value(move || content.get())
.on_change(move |t: &str| content.set(t.to_owned()))
.input_accessory("note-bar")
.placeholder("A note"),
)
.child(
input_accessory_view("note-bar").bg(rgb(0xf6f8fa)).child(
view()
.flex_row()
.items_center()
.p(px(8.0))
.child(
text(move || format!("{} characters", content.with(|t| t.chars().count())))
.flex_1()
.text_color(rgb(0x57606a)),
)
.child(
text("Done")
.on_press(move || field.blur())
.text_color(rgb(0x0969da))
.font_weight(FontWeight::SEMIBOLD),
),
),
)
}import { Input, signal, Text, View, type YNode } from "nsh";
import { InputAccessoryView } from "nsh/ios";
function Note() {
const text = signal("");
let input!: YNode;
return (
<View style={{ padding: 16 }}>
<Input
ref={input}
value={text()}
onChange={(t) => text.set(t)}
ios={{ inputAccessoryViewID: "note-bar" }}
placeholder="A note"
/>
<InputAccessoryView nativeID="note-bar" style={{ backgroundColor: "#f6f8fa" }}>
<View style={{ flexDirection: "row", alignItems: "center", padding: 8 }}>
<Text style={{ flex: 1, color: "#57606a" }}>{text().length} characters</Text>
<Text onPress={() => input.blur()} style={{ color: "#0969da", fontWeight: "600" }}>Done</Text>
</View>
</InputAccessoryView>
</View>
);
}The bar is as wide as the window and as tall as its content. Input accessories are iOS’s: on Android the bar is an empty hidden view (isAvailable from nsh/ios is false), so place those controls elsewhere there, for example above the input (os() == Os::Android in Rust, platform.os === "android" in TSX).
#Related
Input: the element reference.KeyboardAvoidingViewandKeyboard.nsh/ios:InputAccessoryView.- Scrolling: keyboard dismissal and taps in scroll views.