Elementi
Radio
Le radio offrono un insieme di opzioni tra cui l’utente ne sceglie esattamente una: un piano, un ordinamento, un tema. Ogni Radio gestisce il comportamento di una opzione: pressioni, attivazione da tastiera, stato disabilitato, e il ruolo radio con il relativo stato checked per gli screen reader. Un RadioGroup attorno ad esse costituisce l’insieme, che gli screen reader annunciano come un unico gruppo. L’aspetto è dell’app: una radio non ha un proprio cerchio, punto, colori, dimensioni o etichetta. I suoi figli sono l’indicatore e l’etichetta dell’app, e le classi checked: sulla radio, o le classi group-checked: su ciò che contiene, ne seguono lo stato.
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 provengono da nsh/base, il crate nsh-base in Rust. Il valore appartiene all’app: il gruppo non ne conserva alcuno. A ogni radio viene comunicato se è selezionata; una pressione su una radio non selezionata segnala onChange({ checked: true }), l’app imposta il proprio valore, e ogni radio mostra il checked che l’app restituisce. In Rust, radio() e radio_group() le creano, con un setter per ogni prop (.checked(..)), un listener per ogni evento (.on_change(..)), i metodi di stile tipizzati e .child(..) per il loro contenuto.
#Props
#Radio
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
checked | .checked(..) | boolean | false | Selezionata o no. Una pressione su una radio non selezionata segnala true con onChange; la radio mostra il valore che l’app restituisce. |
disabled | .disabled(..) | boolean | false | Nessuna pressione e nessun feedback alla pressione; si applicano gli stili disabled:, e gli screen reader dicono che è attenuata. |
checkedStyle | .checked_style(|s| ..) | StyleProp<ViewStyle> | nessuno | Lo stile della radio mentre è selezionata e non disabilitata, come classi checked:. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Ciò che dicono gli screen reader: l’opzione, quando i figli non hanno un testo che la esprima. |
hitSlop | .hit_slop(..) | number | Partial<Edges> | nessuno | Di quanto fuori dai suoi limiti le pressioni contano ancora, in punti: un numero per tutti i lati, oppure i lati indicati per nome. Come hitSlop di una view. |
children | .child(..) | JSX.Element | nessuno | L’indicatore e l’etichetta dell’app, che gli screen reader leggono. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | La radio stessa: la riga attorno all’indicatore e all’etichetta. |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile della radio mentre è premuta, in hover, con il focus o disabilitata, come classi <state>:. |
#RadioGroup
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
children | .child(..) | JSX.Element | nessuno | Le radio dell’insieme, e qualsiasi cosa l’app disponga tra di esse. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il layout e l’aspetto del gruppo: una colonna per impostazione predefinita, flex-row per una riga. |
#Eventi
| Evento | Rust | Descrizione |
|---|---|---|
onChange (Radio) | .on_change(|checked: &bool| ..) | Una radio non selezionata è stata premuta: un tap, un clic, Space o Return mentre ha il focus da tastiera, oppure l’attivazione da parte di uno screen reader; oppure un tasto freccia del suo gruppo ha spostato il focus da tastiera su di essa. Chiede di essere selezionata, quindi checked è true. Viene eseguito prima che cambi qualsiasi cosa: la radio mostra il checked che l’app restituisce. |
Una radio selezionata resta selezionata e non segnala nulla, poiché una radio non può deselezionarsi da sola; nemmeno una radio disabilitata segnala nulla.
#Parti
| Parte | Dentro | Stilizzata con | Stati |
|---|---|---|---|
| Il gruppo | RadioGroup: class, style | ||
| La radio | il gruppo | Radio: class, style | checked:, disabled:, active:, hover:, focus: (o checkedStyle, disabledStyle, …) |
| L’indicatore e l’etichetta | la radio | propri elementi (children) | group-checked:, group-disabled: e le altre varianti group-*: con group su class della radio |
L’indicatore è contenuto dell’app: un anello con un punto, un segno di spunta, una card evidenziata. Con group su class della radio, le sue classi group-checked: mostrano il punto mentre la radio è selezionata.
#Disabilitato
Una radio disabilitata non accetta pressioni, e gli screen reader la annunciano come attenuata. Le sue classi disabled: si applicano sopra quelle checked:, e le classi group-disabled: al suo interno le seguono:
<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’area piccola con hitSlop
Una radio senza etichetta accanto (un punto nella riga di una tabella) può essere più piccola di un dito. hitSlop fa contare le pressioni che cadono vicino ad essa, in punti oltre ciascun lato, senza cambiarne il layout o l’aspetto: un numero per tutti i lati, oppure i lati indicati per nome ({ top: 8, bottom: 8 }), come li accetta hitSlop di una view.
<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>
#Accessibilità
- Una radio è un unico elemento per gli screen reader, con il ruolo
"radio"e il suo stato: selezionata o no, e selected mentre è selezionata, poiché alcuni screen reader leggono l’uno e altri l’altro (le radio di UI Automation sono elementi di selezione). Il suo indicatore e la sua etichetta ne sono parti; la sua etichetta è il testo dei suoi figli, oppureaccessibilityLabel. - Un
RadioGroupha il ruolo"radiogroup": gli screen reader sentono le radio al suo interno come un unico insieme, ciascuna comunque un elemento a sé. disabledviene annunciato come lo annuncia la piattaforma (VoiceOver, TalkBack, UI Automation, l’albero di accessibilità del browser).- Su macOS e sul web un
RadioGroupè una sola tappa del focus da tastiera, e i tasti freccia si spostano tra le sue radio e le selezionano (vedi sotto).
Vedi Accessibilità per etichette, ruoli e annunci in generale.
#Tastiera
Su macOS e sul web, dove Tab raggiunge le view che accettano pressioni, un RadioGroup segue il pattern WAI-ARIA per i radio group:
| Tasto | Cosa fa |
|---|---|
| Tab, Shift+Tab | Entra nel gruppo sulla radio selezionata (la prima radio non disabilitata se nessuna lo è), e lo lascia in un solo passaggio. |
| Giù, Destra | Sposta il focus sulla radio successiva, dall’ultima alla prima, e la seleziona. |
| Su, Sinistra | Sposta il focus sulla radio precedente, dalla prima all’ultima, e la seleziona. |
| Space | Seleziona la radio con il focus. |
I tasti freccia saltano le radio disabilitate; la radio su cui si spostano chiede di essere selezionata con onChange, e l’app la seleziona. In un layout da destra a sinistra Sinistra e Destra si scambiano: Sinistra passa alla radio successiva e Destra alla precedente; Su e Giù mantengono le proprie. Gli stili focus: mostrano dove si trova il focus. Un Radio fuori da un RadioGroup è una tappa a sé, che Tab raggiunge e che Space e Return selezionano.
#Note sulle piattaforme
- iOS, Android e Mac Catalyst: una pressione è un tap che termina sulla radio, etichetta inclusa.
- Desktop (macOS, Windows, Linux, GPUI) e web: una pressione è un clic. Su macOS e sul web, Space e Return selezionano anche la radio che ha il focus da tastiera, e i tasti freccia lo spostano (Tastiera).
- Ogni piattaforma disegna ciò che l’app stilizza: l’indicatore, l’etichetta e le loro transizioni.