TextInput
Una línea de texto en la que el jugador escribe. Es el único nodo con cursor — un punto de inserción dentro de un contenido que se está escribiendo.
primitivedesde v1focusable
Un TextInput es una línea en la que el jugador escribe. Usas uno siempre que el
juego necesita palabras que no podría haber ofrecido como opciones: el nombre de
un personaje, un buscador, un código para canjear. Piensa en una pantalla de
perfil: el campo Name escribe cada tecla directamente en los datos del propio
juego a través de su binding, y Enter es una señal aparte
que significa acéptalo. En la v1 es de una sola línea, y no crece con lo que se
escribe dentro.

- hover — inactivo
- pressed — inactivo
- focused — inactivo
- selected — inactivo
- disabled — inactivo
Se lee del último frame dibujado
Parado: pulsa Ejecutar para dibujarlo en la GPU.
import { Column, Row, Text, TextInput } from "@zabloo/react";
const FIELD = {
background: "{color.slot}",
radius: "{radius.md}",
borderWidth: "{border.hairline}",
borderColor: "{color.line-strong}",
color: "{color.text}",
fontSize: "{text.sm}",
} as const;
/** The focus ring lives in `states`, like every other visual answer to input. */
const FIELD_STATES = {
focused: { style: { borderWidth: "{border.focus}", borderColor: "{color.brand}" } },
empty: { style: { color: "{color.faint}" } },
} as const;
export default function TextInputField() {
return (
<Column
layout={{ grow: 1, justify: "center", align: "center", padding: "{space.6}" }}
style={{ background: "{color.bg}" }}
>
<Column
id="panel"
layout={{ width: 400, padding: "{space.5}", gap: "{space.4}", align: "stretch" }}
style={{
background: "{color.surface}",
radius: "{radius.lg}",
borderWidth: "{border.hairline}",
borderColor: "{color.line}",
}}
>
<Column layout={{ gap: "{space.2}", align: "stretch" }}>
<Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Character name</Text>
{/* maxLength bounds what the PLAYER can type; a longer string pushed
by the game is still shown whole. */}
<TextInput
id="name"
value={{ bind: "profile.name" }}
placeholder="Your name"
maxLength={16}
onSubmit="name-accept"
width={360}
padding={10}
style={FIELD}
states={FIELD_STATES}
/>
<Row layout={{ gap: "{space.1}", align: "center" }}>
<Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Greeting:</Text>
{/* The same path, read. No callback, no game code. */}
<Text bind="profile.name" style={{ color: "{color.muted}", fontSize: "{text.xs}" }} />
</Row>
</Column>
<Column layout={{ gap: "{space.2}", align: "stretch" }}>
<Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Search</Text>
{/* Empty, so `states.empty` is what paints the placeholder — and
`onChange` is the live hook a filter-as-you-type hangs off. */}
<TextInput
id="search"
value={{ bind: "search.query" }}
placeholder="Filter items"
onChange="search-typed"
onSubmit="search-run"
width={360}
padding={10}
style={FIELD}
states={FIELD_STATES}
/>
</Column>
</Column>
</Column>
);
}{
"v": 1,
"tokens": {
"color.bg": "#0b0d13",
"color.surface": "#0e1016",
"color.raised": "#14141a",
"color.line": "#ffffff1f",
"color.line-strong": "#ffffff24",
"color.text": "#ffffff",
"color.muted": "#a1a1aa",
"color.faint": "#8a8a93",
"color.on-brand": "#ffffff",
"color.brand": "#8b5cf6",
"color.brand-strong": "#7c3aed",
"color.brand-hover": "#8b5cf6",
"color.brand-pressed": "#6d28d9",
"color.brand-soft": "#8b5cf61f",
"color.gold": "#fcd34d",
"color.slot": "#ffffff08",
"color.danger": "#f87171",
"radius.sm": 6,
"radius.md": 10,
"radius.lg": 14,
"radius.pill": 999,
"border.hairline": 1,
"border.focus": 2,
"space.1": 4,
"space.2": 8,
"space.3": 12,
"space.4": 16,
"space.5": 20,
"space.6": 24,
"text.xs": 11,
"text.sm": 13,
"text.md": 15,
"text.lg": 17,
"motion.fast": 120,
"motion.loop": 900
},
"views": {
"textinput-field": {
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"justify": "center",
"align": "center",
"padding": "{space.6}"
},
"style": {
"background": "{color.bg}"
},
"children": [
{
"type": "Container",
"id": "panel",
"layout": {
"direction": "column",
"width": 400,
"padding": "{space.5}",
"gap": "{space.4}",
"align": "stretch"
},
"style": {
"background": "{color.surface}",
"radius": "{radius.lg}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "column",
"gap": "{space.2}",
"align": "stretch"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Character name"
},
{
"type": "TextInput",
"id": "name",
"layout": {
"width": 360,
"padding": 10
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}",
"color": "{color.text}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"fontSize": "{text.sm}"
},
"states": {
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"empty": {
"style": {
"color": "{color.faint}"
}
}
},
"value": {
"bind": "profile.name"
},
"placeholder": "Your name",
"onSubmit": "name-accept",
"maxLength": 16
},
{
"type": "Container",
"layout": {
"direction": "row",
"gap": "{space.1}",
"align": "center"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Greeting:"
},
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": {
"bind": "profile.name"
}
}
]
}
]
},
{
"type": "Container",
"layout": {
"direction": "column",
"gap": "{space.2}",
"align": "stretch"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Search"
},
{
"type": "TextInput",
"id": "search",
"layout": {
"width": 360,
"padding": 10
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}",
"color": "{color.text}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line-strong}",
"fontSize": "{text.sm}"
},
"states": {
"focused": {
"style": {
"borderWidth": "{border.focus}",
"borderColor": "{color.brand}"
}
},
"empty": {
"style": {
"color": "{color.faint}"
}
}
},
"value": {
"bind": "search.query"
},
"placeholder": "Filter items",
"onChange": "search-typed",
"onSubmit": "search-run"
}
]
}
]
}
]
}
}
}Todos los controles anteriores a este producían un valor señalando a una
geometría: un booleano, un número, un índice. Este tiene una posición dentro
de sí mismo, y eso es lo que lo convierte en un tipo en vez de en un Text
con un flag.
Las dos tablas de abajo se diferencian en un par de filas nada más, y las dos
son comodidades: width y padding son números que escribes tú y que se
resuelven en el layout normal del nodo antes de publicar nada. Lo que llega al
juego es un campo, su texto y los dos nombres que puede disparar.
Props de autoría
Lo que escribes en @zabloo/react.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| value | Bindable<string> | "" | Texto actual, o un binding de lectura y escritura. |
| placeholder | string | absent | Pista que se ve mientras el campo está vacío. |
| onChange / onSubmit | string | absent | El hook en vivo y el de confirmar. |
| maxLength | number | unbounded | Tope de lo que el jugador puede escribir. |
| width | number | 220 | Ancho del campo a lo largo de su línea, en px. |
| padding | number | 8 | Espacio entre la caja y el texto, en px. |
Además de estas, todo componente acepta las props base del nodo —id,
visible, disabled, layout, style, states, transition, autofocus,
clip— más variant, que el theme resuelve al exportar y que nunca aparece en
la IR. Un campo no cambia de tamaño con lo que se escribe dentro, así que
necesita un ancho propio; un layout explícito le sigue ganando, y grow: 1
rellena una fila.
Props de IR
Lo que llega al juego, cuando ya no queda capa de autoría.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| value | Bindable<string> | "" | Texto actual, o un binding de lectura y escritura. |
| placeholder | string | absent | Se ve mientras el valor está vacío. |
| onChange | string | absent | Named action que se dispara tras cada edición. |
| onSubmit | string | absent | Named action que se dispara cuando el jugador confirma (Enter). |
| maxLength | number | unbounded | Tope de lo que el jugador puede escribir. |
Es una hoja con contenido, como Text e Image: no acepta hijos y pinta su
propio valor por el camino de texto de siempre.
El valor
value es o una cadena inicial literal o un binding de lectura y escritura:
el SDK escribe cada cambio en su almacén de datos y avisa al juego. Un <Text bind> sobre el mismo path sigue lo que se escribe sin código del juego por
medio — la figura de arriba es exactamente eso.
maxLength acota lo que se teclea, no los datos. Una cadena más larga que
el juego empuje por un binding se enseña entera: truncar en silencio los datos
del propio juego sería mentir sobre lo que tiene.
Dos hooks, y uno que no existe
onChangese dispara en cada edición: el hook en vivo, simétrico delSlider.onChange. De aquí cuelga una búsqueda que filtra mientras escribes.onSubmitse dispara cuando el jugador confirma el campo: la búsqueda que se lanza, el nombre que se acepta.
A diferencia de un arrastre, un campo de texto ya tiene un gesto explícito de confirmación —Enter—, así que el par en vivo/asentado existe; lo único que cambia es el nombre del segundo hook, y cambia porque el gesto es otro. El submit es algo que el jugador hace, mientras que el commit de un slider es algo que deja de hacer. El commit al perder el focus no sobreviviría a un juego de todas formas: con navegación por flechas y por mando el focus sale del campo cada vez que el jugador lo cruza, así que un commit al salir dispararía valores “asentados” que no lo son — y un campo enlazado ya ha escrito cada edición en los datos, así que al salir no se pierde nada.
Una sola línea en la v1
El nodo se mide como una línea de texto y hace scroll horizontal de su contenido para mantener el cursor a la vista. Nunca se inserta un salto de línea; uno pegado se convierte en un espacio. El campo multilínea es una extensión compatible sobre el algoritmo de wrap —un cursor con fila además de columna, y una selección que cruza líneas— y queda aplazado.
El placeholder
El placeholder se pinta con el estilo de texto del propio campo mientras el
valor está vacío, y quien lo estiliza es el estado empty:
"states": { "empty": { "style": { "color": "{color.muted}" } } }Ni un segundo campo de color ni un slot: el nodo ya es dueño del pintado del
texto, así que un placeholder es ese mismo pintado con otra cadena. empty abre
el orden de mezcla — es lo más débil que un control dice sobre su valor, así que
cualquier cosa declarada para un campo con focus o seleccionado le gana.
Comportamiento
- Estados
empty, máshoveryfocused— ydisabled, propio o heredado. No haypressed: una pulsación sobre un campo coloca el cursor, no activa nada, así que no hay estado de “pulsado” que vestir. Un campo deshabilitado no acepta cursor ni teclas, y sigue enseñando lo que tiene.- Focusable
Sí, salvo que esté
disabled. Se queda las flechas para mover su cursor pero las devuelve en los extremos: al final del texto, una pulsación más sale del campo. A propósito distinto delSlider, que nunca suelta las flechas de su eje — salir andando de una cadena larga tecla a tecla no es un precio razonable.- Pintado
El cursor y el resaltado de la selección los pinta el SDK, los dos a partir del
style.colordel propio campo — el mismo “color del contenido de este nodo” que tiñe glifos e imágenes. Su parpadeo y su estilo son comportamiento, no IR: el mismo reparto que mantiene la barra de scroll delScrollViewfuera del formato.- Actions
El jugador escribe un carácter → el SDK escribe la cadena nueva en los datos → se dispara
onChange. El jugador pulsa Enter → el texto no cambia → se disparaonSubmitpor su cuenta. En el otro sentido,SetText(id, text)sustituye el buffer y deja el cursor al final, que es por donde empezaría a escribir alguien a quien le acaban de dar un valor ya puesto.
Degradación
En un SDK más antiguo se ve la caja del campo a su tamaño exacto y nada más: ni texto dentro, ni forma de escribir nada.
Como un Container vacío: es una hoja, así que lo que sobrevive es la caja — su fondo, su borde, su tamaño. El layout no se mueve, porque el tamaño de un campo nunca fue función de su contenido.
Cómo oye esto el juego
El binding es el texto; la action es el momento en que el jugador lo ha
confirmado. Aquí es donde más fácil resulta distinguir los dos canales. Cada
edición se escribe en el almacén de datos según ocurre, así que la copia del
juego nunca está vieja; la action no lleva texto ninguno, solo la noticia de que
se ha pulsado Enter. Un campo de nombre que solo importa una vez aceptado se
suscribe únicamente a onSubmit y no mira las teclas.
using UnityEngine;
using Zabloo;
[RequireComponent(typeof(ZablooDocument))]
public sealed class ProfileScreen : MonoBehaviour
{
ZablooDocument _doc;
void Start()
{
_doc = GetComponent<ZablooDocument>();
_doc.OnAction += OnZablooAction;
// The field starts on what the game holds — the same path it writes back.
_doc.SetData("profile.name", SaveGame.PlayerName);
}
void OnDestroy()
{
if (_doc != null) _doc.OnAction -= OnZablooAction;
}
void OnZablooAction(string action)
{
// Enter, not every keystroke: this is the gesture that means "accept it".
if (action == "name-accept") CommitPlayerName();
}
}Composición
Un campo más un Text enlazado al mismo path es el patrón entero de “vista
previa en vivo”, y no necesita código.
- El campo canónico — enlazado, con tope y confirmado con Enter; usa esta forma para todo lo que el jugador nombre.
- Un campo con un
Textsobre el mismo path — cuando el valor tenga que verse en otro sitio de la pantalla según se escribe. - Un campo con
grow: 1dentro de unRow— para una barra de búsqueda, donde el campo debe quedarse el ancho que le deje el botón de al lado.
// The canonical field: bound, capped, confirmed with Enter.
<TextInput
value={{ bind: "profile.name" }}
placeholder="Your name"
maxLength={16}
onSubmit="name-accept"
states={{ empty: { style: { color: "{color.muted}" } } }}
/>
// A live preview of what is being typed. No callback in between.
<Text bind="profile.name" />
// A search bar: the field takes the leftover space, the button keeps its size.
<Row layout={{ gap: 8, align: "center" }}>
<TextInput value={{ bind: "search.query" }} onChange="search-typed" layout={{ grow: 1 }} />
<Button onClick="search-run"><Text>Search</Text></Button>
</Row>