Элементы
Radio
Radio-кнопки предлагают набор вариантов, из которых пользователь выбирает ровно один: тариф, порядок сортировки, тему. Каждый Radio отвечает за поведение одного варианта: нажатия, активацию с клавиатуры, отключённое состояние, а также роль radio и её состояние checked для программ чтения с экрана. RadioGroup вокруг них задаёт набор, который программы чтения с экрана объявляют как одну группу. Внешний вид определяет приложение: у radio нет собственного круга, точки, цветов, размеров или подписи. Его дочерние элементы — это индикатор и подпись приложения, а классы checked: на radio или классы group-checked: на том, что находится внутри него, следуют за его состоянием.
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 и RadioGroup приходят из nsh/base, крейта nsh-base в Rust. Значение принадлежит приложению: группа его не хранит. Каждому radio сообщается, выбран ли он; нажатие на невыбранный radio сообщает onChange({ checked: true }), приложение устанавливает своё значение, и каждый radio показывает checked, которое передаёт обратно приложение. В Rust их создают radio() и radio_group(): с сеттером для каждого свойства (.checked(..)), слушателем для каждого события (.on_change(..)), типизированными методами стиля и .child(..) для их содержимого.
#Props
#Radio
| Prop | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
checked | .checked(..) | boolean | false | Выбран или нет. Нажатие на невыбранный radio сообщает true со значением onChange; radio показывает значение, которое передаёт обратно приложение. |
disabled | .disabled(..) | boolean | false | Нет ни нажатий, ни отклика на нажатие; применяются стили disabled:, а программы чтения с экрана сообщают, что элемент приглушён. |
checkedStyle | .checked_style(|s| ..) | StyleProp<ViewStyle> | нет | Стиль radio, пока он выбран и не отключён, в виде классов checked:. |
accessibilityLabel | .accessibility_label(..) | string | нет | Что произносят программы чтения с экрана: вариант, если в дочерних элементах нет текста, который его называет. |
hitSlop | .hit_slop(..) | number | Partial<Edges> | нет | На каком расстоянии за пределами границ нажатия всё ещё засчитываются, в пунктах: одно число для всех сторон или отдельно для названных сторон. Как hitSlop у view. |
children | .child(..) | JSX.Element | нет | Индикатор и подпись приложения, которые произносят программы чтения с экрана. |
class, style | методы стиля | string, StyleProp<ViewStyle> | нет | Сам radio: строка вокруг индикатора и подписи. |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | нет | Стиль radio, пока он нажат, находится под курсором, в фокусе или отключён, в виде классов <state>:. |
#RadioGroup
| Prop | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
children | .child(..) | JSX.Element | нет | Radio набора и всё, что приложение размещает между ними. |
class, style | методы стиля | string, StyleProp<ViewStyle> | нет | Раскладка и внешний вид группы: по умолчанию колонка, flex-row для строки. |
#События
| Событие | Rust | Описание |
|---|---|---|
onChange (Radio) | .on_change(|checked: &bool| ..) | Невыбранный radio был нажат: касание, щелчок, Space или Return, пока у него фокус клавиатуры, либо активация программой чтения с экрана; или стрелка группы переместила на него фокус клавиатуры. Он просит выбрать его, поэтому checked равно true. Событие срабатывает до каких-либо изменений: radio показывает checked, которое передаёт обратно приложение. |
Выбранный radio остаётся выбранным и ничего не сообщает, так как radio не может снять выбор сам; отключённый radio тоже ничего не сообщает.
#Части
| Часть | Находится внутри | Стилизуется с помощью | Состояния |
|---|---|---|---|
| Группа | RadioGroup: class, style | ||
| Radio | группа | Radio: class, style | checked:, disabled:, active:, hover:, focus: (или checkedStyle, disabledStyle, …) |
| Индикатор и подпись | radio | собственные элементы (children) | group-checked:, group-disabled: и другие варианты group-*: с group на class у radio |
Индикатор — это содержимое приложения: кольцо с точкой, галочка, выделенная карточка. Если на class у radio задан group, его классы group-checked: показывают точку, пока radio выбран.
#Отключённое состояние
Отключённый radio не принимает нажатий, а программы чтения с экрана объявляют его приглушённым. Его классы disabled: применяются поверх классов checked:, а классы group-disabled: внутри него следуют за ним:
<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>
#Маленькая область нажатия с hitSlop
Radio без подписи рядом (точка в строке таблицы) может быть меньше пальца. hitSlop делает так, что нажатия рядом с ним засчитываются, в пунктах за каждой стороной, не меняя ни раскладку, ни внешний вид: одно число для всех сторон или отдельно для названных сторон ({ top: 8, bottom: 8 }), так же как их принимает hitSlop у 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>
#Доступность
- Radio для программ чтения с экрана — один элемент с ролью
"radio"и своим состоянием: выбран или нет, и selected, пока он выбран, поскольку одни программы чтения с экрана читают первое, а другие второе (radio в UI Automation — это элементы выбора). Его индикатор и подпись являются его частями; подпись — это текст его дочерних элементов илиaccessibilityLabel. - У
RadioGroupроль"radiogroup": программы чтения с экрана воспринимают radio внутри него как один набор, при этом каждый остаётся отдельным элементом. disabledобъявляется так, как его объявляет платформа (VoiceOver, TalkBack, UI Automation, дерево доступности браузера).- В macOS и в вебе
RadioGroup— это одна остановка фокуса клавиатуры, а клавиши со стрелками перемещаются между его radio и выбирают их (см. ниже).
Подробнее о подписях, ролях и объявлениях в целом см. Accessibility.
#Клавиатура
В macOS и в вебе, где Tab переходит к views, принимающим нажатия, RadioGroup следует паттерну radio group из WAI-ARIA:
| Клавиша | Что делает |
|---|---|
| Tab, Shift+Tab | Входит в группу на выбранном radio (если выбранного нет — на первом radio, который не отключён) и покидает её за один шаг. |
| Down, Right | Перемещает фокус на следующий radio, с последнего на первый, и выбирает его. |
| Up, Left | Перемещает фокус на предыдущий radio, с первого на последний, и выбирает его. |
| Space | Выбирает radio, находящийся в фокусе. |
Клавиши со стрелками пропускают отключённые radio; radio, на который они переходят, просит выбрать его с помощью onChange, и приложение его выбирает. В раскладке справа налево Left и Right меняются местами: Left переходит к следующему radio, а Right — к предыдущему; Up и Down остаются прежними. Стили focus: показывают, где находится фокус. Radio вне RadioGroup — это отдельная остановка, до которой доходит Tab и которую выбирают Space и Return.
#Особенности платформ
- iOS, Android и Mac Catalyst: нажатие — это касание, которое заканчивается на radio, включая его подпись.
- Настольные платформы (macOS, Windows, Linux, GPUI) и веб: нажатие — это щелчок. В macOS и в вебе Space и Return также выбирают radio, у которого фокус клавиатуры, а клавиши со стрелками перемещают его (Клавиатура).
- Каждая платформа отрисовывает то, что стилизует приложение: индикатор, подпись и их переходы.