Elementos
Radio
Radios oferecem um conjunto de opções das quais o usuário escolhe exatamente uma: um plano, uma ordem de classificação, um tema. Cada Radio é responsável pelo comportamento de uma opção: pressionamentos, ativação por teclado, o estado desabilitado, e o papel de radio e seu estado marcado para leitores de tela. Um RadioGroup em volta deles é o conjunto, que os leitores de tela anunciam como um só grupo. A aparência é do app: um radio não tem círculo, ponto, cores, tamanhos nem rótulo próprios. Seus filhos são o indicador e o rótulo do app, e classes checked: no radio, ou classes group-checked: no que está dentro dele, acompanham seu 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 e RadioGroup vêm de nsh/base, o crate nsh-base do Rust. O valor é do app: o grupo não guarda nenhum. Cada radio é informado se está marcado; um pressionamento em um radio desmarcado reporta onChange({ checked: true }), o app define seu valor, e cada radio mostra o checked que o app devolve. Em Rust, radio() e radio_group() os criam, com um setter por prop (.checked(..)), um listener por evento (.on_change(..)), os métodos de estilo tipados e .child(..) para o conteúdo.
#Props
#Radio
| Prop | Rust | Tipo | Padrão | Descrição |
|---|---|---|---|---|
checked |
.checked(..) |
boolean |
false |
Marcado ou não. Um pressionamento em um radio desmarcado reporta true com onChange; o radio mostra o valor que o app devolve. |
disabled |
.disabled(..) |
boolean |
false |
Sem pressionamentos e sem feedback de pressionamento; os estilos disabled: se aplicam, e os leitores de tela dizem que está esmaecido. |
checkedStyle |
.checked_style(|s| ..) |
StyleProp<ViewStyle> |
nenhum | O estilo do radio enquanto marcado e não desabilitado, como classes checked:. |
accessibilityLabel |
.accessibility_label(..) |
string |
nenhum | O que os leitores de tela dizem: a opção, quando seus filhos não têm texto que a diga. |
hitSlop |
.hit_slop(..) |
number | Partial<Edges> |
nenhum | A distância, fora dos limites do radio, em que os pressionamentos ainda contam, em pontos: um número para todos os lados, ou os lados nomeados. Como o hitSlop de uma view. |
children |
.child(..) |
JSX.Element |
nenhum | O indicador e o rótulo do app, que os leitores de tela dizem. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
nenhum | O próprio radio: a linha em torno do indicador e do rótulo. |
activeStyle, hoverStyle, focusStyle, disabledStyle |
.active(|s| ..), .hover(..), .focus(..), .disabled_style(..) |
StyleProp<ViewStyle> |
nenhum | O estilo do radio enquanto pressionado, com hover, com foco ou desabilitado, como as classes <state>:. |
#RadioGroup
| Prop | Rust | Tipo | Padrão | Descrição |
|---|---|---|---|---|
children |
.child(..) |
JSX.Element |
nenhum | Os radios do conjunto, e o que o app dispuser entre eles. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
nenhum | O layout e a aparência do grupo: uma coluna por padrão, flex-row para uma linha. |
#Eventos
| Evento | Rust | Descrição |
|---|---|---|
onChange (Radio) |
.on_change(|checked: &bool| ..) |
Um radio desmarcado foi pressionado: um toque, um clique, Space ou Return enquanto ele tem o foco do teclado, ou a ativação por um leitor de tela; ou uma tecla de seta do seu grupo moveu o foco do teclado para ele. Ele pede para ser marcado, então checked é true. Executa antes de qualquer mudança: o radio mostra o checked que o app devolve. |
Um radio marcado continua marcado e não reporta nada, pois um radio não pode se desmarcar sozinho; um radio desabilitado também não reporta nada.
#Partes
| Parte | Dentro de | Estilizada com | Estados |
|---|---|---|---|
| O grupo | class, style de RadioGroup |
||
| O radio | o grupo | class, style de Radio |
checked:, disabled:, active:, hover:, focus: (ou checkedStyle, disabledStyle, …) |
| O indicador e o rótulo | o radio | seus próprios elementos (children) |
group-checked:, group-disabled: e as outras variantes group-*: com group no class do radio |
O indicador é o conteúdo do app: um anel com um ponto, um check, um card destacado. Com group no class do radio, suas classes group-checked: mostram o ponto enquanto o radio está marcado.
#Desabilitado
Um radio desabilitado não recebe pressionamentos, e os leitores de tela o anunciam como esmaecido. Suas classes disabled: se aplicam sobre as checked:, e as classes group-disabled: dentro dele acompanham:
<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>
#Um alvo pequeno com hitSlop
Um radio sem rótulo ao lado (um ponto na linha de uma tabela) pode ser menor que um dedo. hitSlop faz com que os pressionamentos que caem perto dele contem, em pontos além de cada lado, sem mudar seu layout nem sua aparência: um número para todos os lados, ou os lados nomeados ({ top: 8, bottom: 8 }), como o hitSlop de uma view os recebe.
<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>
#Acessibilidade
- Um radio é um único elemento para os leitores de tela, com o papel
"radio"e seu estado: marcado ou não, e selecionado enquanto marcado, pois alguns leitores de tela leem um e outros leem o outro (os radios do UI Automation são itens de seleção). Seu indicador e seu rótulo são partes dele; seu rótulo é o texto de seus filhos, ouaccessibilityLabel. - Um
RadioGrouptem o papel"radiogroup": os leitores de tela ouvem os radios dentro dele como um só conjunto, cada um ainda sendo um elemento próprio. disabledé anunciado como a plataforma o anuncia (VoiceOver, TalkBack, UI Automation, a árvore de acessibilidade do navegador).- No macOS e na web, um
RadioGroupé uma única parada do foco do teclado, e as teclas de seta se movem entre seus radios e os marcam (abaixo).
Veja Acessibilidade para rótulos, papéis e anúncios em geral.
#Teclado
No macOS e na web, onde Tab alcança as views que recebem pressionamentos, um RadioGroup segue o padrão de radio group do WAI-ARIA:
| Tecla | O que faz |
|---|---|
| Tab, Shift+Tab | Entra no grupo pelo radio marcado (o primeiro radio não desabilitado, enquanto nenhum estiver marcado), e sai dele em um só passo. |
| Down, Right | Move o foco para o próximo radio, do último para o primeiro, e o marca. |
| Up, Left | Move o foco para o radio anterior, do primeiro para o último, e o marca. |
| Space | Marca o radio com foco. |
As teclas de seta pulam os radios desabilitados; o radio para o qual elas movem pede para ser marcado com onChange, e o app o marca. Em um layout da direita para a esquerda, Left e Right trocam de lugar: Left move para o próximo radio e Right para o anterior; Up e Down mantêm os seus. Os estilos focus: mostram onde está o foco. Um Radio fora de um RadioGroup é uma parada própria, que Tab alcança e que Space e Return marcam.
#Notas de plataforma
- iOS, Android e Mac Catalyst: um pressionamento é um toque que termina no radio, incluindo seu rótulo.
- Desktops (macOS, Windows, Linux, GPUI) e web: um pressionamento é um clique. No macOS e na web, Space e Return também marcam o radio que tem o foco do teclado, e as teclas de seta o movem (Teclado).
- Todas as plataformas desenham o que o app estiliza: o indicador, o rótulo e suas transições.