Elementos
Toggle
Un toggle es un botón que permanece activado o desactivado: la negrita en una barra de herramientas de texto, una estrella de favoritos, una opción de vista. Toggle se encarga del comportamiento de uno: pulsaciones, activación con el teclado, el estado deshabilitado y el rol de botón con su estado de pulsado para los lectores de pantalla, que lo anuncian como un botón de alternancia. Un ToggleGroup alrededor de varios es una barra de herramientas: los lectores de pantalla la anuncian como tal, y el teclado llega a ella en una sola parada y se mueve entre sus toggles con las teclas de flecha. El aspecto es el de la app: un toggle no tiene colores, tamaños, icono ni etiqueta propios. Sus hijos son el icono o la etiqueta de la app, y las clases aria-pressed: en el toggle, o las clases group-aria-pressed: en lo que contiene, siguen su estado.
import { Canvas, mount, signal, Text, View } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Split, tint, ui } from "./palette";
type Mark = "bold" | "italic" | "underline";
type Align = "left" | "center" | "right";
const marks: { id: Mark; label: string; glyph: string }[] = [
{ id: "bold", label: "Bold", glyph: "B" },
{ id: "italic", label: "Italic", glyph: "I" },
{ id: "underline", label: "Underline", glyph: "U" },
];
const aligns: { id: Align; label: string }[] = [
{ id: "left", label: "Align left" },
{ id: "center", label: "Align center" },
{ id: "right", label: "Align right" },
];
/** Three lines of a paragraph, set as `align` sets them. */
function Lines(props: { align: Align; color: string }) {
return (
<Canvas
style={{ pointerEvents: "none", width: 16, height: 16 }}
draw={(cx) => {
cx.strokeStyle = props.color;
cx.lineWidth = 1.6;
cx.lineCap = "round";
[12, 7, 10].forEach((w, i) => {
const x = props.align === "center" ? 8 - w / 2 : props.align === "right" ? 14 - w : 2;
cx.beginPath();
cx.moveTo(x, 4 + i * 4);
cx.lineTo(x + w, 4 + i * 4);
cx.stroke();
});
}}
/>
);
}
function App() {
const on = signal<Mark[]>(["bold"]);
const align = signal<Align>("left");
const starred = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 3));
const has = (m: Mark) => on().includes(m);
// A toolbar toggle: bare while off, raised while pressed (`pressedStyle`).
const look = (pressed: boolean) => ({
style: { width: desktop ? 28 : 34, height: desktop ? 28 : 34, borderRadius: 7, alignItems: "center", justifyContent: "center" } as const,
class: "transition-colors active:opacity-70 motion-reduce:transition-none",
pressedStyle: ui().raised,
hoverStyle: pressed ? undefined : hoverFill(),
focusStyle: focusRing(),
});
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>toggle</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click a toggle, or Tab to the toolbar and move with the arrow keys" : "Tap a toggle to turn it on or off"}
</Text>
<Split gap={14} inspector={<Log title={`onChange · ${on().join(", ") || "none"}, ${align()}`} lines={events()} minHeight={76} />}>
{/* One toolbar: the marks any number at once, the alignment exactly one. */}
<ToggleGroup style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 2, padding: 3, borderRadius: 10, ...ui().inset }}>
<Each of={marks}>
{(m) => (
<Toggle
pressed={has(m().id)}
accessibilityLabel={m().label}
onChange={({ pressed }) => {
on.set(pressed ? [...on(), m().id] : on().filter((x) => x !== m().id));
log(`${m().label}: ${pressed}`);
}}
{...look(has(m().id))}
>
<Text
style={{
fontSize: 14,
color: has(m().id) ? ui().text : ui().muted,
fontWeight: m().id === "bold" ? "800" : "500",
fontStyle: m().id === "italic" ? "italic" : "normal",
textDecorationLine: m().id === "underline" ? "underline" : "none",
}}
>
{m().glyph}
</Text>
</Toggle>
)}
</Each>
<View style={{ width: 1, height: 16, marginHorizontal: 4, backgroundColor: ui().line }} />
<Each of={aligns}>
{(a) => (
<Toggle
pressed={align() === a().id}
accessibilityLabel={a().label}
onChange={({ pressed }) => {
// Exactly one: the pressed alignment stays pressed.
if (pressed) align.set(a().id);
log(`${a().label}: ${pressed}`);
}}
{...look(align() === a().id)}
>
<Lines align={a().id} color={align() === a().id ? ui().text : ui().muted} />
</Toggle>
)}
</Each>
</ToggleGroup>
{/* The text the toolbar formats: the app reads its own values. */}
<View style={{ ...ui().card, borderRadius: 14, padding: 14 }}>
<Text
style={{
fontSize: 15,
lineHeight: 22,
color: ui().text,
fontWeight: has("bold") ? "700" : "400",
fontStyle: has("italic") ? "italic" : "normal",
textDecorationLine: has("underline") ? "underline" : "none",
textAlign: align(),
}}
>
Every toggle reports the value it asks for; the app passes it back.
</Text>
</View>
{/* A toggle on its own, with a label: `aria-pressed:` tints it. */}
<Toggle
pressed={starred()}
onChange={({ pressed }) => {
starred.set(pressed);
log(`Star: ${pressed}`);
}}
class="transition-colors active:opacity-70 motion-reduce:transition-none"
style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 6, height: desktop ? 28 : 34, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: ui().line }}
pressedStyle={{ backgroundColor: tint(ui().accent, 0.16), borderColor: tint(ui().accent, 0.4) }}
hoverStyle={starred() ? undefined : hoverFill()}
focusStyle={focusRing()}
>
<Text style={{ fontSize: 14, color: starred() ? ui().accent : ui().muted }}>{starred() ? "★" : "☆"}</Text>
<Text style={{ fontSize: 13, fontWeight: "500", color: starred() ? ui().accent : ui().text }}>{starred() ? "Starred" : "Star"}</Text>
</Toggle>
</Split>
</View>
);
}
mount(App);use nsh_base::{theme, toggle, toggle_group};
use nsh_core::prelude::*;
fn formatting() -> impl IntoElement {
let bold = signal(true);
let italic = signal(false);
let item = move |label: &'static str, on: Signal<bool>| {
toggle()
.pressed(on)
.accessibility_label(label)
.on_change(move |pressed: &bool| on.set(*pressed))
.size_8()
.items_center()
.justify_center()
.rounded(move || theme().radius)
.pressed_style(|s| s.bg(move || theme().accent))
.child(text(&label[..1]).text_sm().text_color(move || {
if on.get() { theme().accent_foreground } else { theme().muted_foreground }
}))
};
toggle_group()
.flex_row()
.self_start()
.gap_1()
.child(item("Bold", bold))
.child(item("Italic", italic))
}import { mount, signal, Text } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
function App() {
const marks = signal<string[]>(["bold"]);
const item = (value: string, label: string) => (
<Toggle
pressed={marks().includes(value)}
accessibilityLabel={label}
onChange={({ pressed }) =>
marks.set(pressed ? [...marks(), value] : marks().filter((m) => m !== value))
}
class="group size-8 items-center justify-center rounded-(--radius) aria-pressed:bg-accent"
>
<Text class="text-sm text-muted-foreground group-aria-pressed:text-accent-foreground">{label[0]}</Text>
</Toggle>
);
return (
<ToggleGroup class="flex-row self-start gap-1">
{item("bold", "Bold")}
{item("italic", "Italic")}
</ToggleGroup>
);
}
mount(App);Toggle y ToggleGroup provienen de nsh/base, el crate nsh-base en Rust. El valor pertenece a la app: a cada toggle se le dice si está pulsado, una pulsación informa del valor que solicita con onChange, y el toggle muestra el pressed que la app le devuelve. El grupo tampoco guarda ningún valor, así que cuántos toggles pueden estar pulsados también depende de la app: cualquier cantidad (negrita, cursiva y subrayado), o exactamente uno (una alineación, un selector de vistas), donde la app pulsa el toggle solicitado, suelta los demás y mantiene pulsado el que ya lo estaba. En Rust, toggle() y toggle_group() los crean, con un setter por prop (.pressed(..)), un listener por evento (.on_change(..)), los métodos de estilo tipados y .child(..) para su contenido.
#Props
#Toggle
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
pressed |
.pressed(..) |
boolean |
false |
Activado o desactivado: los lectores de pantalla indican que está pulsado, y se aplican los estilos aria-pressed:. Una pulsación informa del otro valor con onChange; el toggle muestra el valor que la app le devuelve. |
disabled |
.disabled(..) |
boolean |
false |
Sin pulsaciones ni respuesta visual al pulsar; se aplican los estilos disabled:, y los lectores de pantalla indican que está atenuado. Un toggle dentro de un ToggleGroup deshabilitado también queda deshabilitado. |
pressedStyle |
.pressed_style(|s| ..) |
StyleProp<ViewStyle> |
ninguno | El estilo del toggle mientras está pulsado y no deshabilitado, como clases aria-pressed:. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Lo que dicen los lectores de pantalla: lo que activa el toggle, cuando sus hijos no tienen texto que lo diga (un icono). |
hitSlop |
.hit_slop(..) |
number | Partial<Edges> |
ninguno | Hasta qué distancia fuera de sus límites siguen contando las pulsaciones, en puntos: un número para todos los lados, o los lados por nombre. Como el hitSlop de una vista. |
children |
.child(..) |
JSX.Element |
ninguno | El icono o la etiqueta de la app, que los lectores de pantalla leen. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El propio toggle: la superficie que rodea su contenido. |
activeStyle, hoverStyle, focusStyle, disabledStyle |
.active(|s| ..), .hover(..), .focus(..), .disabled_style(..) |
StyleProp<ViewStyle> |
ninguno | El estilo del toggle mientras hay una pulsación en curso, con el cursor encima, con el foco o deshabilitado, como las clases <state>:. |
#ToggleGroup
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
disabled |
.disabled(..) |
boolean |
false |
Todos los toggles del grupo quedan deshabilitados, sea cual sea su propio disabled. |
children |
.child(..) |
JSX.Element |
ninguno | Los toggles del grupo, y lo que la app disponga entre ellos (un separador). |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El diseño y el aspecto del grupo: una columna por defecto, flex-row para una fila. |
#Eventos
| Evento | Rust | Descripción |
|---|---|---|
onChange (Toggle) |
.on_change(|pressed: &bool| ..) |
Se pulsó el toggle: un toque, un clic, Espacio o Return mientras tiene el foco del teclado, o la activación de un lector de pantalla. { pressed } es el valor que solicita, el opuesto de pressed. Se ejecuta antes de que cambie nada: el toggle muestra el pressed que la app le devuelve. |
Una tecla de flecha de un ToggleGroup solo mueve el foco del teclado: no pulsa nada. Un toggle deshabilitado no informa de nada.
#Partes
| Parte | Dentro de | Estilizado con | Estados |
|---|---|---|---|
| El grupo | ToggleGroup: class, style |
||
| El toggle | el grupo, o cualquier lugar | Toggle: class, style |
aria-pressed:, disabled:, active:, hover:, focus: (o pressedStyle, disabledStyle, …) |
| El icono o la etiqueta | el toggle | sus propios elementos (children) |
group-aria-pressed:, group-disabled: y las demás variantes group-*: con group en el class del toggle |
aria-pressed: es el toggle activado; active: es una pulsación en curso, esté activado o desactivado. Muestra un toggle pulsado mediante su propia superficie: un relleno, una píldora elevada, un icono más marcado (consulta Guías de diseño).
#Segmentado
Un grupo cuyos toggles están unidos, con uno pulsado a la vez, es un control segmentado (un selector de vistas): la app mantiene un valor y pulsa el toggle que lo nombra. El aspecto unido son las clases de la app:
<ToggleGroup class="flex-row self-start">
<Each of={["grid", "list"]}>
{(value) => (
<Toggle
pressed={view() === value()}
accessibilityLabel={`${value()} view`}
onChange={({ pressed }) => pressed && view.set(value())}
class="-ml-px h-8 px-3 border border-border first:ml-0 first:rounded-l-(--radius) last:rounded-r-(--radius) aria-pressed:bg-accent"
>
<Text class="text-sm">{value()}</Text>
</Toggle>
)}
</Each>
</ToggleGroup>
#Deshabilitado
Un toggle deshabilitado no admite pulsaciones, y los lectores de pantalla lo anuncian como atenuado. Sus clases disabled: se aplican sobre las aria-pressed:, y las clases group-disabled: de su interior las siguen. disabled en un ToggleGroup deshabilita todos los toggles que contiene, una barra de herramientas que la app bloquea:
<ToggleGroup disabled={readonly()} class="flex-row gap-1">
<Toggle pressed={bold()} onChange={({ pressed }) => bold.set(pressed)} class="size-8 disabled:opacity-50">
<Text>B</Text>
</Toggle>
</ToggleGroup>
#Un objetivo pequeño con hitSlop
Un toggle 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 diseño ni su aspecto: un número para todos los lados, o los lados por nombre ({ top: 8, bottom: 8 }), tal como los acepta el hitSlop de una vista.
<Toggle
pressed={favorite()}
hitSlop={12}
accessibilityLabel="Favorite"
onChange={({ pressed }) => favorite.set(pressed)}
class="size-5 items-center justify-center"
>
<Text>★</Text>
</Toggle>
#Accesibilidad
- Un toggle es un único elemento para los lectores de pantalla, con el rol
"button"y su estado de pulsado: un botón de alternancia. Su etiqueta es el texto de sus hijos, oaccessibilityLabel. ToggleGrouptiene el rol"toolbar": los lectores de pantalla oyen los toggles que contiene como una sola barra de herramientas, cada uno aún como un elemento propio.disabledse anuncia como lo anuncia la plataforma (VoiceOver, TalkBack, UI Automation, el árbol de accesibilidad del navegador).- En macOS y la web el grupo es una sola parada del foco del teclado, y las teclas de flecha se mueven entre sus toggles (más abajo).
Consulta Accesibilidad para etiquetas, roles y anuncios en general.
#Teclado
En macOS y la web, donde Tab llega a las vistas que admiten pulsaciones, un ToggleGroup sigue el patrón de barra de herramientas de WAI-ARIA:
| Tecla | Qué hace |
|---|---|
| Tab, Shift+Tab | Entra en el grupo por el primer toggle pulsado (el primer toggle no deshabilitado si no hay ninguno pulsado), y sale de él en un solo paso. |
| Derecha, Abajo | Mueve el foco al siguiente toggle, del último al primero. |
| Izquierda, Arriba | Mueve el foco al toggle anterior, del primero al último. |
| Inicio, Fin | Mueve el foco al primer o al último toggle. |
| Espacio, Return | Pulsa el toggle con el foco. |
Las teclas de flecha omiten los toggles deshabilitados y no pulsan nada: un toggle solo cambia cuando se pulsa. Los dos pares de flechas mueven el foco, sea cual sea el diseño del grupo, que es una clase de la app. En un diseño de derecha a izquierda, Izquierda y Derecha se intercambian: Izquierda pasa al siguiente toggle y Derecha al anterior; Arriba, Abajo, Inicio y Fin mantienen las suyas. Los estilos focus: muestran dónde está el foco. Un Toggle fuera de un ToggleGroup es una parada propia.
#Notas de plataforma
- iOS, Android y Mac Catalyst: una pulsación es un toque que termina en el toggle, incluido su contenido.
- Escritorio (macOS, Windows, Linux, GPUI) y la web: una pulsación es un clic. En macOS y la web, Espacio y Return también pulsan el toggle que tiene el foco del teclado, y las flechas, Inicio y Fin lo mueven (Teclado).
- Cada plataforma dibuja lo que la app estiliza: los toggles, la superficie de los pulsados y la pista del grupo.