Elementos
Radio
Los radios ofrecen un conjunto de opciones de las que el usuario elige exactamente una: un plan, un orden, un tema. Cada Radio se encarga del comportamiento de una opción: pulsaciones, activación por teclado, el estado deshabilitado, y el rol de radio y su estado marcado para los lectores de pantalla. Un RadioGroup alrededor de ellos es el conjunto, que los lectores de pantalla anuncian como un solo grupo. El aspecto es de la app: un radio no tiene círculo, punto, colores, tamaños ni etiqueta propios. Sus hijos son el indicador y la etiqueta de la app, y las clases checked: del radio, o las clases group-checked: de lo que hay dentro, siguen su estado.
import { mount, signal, Text, View } from "nsh";
import { Radio, RadioGroup } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, size, Split, ui } from "./palette";
const plans = [
{ id: "starter", label: "Starter", detail: "1 project · 5 GB", price: "Free" },
{ id: "pro", label: "Pro", detail: "Unlimited projects · 100 GB", price: "$12" },
{ id: "team", label: "Team", detail: "Shared workspace · 1 TB", price: "$40", locked: true },
];
const cycles = ["Monthly", "Yearly"];
function App() {
const plan = signal("pro");
const cycle = signal("Monthly");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 3));
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>radio</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click an option, or Tab to a group and move with the arrow keys" : "Tap an option: its label checks it too"}
</Text>
<Split gap={14} inspector={<Log title={`onChange({ checked }) · ${plan()}, ${cycle()}`} lines={events()} minHeight={76} />}>
{/* A segmented choice: each radio is a segment, `checkedStyle` raises the checked one. */}
<RadioGroup style={{ flexDirection: "row", gap: 4, padding: 3, borderRadius: 10, ...ui().inset, ...(desktop ? { alignSelf: "flex-start" } : {}) }}>
<Each of={cycles}>
{(c) => (
<Radio
checked={cycle() === c()}
onChange={({ checked }) => {
cycle.set(c());
log(`${c()} → ${checked}`);
}}
style={
desktop
? { height: 26, paddingHorizontal: 14, borderRadius: 7, alignItems: "center", justifyContent: "center" }
: { flex: 1, height: 30, borderRadius: 7, alignItems: "center", justifyContent: "center" }
}
class="active:opacity-70"
hoverStyle={cycle() === c() ? undefined : hoverFill()}
focusStyle={focusRing()}
checkedStyle={ui().raised}
>
<Text style={{ fontSize: 13, fontWeight: "500", color: cycle() === c() ? ui().text : ui().muted }}>{c()}</Text>
</Radio>
)}
</Each>
</RadioGroup>
{/* A list of options: a ring with a dot, which `group-checked:` grows in (in place with reduced motion). */}
<RadioGroup style={{ ...ui().card, borderRadius: 14, overflow: "hidden" }}>
<Each of={plans}>
{(item, i) => (
<>
{i() > 0 ? <View style={{ height: 1, marginLeft: desktop ? 40 : 48, backgroundColor: ui().line }} /> : null}
<Radio
checked={plan() === item().id}
disabled={item().locked}
onChange={({ checked }) => {
plan.set(item().id);
log(`${item().label} → ${checked}`);
}}
class={`group flex-row items-center gap-3 ${desktop ? "px-3 py-2" : "px-4 py-3"} active:opacity-70 disabled:opacity-40`}
hoverStyle={item().locked ? undefined : hoverFill()}
focusStyle={focusRing()}
>
<View
class="transition-colors motion-reduce:transition-none"
style={{
width: desktop ? 16 : 20,
height: desktop ? 16 : 20,
borderRadius: desktop ? 8 : 10,
borderWidth: 2,
borderColor: plan() === item().id ? ui().accent : ui().faint,
alignItems: "center",
justifyContent: "center",
}}
>
<View
class="opacity-0 scale-50 transition duration-150 ease-out motion-reduce:transition-none group-checked:opacity-100 group-checked:scale-100"
style={{ width: desktop ? 8 : 10, height: desktop ? 8 : 10, borderRadius: desktop ? 4 : 5, backgroundColor: ui().accent }}
/>
</View>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{item().label}</Text>
<Text style={{ fontSize: size.detail, color: ui().muted }}>{item().detail}</Text>
</View>
<Text style={{ fontSize: 14, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().text }}>{item().price}</Text>
</Radio>
</>
)}
</Each>
</RadioGroup>
</Split>
</View>
);
}
mount(App);use nsh_base::{radio, radio_group, theme};
use nsh_core::prelude::*;
fn plans() -> impl IntoElement {
let plan = signal("basic");
let option = move |value: &'static str, label: &'static str| {
let checked = move || plan.get() == value;
radio()
.checked(checked)
.on_change(move |_: &bool| plan.set(value))
.flex_row()
.items_center()
.gap_2()
.child(
view()
.size(px(20.0))
.items_center()
.justify_center()
.rounded_full()
.border_2()
.border_color(move || theme().border)
.child(
view()
.size(px(10.0))
.rounded_full()
.bg(move || theme().primary)
.opacity(move || if checked() { 1.0_f32 } else { 0.0 }),
),
)
.child(text(label))
};
radio_group()
.gap_3()
.child(option("basic", "Basic"))
.child(option("pro", "Pro"))
}import { mount, signal, Text, View } from "nsh";
import { Radio, RadioGroup } from "nsh/base";
function App() {
const plan = signal("basic");
const option = (value: string, label: string) => (
<Radio
checked={plan() === value}
onChange={() => plan.set(value)}
class="group flex-row items-center gap-2"
>
<View class="size-5 items-center justify-center rounded-full border-2 border-border group-checked:border-primary">
<View class="size-2.5 rounded-full group-checked:bg-primary" />
</View>
<Text>{label}</Text>
</Radio>
);
return (
<RadioGroup class="gap-3">
{option("basic", "Basic")}
{option("pro", "Pro")}
</RadioGroup>
);
}
mount(App);Radio y RadioGroup vienen de nsh/base, el crate nsh-base en Rust. El valor es de la app: el grupo no guarda ninguno. A cada radio se le indica si está marcado; una pulsación sobre un radio sin marcar informa onChange({ checked: true }), la app establece su valor, y cada radio muestra el checked que la app devuelve. En Rust, radio() y radio_group() los crean, con un setter por prop (.checked(..)), un listener por evento (.on_change(..)), los métodos de estilo tipados y .child(..) para su contenido.
#Props
#Radio
| Prop | Rust | Tipo | Predeterminado | Descripción |
|---|---|---|---|---|
checked |
.checked(..) |
boolean |
false |
Marcado o no. Una pulsación sobre un radio sin marcar informa true con onChange; el radio muestra el valor que la app devuelve. |
disabled |
.disabled(..) |
boolean |
false |
Sin pulsaciones ni respuesta a la pulsación; se aplican los estilos disabled:, y los lectores de pantalla indican que está atenuado. |
checkedStyle |
.checked_style(|s| ..) |
StyleProp<ViewStyle> |
ninguno | El estilo del radio mientras está marcado y no deshabilitado, como clases checked:. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Lo que dicen los lectores de pantalla: la opción, cuando sus hijos no tienen texto que la diga. |
hitSlop |
.hit_slop(..) |
number | Partial<Edges> |
ninguno | Cuánto fuera de sus límites cuentan aún 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 indicador y la etiqueta de la app, que los lectores de pantalla leen. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El radio en sí: la fila que rodea al indicador y a la etiqueta. |
activeStyle, hoverStyle, focusStyle, disabledStyle |
.active(|s| ..), .hover(..), .focus(..), .disabled_style(..) |
StyleProp<ViewStyle> |
ninguno | El estilo del radio mientras está pulsado, con el cursor encima, con el foco o deshabilitado, como clases <state>:. |
#RadioGroup
| Prop | Rust | Tipo | Predeterminado | Descripción |
|---|---|---|---|---|
children |
.child(..) |
JSX.Element |
ninguno | Los radios del conjunto, y lo que la app disponga entre ellos. |
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 (Radio) |
.on_change(|checked: &bool| ..) |
Se pulsó un radio sin marcar: un toque, un clic, Espacio o Retorno mientras tiene el foco del teclado, o la activación de un lector de pantalla; o una tecla de flecha de su grupo movió el foco del teclado hasta él. Pide ser marcado, así que checked es true. Se ejecuta antes de que cambie nada: el radio muestra el checked que la app devuelve. |
Un radio marcado sigue marcado y no informa nada, ya que un radio no puede desmarcarse a sí mismo; un radio deshabilitado tampoco informa nada.
#Partes
| Parte | Dentro de | Estilizado con | Estados |
|---|---|---|---|
| El grupo | class, style de RadioGroup |
||
| El radio | el grupo | class, style de Radio |
checked:, disabled:, active:, hover:, focus: (o checkedStyle, disabledStyle, …) |
| El indicador y la etiqueta | el radio | sus propios elementos (children) |
group-checked:, group-disabled: y las demás variantes group-*: con group en el class del radio |
El indicador es contenido de la app: un anillo con un punto, una marca de verificación, una tarjeta resaltada. Con group en el class del radio, sus clases group-checked: muestran el punto mientras el radio está marcado.
#Deshabilitado
Un radio deshabilitado no admite pulsaciones, y los lectores de pantalla lo anuncian como atenuado. Sus clases disabled: se aplican sobre las checked:, y las clases group-disabled: dentro de él las siguen:
<Radio
checked={plan() === "team"}
disabled={!eligible()}
onChange={() => plan.set("team")}
class="group flex-row items-center gap-2 disabled:opacity-50"
>
<View class="size-5 items-center justify-center rounded-full border-2 border-border group-checked:border-primary">
<View class="size-2.5 rounded-full group-checked:bg-primary" />
</View>
<Text>Team</Text>
</Radio>
#Un objetivo pequeño con hitSlop
Un radio sin etiqueta a su lado (un punto en la fila de una tabla) 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.
<Radio
checked={plan() === "team"}
hitSlop={12}
accessibilityLabel="Team"
onChange={() => plan.set("team")}
class="group size-5 items-center justify-center rounded-full border-2 border-border group-checked:border-primary"
>
<View class="size-2.5 rounded-full group-checked:bg-primary" />
</Radio>
#Accesibilidad
- Un radio es un solo elemento para los lectores de pantalla, con el rol
"radio"y su estado: marcado o no, y seleccionado mientras está marcado, ya que algunos lectores de pantalla leen uno y otros el otro (los radios de UI Automation son elementos de selección). Su indicador y su etiqueta son partes de él; su etiqueta es el texto de sus hijos, oaccessibilityLabel. - Un
RadioGrouptiene el rol"radiogroup": los lectores de pantalla oyen los radios que contiene como un solo conjunto, 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, un
RadioGroupes una sola parada del foco del teclado, y las teclas de flecha se mueven entre sus radios y los marcan (más abajo).
Consulta Accesibilidad para ver etiquetas, roles y anuncios en general.
#Teclado
En macOS y la web, donde Tab llega a las vistas que admiten pulsaciones, un RadioGroup sigue el patrón de grupo de radios de WAI-ARIA:
| Tecla | Qué hace |
|---|---|
| Tab, Shift+Tab | Entra en el grupo por el radio marcado (el primer radio no deshabilitado si no hay ninguno), y sale de él en un solo paso. |
| Abajo, Derecha | Mueve el foco al siguiente radio, del último al primero, y lo marca. |
| Arriba, Izquierda | Mueve el foco al radio anterior, del primero al último, y lo marca. |
| Espacio | Marca el radio con foco. |
Las teclas de flecha omiten los radios deshabilitados; el radio al que se mueven pide ser marcado con onChange, y la app lo marca. En un diseño de derecha a izquierda, Izquierda y Derecha intercambian sus papeles: Izquierda pasa al siguiente radio y Derecha al anterior; Arriba y Abajo mantienen los suyos. Los estilos focus: muestran dónde está el foco. Un Radio fuera de un RadioGroup es una parada propia, a la que llega Tab y que Espacio y Retorno marcan.
#Notas de plataforma
- iOS, Android y Mac Catalyst: una pulsación es un toque que termina sobre el radio, etiqueta incluida.
- Escritorio (macOS, Windows, Linux, GPUI) y la web: una pulsación es un clic. En macOS y la web, Espacio y Retorno también marcan el radio que tiene el foco del teclado, y las teclas de flecha lo mueven (Teclado).
- Todas las plataformas dibujan lo que la app estiliza: el indicador, la etiqueta y sus transiciones.