Elementos
Button
Un botón es un objetivo de pulsación. Es dueño del comportamiento: las pulsaciones, la respuesta visual a la pulsación mediante los estilos de estado de la app, los estados deshabilitado y seleccionado, el foco de teclado y su rol para los lectores de pantalla. El aspecto es de la app: un Button no tiene colores, tamaños, rellenos ni etiqueta propios, de modo que un mismo componente se convierte en un botón primario, un botón de icono, una pestaña, un interruptor de barra de herramientas o la fila de una lista, según los estilos y los hijos que le des.
import { mount, signal, Text, View } from "nsh";
import { Button, Switch } from "nsh/base";
import { button, buttonText, desktop, Inspector, Panel, size, Stage, ui, wide } from "./palette";
const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`;
const thumb = desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5";
function App() {
const presses = signal(0);
const pressing = signal(false);
const disabled = signal(false);
const bold = signal(true);
const italic = signal(false);
const last = signal("–");
const label = () => ({ color: ui().text, fontSize: desktop ? 13 : 15, fontWeight: "600" as const });
const counter = () => (
<Button
onPress={() => {
presses.update((n) => n + 1);
last.set(`counter ${presses()}`);
}}
onPressDown={() => pressing.set(true)}
onPressUp={() => pressing.set(false)}
disabled={disabled()}
{...button("accent")}
>
<Text style={buttonText("accent")}>Pressed {presses()}×</Text>
</Button>
);
const pressState = () => `${pressing() ? "onPressDown: pressing…" : "onPressUp: released"} · disabled ${String(disabled())}`;
const disabler = () => (
<Switch
checked={disabled()}
onChange={({ checked }) => disabled.set(checked)}
class={`flex-row items-center gap-2 active:opacity-70 ${desktop && !wide() ? "self-start" : ""}`}
trackClass={track}
trackStyle={disabled() ? ui().accentFill : ui().inset}
thumbClass={thumb}
thumbStyle={disabled() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={label()}>{wide() ? (disabled() ? "Enable it" : "Disable it") : disabled() ? "Enable the button above" : "Disable the button above"}</Text>
</Switch>
);
const toggle = (name: string, on: () => boolean, flip: () => void, text: () => object, glyph: string) => (
<Button
selected={on()}
onPress={flip}
accessibilityLabel={name}
{...button("secondary", { on, align: "flex-start", style: { width: size.control, paddingHorizontal: 0 } })}
>
<Text style={{ ...text(), color: buttonText("secondary", { on }).color }}>{glyph}</Text>
</Button>
);
const toggles = () => (
<>
{toggle("Bold", bold, () => bold.set(!bold()), () => ({ ...label(), fontWeight: "800" }), "B")}
{toggle("Italic", italic, () => italic.set(!italic()), () => ({ ...label(), fontStyle: "italic" }), "I")}
</>
);
const caption = (text: string) => <Text style={{ fontSize: 12, color: ui().muted }}>{text}</Text>;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>button</Text>
<Text style={{ color: ui().muted }}>{desktop ? "Click, hover, or Tab to a button and press Space" : "Tap; move your finger away to cancel"}</Text>
{() =>
wide() ? (
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage width={360} gap={24}>
<View style={{ gap: 8 }}>
{caption("onPress, onPressDown, onPressUp")}
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
{counter()}
{disabler()}
</View>
</View>
<View style={{ gap: 8 }}>
{caption("selected: toggles")}
<View style={{ flexDirection: "row", gap: 8 }}>{toggles()}</View>
</View>
</Stage>
<Inspector>
<Panel title="press · disabled">
<Text style={{ fontSize: 13, color: ui().text }}>{pressState()}</Text>
</Panel>
<Panel title="selected">
<Text style={{ fontSize: 13, color: ui().text }}>
bold {String(bold())} · italic {String(italic())}
</Text>
</Panel>
<Panel title="last onPress">
<Text style={{ fontSize: 13, color: ui().up }}>{last()}</Text>
</Panel>
</Inspector>
</View>
) : (
<View style={{ gap: 12 }}>
{counter()}
<Text style={{ color: ui().muted, fontSize: 13 }}>{pressState()}</Text>
{disabler()}
<Text style={{ color: ui().muted, marginTop: 6 }}>selected: toggles</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
{toggles()}
<Text style={{ color: ui().muted, alignSelf: "center", flex: 1 }}>
bold {String(bold())} · italic {String(italic())}
</Text>
</View>
<Text style={{ color: ui().up }}>last onPress: {last()}</Text>
</View>
)
}
</View>
);
}
mount(App);use nsh_base::button;
use nsh_core::prelude::*;
fn counter() -> impl IntoElement {
let count = signal(0_u32);
button()
.on_press(move || count.update(|n| *n += 1))
.self_center()
.px_5()
.py_3()
.rounded_xl()
.bg(rgb(0x2563eb))
.active(|s| s.bg(rgb(0x1d4ed8)))
.child(
text(move || format!("Pressed {}", count.get()))
.text_color(white())
.font_weight(FontWeight::SEMIBOLD),
)
}import { mount, signal, Text } from "nsh";
import { Button } from "nsh/base";
function App() {
const count = signal(0);
return (
<Button
onPress={() => count.update((n) => n + 1)}
class="self-center rounded-xl bg-blue-600 px-5 py-3 active:bg-blue-700"
>
<Text class="text-base font-semibold text-white">{`Pressed ${count()}`}</Text>
</Button>
);
}
mount(App);Button proviene de nsh/base, el crate nsh-base en Rust. En TSX su aspecto se define con class y
style, con las variantes de estado active:, hover:, focus:, selected: y disabled: (o las equivalentes
activeStyle, hoverStyle, focusStyle, selectedStyle y disabledStyle). En Rust, button() crea uno igual que
view() crea una vista: un setter por cada prop (.disabled(..), .selected(..)), un listener por cada evento (.on_press(..)),
los métodos de estilo tipados (.px_4(), .bg(..)) para su aspecto, los métodos de estado (.active(..), .hover(..),
.focus(..), .selected_style(..), .disabled_style(..)) para sus estados y .child(..) para su contenido.
#Props
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
disabled |
.disabled(..) |
boolean |
false |
Sin pulsaciones ni respuesta visual a la pulsación; se aplican los estilos disabled: y los lectores de pantalla lo anuncian como atenuado. |
selected |
.selected(..) |
boolean |
false |
Seleccionado: un interruptor activado, la pestaña actual. Los lectores de pantalla lo anuncian. La app es dueña del valor y le da estilo. |
selectedStyle |
.selected_style(|s| ..) |
StyleProp<ViewStyle> |
ninguno | El estilo mientras selected y no está deshabilitado, como clases de selected:. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Lo que dicen los lectores de pantalla cuando el contenido no basta, como en un icono. |
role |
.role(..) |
AccessibilityRole |
"button" |
Qué es para los lectores de pantalla cuando ningún componente nsh/base es ese control: "link" (abre una URL), … |
hitSlop |
.hit_slop(..) |
number | Partial<Edges> |
ninguno | Hasta qué distancia fuera de sus límites las pulsaciones siguen contando, en puntos: un número para todos los lados, o los lados por nombre. Igual que el hitSlop de una vista. |
children |
.child(..) |
JSX.Element |
ninguno | El contenido: texto, un icono, cualquier layout. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | Su aspecto, por completo. |
activeStyle, hoverStyle, focusStyle, disabledStyle |
.active(|s| ..), .hover(..), .focus(..), .disabled_style(..) |
StyleProp<ViewStyle> |
ninguno | El estilo mientras está pulsado, con el cursor encima, con foco o deshabilitado, como las clases de <state>:. Los estados posteriores prevalecen; disabled prevalece sobre todos. |
En Rust cada prop es un setter que recibe un valor, una señal o un closure; las props que omites conservan su valor por defecto:
button()
.disabled(move || saving.get())
.accessibility_label("Save")
#Eventos
| Evento | Rust | Descripción |
|---|---|---|
onPress |
.on_press(|| ..) |
Se pulsó el botón: un toque, un clic, Return o Space mientras tiene el foco de teclado, o la activación de un lector de pantalla. |
onPressDown |
.on_press_down(|| ..) |
Una pulsación comenzó sobre él. |
onPressUp |
.on_press_up(|| ..) |
La pulsación terminó: se soltó, se movió fuera o se canceló. |
onPressDown y onPressUp sirven para una respuesta visual a la pulsación propia, como un muelle o un resaltado que los estilos de estado no expresan. Un botón deshabilitado no informa de ninguno de ellos.
#Estilos
El aspecto de un botón es el de tu app: la app decide qué es un botón primario o secundario. Un par de componentes pequeños mantiene esa decisión en un solo lugar:
use nsh_base::button;
use nsh_core::prelude::*;
/// A filled button, darker while pressed.
fn primary(label: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
button()
.on_press(on_press)
.items_center()
.rounded_lg()
.px_4()
.py_3()
.bg(rgb(0x2563eb))
.active(|s| s.bg(rgb(0x1d4ed8)))
.disabled_style(|s| s.opacity(0.4))
.child(text(label).text_color(white()).font_weight(FontWeight::SEMIBOLD))
}
/// An outlined button.
fn secondary(label: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
button()
.on_press(on_press)
.items_center()
.rounded_lg()
.px_4()
.py_3()
.border_1()
.border_color(rgb(0xd4d4d4))
.hover(|s| s.bg(rgb(0xfafafa)))
.active(|s| s.bg(rgb(0xf5f5f5)))
.child(text(label).text_color(rgb(0x171717)))
}import { Text } from "nsh";
import { Button } from "nsh/base";
function Primary(props: { label: string; onPress: () => void; disabled?: boolean }) {
return (
<Button
onPress={props.onPress}
disabled={props.disabled}
class="items-center rounded-lg bg-blue-600 px-4 py-3 active:bg-blue-700 disabled:opacity-40"
>
<Text class="font-semibold text-white">{props.label}</Text>
</Button>
);
}
function Secondary(props: { label: string; onPress: () => void }) {
return (
<Button
onPress={props.onPress}
class="items-center rounded-lg border border-neutral-300 px-4 py-3 active:bg-neutral-100 hover:bg-neutral-50"
>
<Text class="text-neutral-900">{props.label}</Text>
</Button>
);
}Las variantes de estado se aplican de forma nativa: una pulsación oscurece el botón en el hilo principal sin que se ejecute JS ni Rust. En Rust los métodos de estado son las mismas variantes: .active(..), .hover(..), .focus(..), .selected_style(..) y
.disabled_style(..) siguen los estados propios del botón. Los colores de tu tema funcionan igual: bg-primary en TSX,
theme().primary en Rust (consulta Theme tokens).
#Deshabilitado
Un botón deshabilitado no recibe pulsaciones ni da respuesta visual, y los lectores de pantalla lo anuncian como atenuado. Su aspecto mientras está deshabilitado es cosa tuya, con clases disabled: o disabledStyle:
fn save(saving: Signal<bool>) -> impl IntoElement {
button()
.disabled(saving)
.on_press(move || saving.set(true))
.rounded_lg()
.px_4()
.py_3()
.bg(rgb(0x2563eb))
.disabled_style(|s| s.opacity(0.4))
.child(text("Save").text_color(white()))
}<Button disabled={saving()} onPress={save} class="rounded-lg bg-blue-600 px-4 py-3 disabled:opacity-40">
<Text class="text-white">Save</Text>
</Button>#Un interruptor con selected
selected convierte un botón en un interruptor: el botón de negrita de una barra de herramientas, un chip de filtro, la pestaña actual. La app guarda el valor, lo invierte en onPress y da estilo al estado seleccionado; los lectores de pantalla anuncian el botón como seleccionado.
fn bold(on: Signal<bool>) -> impl IntoElement {
button()
.selected(on)
.accessibility_label("Bold")
.on_press(move || on.update(|on| *on = !*on))
.size(px(40.0))
.items_center()
.justify_center()
.rounded_md()
.selected_style(|s| s.bg(rgb(0xdbeafe)))
.child(text("B").font_weight(FontWeight::BOLD))
}<Button
selected={bold()}
onPress={() => bold.set(!bold())}
accessibilityLabel="Bold"
class="size-10 items-center justify-center rounded-md selected:bg-blue-100"
>
<Text class="font-bold">B</Text>
</Button>Una fila de pestañas es Tabs y un control de activado / desactivado es Switch, de nsh/base: son dueños del rol, del estado seleccionado y del teclado de su tipo, algo que un Button con role="tab" o role="switch" no ofrece.
#Un objetivo pequeño con hitSlop
Un botón de icono puede ser más pequeño que un dedo. hitSlop hace que cuenten las pulsaciones que caen cerca de él, en puntos más allá de cada lado, sin cambiar su layout ni su aspecto: un número para todos los lados, o los lados por nombre ({ top: 8, bottom: 8 }), tal como los recibe el hitSlop de una vista.
fn close(open: Signal<bool>) -> impl IntoElement {
button()
.hit_slop(EdgeInsets::all(px(12.0)))
.accessibility_label("Close")
.on_press(move || open.set(false))
.size(px(20.0))
.items_center()
.justify_center()
.child(text("×"))
}<Button hitSlop={12} accessibilityLabel="Close" onPress={() => open.set(false)} class="size-5 items-center justify-center">
<Text>×</Text>
</Button>#Accesibilidad
- Un botón es un solo elemento para los lectores de pantalla, con el rol
"button"salvo queroleindique otro. Su etiqueta es su contenido de texto; indicaaccessibilityLabelcuando el contenido es un icono o dice muy poco. disabledyselectedse anuncian tal como la plataforma los anuncia (VoiceOver, TalkBack, UI Automation, el árbol de accesibilidad del navegador).- En macOS y la web un botón es una parada del foco de teclado: Tab llega hasta él, Return y Space lo pulsan, y los estilos
focus:muestran dónde está el foco.
Consulta Accessibility para ver etiquetas, roles y anuncios en general.
#Notas de plataforma
- iOS, Android y Mac Catalyst: una pulsación es un toque que termina sobre el botón; alejarse la cancela, igual que con los botones del propio sistema.
- Escritorio (macOS, Windows, Linux, GPUI) y la web: una pulsación es un clic. En macOS y la web, Return y Space también pulsan el botón que tiene el foco de teclado. Los estilos
hover:se aplican con el puntero de un ratón o de un trackpad. - Cada plataforma dibuja lo que la app define con estilos: la respuesta visual a la pulsación es
active:/activeStyleo una animación propia.