Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Documentation

Formulaires et clavier

La saisie de texte passe par l’élément input, input() en Rust et <Input> en TSX : un UITextField ou un UITextView sur iOS, un EditText sur Android. La frappe, le curseur, la sélection, la correction automatique et le remplissage automatique sont ceux de la plateforme ; votre app reçoit le texte via des événements et peut le contrôler. NSH garde aussi de lui-même les champs ayant le focus au-dessus du clavier logiciel, et vous donne la hauteur du clavier sous forme de valeur réactive pour les cas qu’il ne peut pas gérer seul.

guide-forms.tsx
guide-forms.tsxLive
import { type InputRef, type JSX, mount, Scroll, signal, Text, View } from "nsh";
import { Input } from "nsh/base";
import { button, buttonText, desktop, field, Inspector, Panel, Stage, ui, wide } from "./palette";

const label = () => ({ fontSize: 13, color: ui().muted });

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!: InputRef;
  let passwordInput!: InputRef;
  let notesInput!: InputRef;

  let tries = 0;
  const submit = () => sent.set(`#${++tries}: signed up ${name() || "nobody"} <${email() || "no e-mail"}>`);

  type Style = () => object;
  const nameInput = (style?: Style) => (
    <Input
      {...field({ style })}
      value={name()}
      onChange={({ value }) => name.set(value)}
      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}
      {...field({ style })}
      value={email()}
      onChange={({ value }) => email.set(value.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}
      {...field({ 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
      {...field({ style })}
      value={code()}
      onChange={({ value }) => code.set(value.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={({ value }) => notes.set(value)}
      onContentSizeChange={({ size }) => notesHeight.set(Math.min(size.height, 120))}
      placeholder="Return adds a line"
      {...field({ 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()}

        <Text style={label()}>E-mail</Text>
        {emailField()}

        <Text style={label()}>Password (uncontrolled)</Text>
        {passwordField()}

        <Text style={label()}>Code (number pad, 6 digits)</Text>
        {codeField(() => ({ letterSpacing: 8, textAlign: "center", fontSize: 22 }))}

        <Text style={label()}>About you (grows with its text, 200 max)</Text>
        {notesField(() => ({ height: Math.max(notesHeight(), 22) + 20 }))}
        <Text style={{ ...label(), textAlign: "right" }}>{`${notes().length}/200`}</Text>

        <View onPress={submit} {...button("primary")}>
          <Text style={buttonText("primary")}>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(), <Text style={label()}>Controlled; Return goes to the next field</Text>)}
          {group("E-mail", emailField())}
          {group("Password", passwordField(), <Text style={label()}>Uncontrolled</Text>)}
          {group("Code", codeField(() => ({ width: 160, letterSpacing: 6, textAlign: "center", fontSize: 16 })), <Text style={label()}>6 digits</Text>)}
          {group(
            "About you",
            notesField(() => ({ height: Math.max(notesHeight(), 20) + 14 })),
            <Text style={{ ...label(), textAlign: "right", fontVariant: ["tabular-nums"] }}>{`${notes().length}/200`}</Text>,
          )}
          <View onPress={submit} {...button("primary")}>
            <Text style={buttonText("primary")}>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);

Un input accepte des styles comme n’importe quelle vue (méthodes de style typées en Rust, styles et classes Tailwind en TSX), de sorte que les champs d’un formulaire ont l’apparence propre à votre app. Un champ avec libellé est un petit composant à vous :

use nsh_core::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) }),
        )
}

#Inputs contrôlés et non contrôlés

Un input est contrôlé quand vous lui passez sa valeur (.value(..), TSX value) : il affiche ce que contient votre signal, et vous mettez à jour le signal depuis l’événement de changement (.on_change(..), TSX onChange).

use nsh_core::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 })
        }))
}

Comme vous contrôlez la valeur, vous pouvez transformer ce que l’utilisateur saisit : .on_change(move |t: &str| tag.set(t.to_uppercase())) met en majuscules, onChange={({ value }) => code.set(value.replace(/[^0-9]/g, ""))} ne garde que les chiffres.

#Méthodes de saisie

Les méthodes de saisie chinoise, japonaise et coréenne composent le texte avant de le valider : le pinyin devient 腾 une fois un caractère choisi. onCompositionChange (Rust .on_composition_change(..)) signale la composition au fil de l’eau, { composing: true, text: "teng" } pendant que l’utilisateur tape et { composing: false, text: "" } quand elle se termine ; le texte validé parvient à onChange avant la fin. Sur iOS, Android, macOS et le web, onChange signale aussi le texte en cours de composition, car ces systèmes le conservent dans le champ ; sur Windows, Linux et GPUI, il signale le texte validé. Un input contrôlé maintient la composition quoi que l’app fasse de la valeur : un value défini pendant que l’utilisateur compose s’affiche une fois la composition terminée, si bien qu’un input qui met son texte en majuscules ou le filtre accepte le pinyin tel qu’il est saisi.

Une zone de recherche qui ne cherche que le texte validé :

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());
        }
    })

Un input est non contrôlé sans valeur : il garde son propre texte, en partant de sa valeur par défaut (.default_value(..), TSX defaultValue), et vous le lisez depuis l’événement de changement ou de soumission quand vous en avez besoin. Utilisez-le pour les champs que vous ne lisez qu’à la soumission, comme un mot de passe.

#Props

En Rust, chaque prop est une méthode de input() nommée en snake case (.placeholder(..), .masked(true), .max_length(6), .return_key_type(ReturnKeyType::Next), .selected_range((0, 4))), avec ces noms propres : .keyboard_type(KeyboardType::Email) et les autres variantes de KeyboardType, .auto_fill(AutoFill::Email) pour autoComplete, .keyboard_appearance(..) et .input_accessory(..) pour les props ios={{ … }}. Chaque événement est une méthode on_ : .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>| ..).

PropTypeDéfautDescription
valuestringLe texte, contrôlé par JS.
defaultValuestringLe texte initial d’un input non contrôlé.
placeholderstringAffiché tant que l’input est vide.
maskedbooleanfalseMasque le texte (mots de passe).
keyboardTypeKeyboardType"default"La disposition du clavier.
returnKeyTypeReturnKeyType"default"Le libellé de la touche Entrée.
autoCapitalize"none" | "sentences" | "words" | "characters""sentences"Mise en majuscules automatique.
autoCorrectbooleantrueLes corrections du système.
autoCompleteAutoCompleteCe que contient l’input, pour le remplissage automatique.
multilinebooleanfalsePlusieurs lignes ; Entrée insère un saut de ligne sauf si submitBehavior en dispose autrement.
maxLengthnumberLe nombre maximal de caractères que l’utilisateur peut saisir.
readonlybooleanfalseL’utilisateur peut sélectionner et copier le texte, mais pas le modifier.
autoFocusbooleanfalsePrend le focus à son apparition.
submitBehavior"submit" | "blurAndSubmit" | "newline"une ligne "blurAndSubmit", multiligne "newline"Ce que fait Entrée : soumettre et garder le focus, soumettre et retirer le focus, ou insérer un saut de ligne (multiligne uniquement).
pasteBehavior"insert" | "report""insert"Ce que fait un collage : insérer son texte, ou rien (collage).
caretbooleantrueLe curseur est dessiné ; false le masque.
ios={{ keyboardAppearance }}"default" | "light" | "dark""default"L’apparence du clavier (iOS).
selectedRange{ start: number; end?: number }La sélection contrôlée, en unités UTF-16.
allowFontScalingbooleantrueSuit la taille de texte du système.
maxFontSizeMultipliernumberLa borne supérieure de l’échelle du système (0 : aucune).
ios={{ inputAccessoryViewID }}stringiOS : la barre affichée au-dessus du clavier (voir ci-dessous).
ios={{ textContentType }}TextContentTypeLes noms qu’iOS donne aux valeurs de autoComplete.

Événements :

ÉvénementDonnéesQuand
onChange{ value }Le texte a changé : value est le texte actuel.
onSubmit{ text }La touche Entrée a été pressée dans un input qui soumet (une seule ligne, ou submitBehavior "submit" / "blurAndSubmit").
onFocus / onBlurL’input a gagné / perdu le focus.
onSelectionChange{ start, end }Le curseur ou la sélection a bougé (unités UTF-16).
onPaste{ text, html, files, uris }L’utilisateur a collé : du texte, son HTML (Linux, web), des images sous forme de Files, des URL de fichiers locaux (collage).
onCaretMove{ bounds, pageBounds }Le curseur a bougé : où il se trouve dans l’input et dans la fenêtre (le curseur).
onKeyPress{ key }Avant que le texte change : key est le texte saisi, "Backspace" ou "Enter".
onContentSizeChange{ size }La taille du texte (size) a changé (à la largeur de l’input en multiligne), au fil de la saisie ; un input vide signale une ligne vide, quel que soit son placeholder.
onCompositionChange{ composing, text }La composition d’une méthode de saisie a commencé, changé ou pris fin (voir Méthodes de saisie).

Un input se style comme du texte : fontSize, color, fontFamily, textAlign, letterSpacing et le reste des styles de texte, plus les bordures, le padding et les arrière-plans. Le style de focus (.focus(|s| ..) en Rust, la variante Tailwind focus: ou focusStyle en TSX) s’applique tant que l’input a le focus. La couleur du placeholder est .placeholder_color(..) (la clé de style placeholderColor, placeholder:text-*), celle du curseur est .caret_color(..) (caretColor, caret-*), et celle de la surbrillance de la sélection est .selection_color(..) (selectionColor, selection:text-*) ; UIKit teinte le curseur et la sélection d’une seule couleur, celle du curseur si elle est définie :

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

#Claviers et remplissage automatique

Le type de clavier (.keyboard_type(..), TSX keyboardType) détermine la disposition :

RustTSXClavier
KeyboardType::Default"default"Texte.
KeyboardType::Email"email-address"Texte avec @ et . à portée de main.
KeyboardType::Url"url"Texte avec / et .com.
KeyboardType::Number"number-pad"Chiffres uniquement : ni signe, ni séparateur décimal (codes, codes PIN).
KeyboardType::Decimal"decimal-pad"Chiffres et séparateur décimal.
KeyboardType::Numeric"numeric"Comme React Native : le pavé décimal d’iOS ; sur Android, des chiffres avec un signe et un séparateur décimal.
KeyboardType::NumbersAndPunctuation"numbers-and-punctuation"Chiffres, signe moins et ponctuation (le clavier de chiffres et de ponctuation d’iOS ; sur Android, des chiffres avec un signe et un séparateur décimal). Utilisez-le pour les nombres négatifs sur iOS.
KeyboardType::Phone"phone-pad"Un pavé de composition téléphonique.

Sur iOS, .keyboard_appearance(KeyboardAppearance::Dark) (TSX ios={{ keyboardAppearance: "dark" }} ; ou light, par défaut) donne au clavier une apparence claire ou sombre indépendamment de l’apparence du système, pour une app sombre en mode clair. Les claviers d’Android suivent toujours le thème du système.

Le remplissage automatique (.auto_fill(AutoFill::Email), TSX autoComplete) indique au système ce que contient le champ, afin qu’il puisse proposer des noms, adresses, mots de passe enregistrés et codes à usage unique issus des messages. Le AutoFill de Rust a une variante par type (Name, GivenName, FamilyName, Email, Tel, Username, Password, NewPassword, OneTimeCode, StreetAddress, PostalCode, Country, CreditCardNumber, Url, Birthdate, …, et Off). TSX accepte ces valeurs : "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" et d’autres, ou "off" ; les noms propres à iOS ("emailAddress", "oneTimeCode", …) sont aussi acceptés, dans autoComplete ou ios={{ textContentType }}.

use nsh_core::element::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")

#La touche Entrée et le passage d’un champ à l’autre

Le type de touche Entrée (.return_key_type(ReturnKeyType::Next), TSX returnKeyType) donne son libellé à la touche : default, done, go, next, search ou send. Le comportement de soumission (.submit_behavior(..), TSX submitBehavior) définit ce qui se passe quand on la presse :

RustTSXEntréePar défaut pour
SubmitBehavior::BlurAndSubmit"blurAndSubmit"déclenche l’événement de soumission, puis l’input perd le focusinputs d’une seule ligne
SubmitBehavior::Submit"submit"déclenche l’événement de soumission ; l’input garde le focus
SubmitBehavior::Newline"newline"insère un saut de ligne (inputs multilignes uniquement)inputs multilignes

Un input multiligne avec "submit" ou "blurAndSubmit" envoie sur Entrée, comme les zones de saisie de messagerie, tout en continuant de renvoyer ses lignes à la ligne ; sur un clavier physique (ordinateurs de bureau, Mac, clavier de tablette), Maj+Entrée ou Option / Alt+Entrée insère un saut de ligne. Une touche Entrée qui valide la composition d’une méthode de saisie (saisie chinoise ou japonaise, par exemple) ne fait que la valider et ne soumet jamais.

Pour passer au champ suivant, gardez des refs vers les inputs (element_ref() et .element_ref(&r) en Rust, ref en TSX) et appelez focus() :

use nsh_core::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"),
        )
}

Soumettre sans retirer le focus (SubmitBehavior::Submit, submitBehavior="submit") sur le premier champ garde le clavier affiché pendant que le focus se déplace, de sorte qu’il ne s’abaisse pas pour remonter aussitôt. Un input de chat qui envoie sur Entrée et garde le focus soumet de la même façon (voir la zone de saisie plus bas).

La longueur maximale comme le comportement de soumission sont appliqués nativement : la plateforme demande « ce caractère peut-il être ajouté ? » et « Entrée doit-elle terminer l’édition ? » pendant la saisie, et NSH répond à partir des props sans attendre JS.

#Focus depuis le code

La ref d’un input propose :

RustTSXDescription
focus()focus()Donne le focus à l’input et affiche le clavier.
blur()blur()Lui retire le focus.
set_selection(start, end)setSelection(start, end?)Sélectionne start..end (unités UTF-16) ; un curseur lorsqu’ils sont égaux (TSX : sans end).

Sur macOS et le web, focus() et blur() sur la ref d’une vue qui accepte les appuis déplacent le focus clavier comme le fait Tab (focus clavier).

dismiss_keyboard() en Rust et Keyboard.dismiss() de nsh en TSX retirent le focus de l’input qui l’a :

view().on_press(|| dismiss_keyboard())

#Sélection

L’événement de sélection (.on_selection_change(..), TSX onSelectionChange) signale le curseur et la sélection ; le set_selection de la ref ou la plage sélectionnée (.selected_range(..), TSX selectedRange) les définissent. Les positions sont des unités de code UTF-16, comme les indices des chaînes JavaScript, donc en TSX text.slice(start, end) est le texte sélectionné ; en Rust, découpez les unités UTF-16 du texte.

/// `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")),
    )

#Le curseur

L’événement de curseur (.on_caret_move(..), TSX onCaretMove) indique où se trouve le curseur, afin que ce qui lui est associé puisse s’ouvrir à cet endroit : suggestions pour une mention @, menu d’une commande slash, sélecteur d’emoji. bounds est un Bounds dans l’input (origin et size, avec le padding et le défilement de l’input appliqués), aussi large que la plateforme dessine le curseur et aussi haut que sa ligne ; pageBounds est la même boîte dans la fenêtre (ou la boîte de dialogue où se trouve l’input), qu’un overlay prend comme son anchor. L’événement arrive tant que l’input a le focus, chaque fois que le curseur bouge : avec la sélection, et quand le texte de l’input défile ou passe à d’autres lignes. En Rust, un overlay se place par rapport à une boîte avec .anchor_bounds(..).

let draft = signal(String::new());
let end = signal(0u32);
let caret = signal(None::<Bounds<Points>>);

view()
    .child(
        input()
            .multiline(true)
            .value(move || draft.get())
            .on_change(move |t: &str| draft.set(t.to_owned()))
            .on_selection_change(move |s: &Selection| end.set(s.end))
            .on_caret_move(move |e: &CaretEvent| {
                // Suggestions while the word before the caret starts with "@".
                let typed = draft.with(|t| utf16_slice(t, 0, end.get()));
                let word = typed.rsplit(char::is_whitespace).next().unwrap_or("");
                caret.set(word.starts_with('@').then_some(e.page_bounds));
            })
            .p_2(),
    )
    .child(when(
        move || caret.get().is_some(),
        move || {
            overlay()
                .anchor_bounds(move || caret.get().unwrap_or_default())
                .placement(Placement::Bottom)
                .align(Align::Start)
                .offset(px(4.0))
                .child(text("@ada"))
                .child(text("@grace"))
        },
    ))

pageBounds est l’endroit où se trouvait le curseur à l’arrivée de l’événement : une page qui défile sous l’input déplace l’input mais n’envoie aucun événement ; fermez donc les suggestions au défilement, ou ancrez-les à l’input lui-même.

#Coller des images et des fichiers

L’événement de collage (.on_paste(..), TSX onPaste) indique ce que l’utilisateur a collé, via le raccourci clavier, le Coller d’un menu ou l’image d’un clavier : text, html, files et uris. html est le même contenu avec sa mise en forme, tel qu’un navigateur, une suite bureautique ou un éditeur de texte riche l’a copié (sur Linux et le web). files correspond à ce que le presse-papiers contenait comme données, comme une capture d’écran ou une image copiée depuis une page ou une autre app : des Files dont l’app peut téléverser ou afficher les octets, nommés image.png (ou du type qu’il est). uris correspond aux URL file: de fichiers copiés dans un gestionnaire de fichiers, comme celles d’un glisser-déposer de fichiers. La plateforme lit le presse-papiers au moment où l’utilisateur colle, et seulement alors.

L’input insère lui-même le texte collé, comme toujours : l’événement arrive avant le onChange de ce texte. Les images et les fichiers ne sont jamais insérés, puisqu’un input contient du texte. Avec pasteBehavior "report", l’input n’insère rien, et c’est l’app qui décide de ce que fait un collage : envoyer un long texte en pièce jointe, ou insérer une version nettoyée. L’input décide immédiatement, à partir de la prop, car le système le lui demande sur-le-champ.

let attachments = signal(Vec::<PastedFile>::new());

input()
    .multiline(true)
    .placeholder("Message")
    .on_paste(move |e: &PasteEvent| {
        // A screenshot pasted into the message goes along as an image.
        attachments.update(|a| a.extend(e.files.iter().cloned()));
    })
    .p_2()

Téléversez un fichier collé comme n’importe quel File, avec fetch et FormData (form.append("image", file)) ; ses octets restent natifs jusqu’à leur envoi. Sur Android, les claviers qui insèrent des images (GIF, stickers) les signalent de la même façon. Sur Linux, le presse-papiers est lu de manière asynchrone, l’événement peut donc arriver après le onChange du texte qu’il insère. Les inputs masqués sur macOS conservent le collage propre d’AppKit et ne signalent rien.

#Inputs multilignes qui grandissent

Un input multiligne (.multiline(true), TSX multiline) fait défiler son texte dans sa propre hauteur. Pour le faire grandir avec son contenu, réinjectez la hauteur de l’événement de taille du contenu (.on_content_size_change(..), TSX onContentSizeChange) dans son style, avec une limite :

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

La hauteur signalée est celle du texte (à la largeur de l’input) ; ajoutez donc vous-même le padding vertical, comme ci-dessus.

#Garder les champs de saisie au-dessus du clavier

#Dans les scroll views : automatique

Lorsque le clavier apparaît au-dessus d’un champ de saisie ayant le focus et situé dans une scroll view ou une liste, NSH donne à la scroll view une marge intérieure basse aussi haute que le clavier et fait défiler le champ jusqu’à le rendre visible. Cela se fait nativement et ne demande aucun code. Un long formulaire n’est simplement qu’une scroll view de champs de saisie :

scroll()
    .flex_1()
    .keyboard_dismiss_mode(KeyboardDismissMode::OnDrag)
    .keyboard_taps(KeyboardTaps::Handled)
    .child(view().p(px(16.0)).gap(px(12.0)) /* inputs */)

Le mode de fermeture (.keyboard_dismiss_mode(..), TSX keyboardDismissMode : none, on drag, interactive) permet de masquer le clavier en faisant glisser le formulaire ; les taps gérés (.keyboard_taps(KeyboardTaps::Handled), TSX keyboardShouldPersistTaps="handled") permettent à un tap sur un bouton d’être pris en compte pendant que le clavier est affiché, au lieu de se contenter de le fermer. Voir Défilement.

#Contenu en dehors des scroll views

Sur iOS, lorsque le clavier couvrirait un champ de saisie ayant le focus et ne se trouvant dans aucune scroll view, NSH déplace le contenu de son écran (ou de sa boîte de dialogue) vers le haut jusqu’à ce que le champ soit juste au-dessus du clavier, en suivant la courbe d’animation du clavier, puis le ramène vers le bas quand le clavier se masque ou que le champ perd le focus, comme le fait SwiftUI (sur Android, une fenêtre en mode adjustPan est décalée par le système). NSH ne le fait que pour ce que la mise en page de l’application laisse couvert : une application qui ménage elle-même la place, comme ci-dessous, n’est pas déplacée une seconde fois.

Cela déplace toute la page. Une barre de message ancrée en bas d’un écran devrait plutôt suivre le clavier pendant que le contenu au-dessus se réduit : ajoutez au bas de l’écran un padding égal à env(keyboard-inset-height) en CSS (paddingBottom: "env(keyboard-inset-height)", Tailwind pb-[env(keyboard-inset-height)], Rust .pb_keyboard()) : cela ménage en bas un espace aussi haut que le clavier, animé nativement avec le clavier.

use nsh_core::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());
    };
    view()
        .flex_1()
        .pb_keyboard()
        .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)),
                ),
        )
}

Le padding (.pb_keyboard()) réduit le contenu au-dessus tandis que l’arrière-plan atteint le bas de l’écran ; une marge basse (marginBottom: "env(keyboard-inset-height)", .mb_keyboard()) fait terminer toute la vue au sommet du clavier. calc(env(keyboard-inset-height) + <length>) (Rust .pb_keyboard_plus(..), .mb_keyboard_plus(..)) ajoute une longueur, par exemple moins la hauteur d’une barre d’onglets située sous la vue.

La hauteur complète convient à une vue qui atteint le bas de la surface, comme le fait le contenu d’un écran. Sur un hôte Android dont la fenêtre est redimensionnée pour le clavier, c’est la fenêtre elle-même qui rétrécit et la variable vaut 0. Voir Clavier.

#L’accesseur du clavier

keyboard_height() et keyboard_duration() en Rust, ainsi que keyboardFrame() depuis nsh en TSX (un { height, duration }), sont réactifs : ils donnent jusqu’où le clavier s’avance dans la surface (0 lorsqu’il est masqué ; la zone sûre inférieure située sous lui est incluse) et la durée de son animation en millisecondes. Utilisez-les pour tout besoin personnalisé : masquer un bouton flottant pendant la saisie, ou dimensionner un panneau. Ce qui change avec eux dans la mise à jour où démarre le mouvement du clavier suit le clavier, sur sa courbe et chronométré à partir du moment où il a commencé à bouger (LayoutCurve::Keyboard), comme le fait env(keyboard-inset-height) ; une animation que l’application configure pour cette mise à jour (LayoutAnimation.configureNext) prévaut.

view().child(when(
    || keyboard_height() == px(0.0),
    || view().absolute().right(px(16.0)).bottom(px(16.0)), /* … */
))

#Une barre au-dessus du clavier (iOS) : InputAccessoryView

input_accessory_view(id) en Rust et InputAccessoryView depuis nsh/mobile en TSX affichent une barre attachée au sommet du clavier tant qu’un champ de saisie qui la désigne a le focus : le inputAccessoryView d’iOS. Elle suit nativement le clavier, y compris pendant une fermeture interactive. Elle ne prend aucune place là où elle est placée dans l’arbre ; les champs de saisie y font référence par son id (.input_accessory(id), TSX ios={{ inputAccessoryViewID }} et nativeID) :

use nsh_core::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),
                    ),
            ),
        )
}

La barre est aussi large que la fenêtre et aussi haute que son contenu. Les input accessories sont propres à iOS : sur Android, la barre est une vue vide et masquée, il faut donc y placer ces contrôles ailleurs, par exemple au-dessus du champ de saisie (os() == Os::Android en Rust, platform.os === "android" en TSX).

#Voir aussi