Éléments
Radio
Les radios proposent un ensemble d’options dont l’utilisateur en choisit exactement une : un forfait, un ordre de tri, un thème. Chaque Radio gère le comportement d’une option : les appuis, l’activation au clavier, l’état désactivé, ainsi que le rôle radio et son état coché pour les lecteurs d’écran. Un RadioGroup placé autour d’elles forme l’ensemble, que les lecteurs d’écran annoncent comme un seul groupe. L’apparence appartient à l’app : une radio n’a ni cercle, ni point, ni couleurs, ni tailles, ni libellé propres. Ses enfants sont l’indicateur et le libellé de l’app, et les classes checked: sur la radio, ou les classes group-checked: sur son contenu, suivent son état.
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 et RadioGroup viennent de nsh/base, le crate nsh-base en Rust. La valeur appartient à l’app : le groupe n’en détient aucune. Chaque radio sait si elle est cochée ; un appui sur une radio non cochée signale onChange({ checked: true }), l’app définit sa valeur, et chaque radio affiche le checked que l’app lui renvoie. En Rust, radio() et radio_group() les créent, avec un setter par prop (.checked(..)), un écouteur par événement (.on_change(..)), les méthodes de style typées et .child(..) pour leur contenu.
#Props
#Radio
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
checked | .checked(..) | boolean | false | Cochée ou non. Un appui sur une radio non cochée signale true avec onChange ; la radio affiche la valeur que l’app lui renvoie. |
disabled | .disabled(..) | boolean | false | Aucun appui et aucun retour d’appui ; les styles disabled: s’appliquent, et les lecteurs d’écran indiquent qu’elle est grisée. |
checkedStyle | .checked_style(|s| ..) | StyleProp<ViewStyle> | aucun | Le style de la radio lorsqu’elle est cochée et non désactivée, sous forme de classes checked:. |
accessibilityLabel | .accessibility_label(..) | string | aucun | Ce que disent les lecteurs d’écran : l’option, lorsque ses enfants n’ont aucun texte qui l’indique. |
hitSlop | .hit_slop(..) | number | Partial<Edges> | aucun | Distance, en points, au-delà de ses limites sur laquelle les appuis comptent encore : un nombre pour tous les côtés, ou les côtés nommés. Comme le hitSlop d’une vue. |
children | .child(..) | JSX.Element | aucun | L’indicateur et le libellé de l’app, que les lecteurs d’écran énoncent. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | La radio elle-même : la ligne autour de l’indicateur et du libellé. |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | aucun | Le style de la radio lorsqu’elle est pressée, survolée, ciblée par le focus ou désactivée, sous forme de classes <state>:. |
#RadioGroup
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
children | .child(..) | JSX.Element | aucun | Les radios de l’ensemble, et tout ce que l’app dispose entre elles. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | La mise en page et l’apparence du groupe : une colonne par défaut, flex-row pour une ligne. |
#Événements
| Événement | Rust | Description |
|---|---|---|
onChange (Radio) | .on_change(|checked: &bool| ..) | Une radio non cochée a été pressée : un toucher, un clic, Espace ou Entrée lorsqu’elle a le focus clavier, ou l’activation par un lecteur d’écran ; ou bien une touche fléchée de son groupe a déplacé le focus clavier vers elle. Elle demande à être cochée, donc checked vaut true. L’événement s’exécute avant tout changement : la radio affiche le checked que l’app lui renvoie. |
Une radio cochée reste cochée et ne signale rien, car une radio ne peut pas se décocher elle-même ; une radio désactivée ne signale rien non plus.
#Parties
| Partie | À l’intérieur de | Stylée avec | États |
|---|---|---|---|
| Le groupe | RadioGroup : class, style | ||
| La radio | le groupe | Radio : class, style | checked:, disabled:, active:, hover:, focus: (ou checkedStyle, disabledStyle, …) |
| L’indicateur et le libellé | la radio | leurs propres éléments (children) | group-checked:, group-disabled: et les autres variantes group-*: avec group sur le class de la radio |
L’indicateur est le contenu de l’app : un anneau avec un point, une coche, une carte mise en évidence. Avec group sur le class de la radio, ses classes group-checked: affichent le point tant que la radio est cochée.
#Désactivée
Une radio désactivée ne reçoit aucun appui, et les lecteurs d’écran l’annoncent comme grisée. Ses classes disabled: s’appliquent par-dessus ses classes checked:, et les classes group-disabled: à l’intérieur suivent :
<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>
#Une petite cible avec hitSlop
Une radio sans libellé à côté (un point dans la ligne d’un tableau) peut être plus petite qu’un doigt. hitSlop fait compter les appuis qui tombent près d’elle, en points au-delà de chaque côté, sans modifier sa mise en page ni son apparence : un nombre pour tous les côtés, ou les côtés nommés ({ top: 8, bottom: 8 }), comme le hitSlop d’une vue les accepte.
<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é
- Une radio est un seul élément pour les lecteurs d’écran, avec le rôle
"radio"et son état : cochée ou non, et sélectionnée lorsqu’elle est cochée, car certains lecteurs d’écran lisent l’un et d’autres l’autre (les radios d’UI Automation sont des éléments de sélection). Son indicateur et son libellé en font partie ; son libellé est le texte de ses enfants, ouaccessibilityLabel. - Un
RadioGroupa le rôle"radiogroup": les lecteurs d’écran entendent les radios qu’il contient comme un seul ensemble, chacune restant un élément à part entière. disabledest annoncé comme le fait la plateforme (VoiceOver, TalkBack, UI Automation, l’arbre d’accessibilité du navigateur).- Sur macOS et le web, un
RadioGroupconstitue un seul arrêt du focus clavier, et les touches fléchées se déplacent entre ses radios et les cochent (voir ci-dessous).
Voir Accessibilité pour les libellés, les rôles et les annonces en général.
#Clavier
Sur macOS et le web, où Tab atteint les vues qui reçoivent des appuis, un RadioGroup suit le modèle WAI-ARIA du groupe de boutons radio :
| Touche | Effet |
|---|---|
| Tab, Maj+Tab | Entre dans le groupe sur la radio cochée (la première radio non désactivée si aucune ne l’est), et en sort en une seule étape. |
| Bas, Droite | Déplace le focus vers la radio suivante, de la dernière à la première, et la coche. |
| Haut, Gauche | Déplace le focus vers la radio précédente, de la première à la dernière, et la coche. |
| Espace | Coche la radio ayant le focus. |
Les touches fléchées ignorent les radios désactivées ; la radio vers laquelle elles se déplacent demande à être cochée avec onChange, et l’app la coche. Dans une mise en page de droite à gauche, Gauche et Droite s’inversent : Gauche passe à la radio suivante et Droite à la précédente ; Haut et Bas gardent leur sens. Les styles focus: montrent où se trouve le focus. Une Radio en dehors d’un RadioGroup est un arrêt à part entière, que Tab atteint et que Espace et Entrée cochent.
#Notes sur les plateformes
- iOS, Android et Mac Catalyst : un appui est un toucher qui se termine sur la radio, libellé compris.
- Ordinateurs de bureau (macOS, Windows, Linux, GPUI) et web : un appui est un clic. Sur macOS et le web, Espace et Entrée cochent aussi la radio qui a le focus clavier, et les touches fléchées le déplacent (Clavier).
- Chaque plateforme dessine ce que l’app stylise : l’indicateur, le libellé et leurs transitions.