元素
Radio
Radio 提供一組選項,使用者只能從中選擇其中一個,例如方案、排序方式或主題。每個 Radio 負責單一選項的行為:按壓、鍵盤啟用、停用狀態,以及供螢幕閱讀器使用的 radio 角色與其勾選狀態。包在它們外層的 RadioGroup 就是整組選項,螢幕閱讀器會將其視為一個群組來朗讀。外觀由應用程式決定:radio 本身沒有圓圈、圓點、顏色、尺寸或標籤。它的子元素就是應用程式自己的指示器與標籤,radio 上的 checked: 類別,或其內部元素上的 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,也就是 Rust 中的 nsh-base crate。值由應用程式持有,群組本身不保存任何值。每個 radio 都會被告知自己是否已勾選;按壓未勾選的 radio 時會回報 onChange({ checked: true }),由應用程式設定其值,而每個 radio 顯示的是應用程式回傳的 checked。在 Rust 中,使用 radio() 與 radio_group() 建立它們,每個 prop 都有對應的 setter(.checked(..)),每個事件都有對應的 listener(.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 | 無 | 螢幕閱讀器朗讀的內容:當其子元素沒有可表達該選項的文字時,用此 prop 說明該選項。 |
hitSlop | .hit_slop(..) | number | Partial<Edges> | 無 | 超出邊界多遠的按壓仍會被計入,單位為點:可用一個數字套用於所有邊,或分別指定各邊。與 view 的 hitSlop 相同。 |
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:,以及在 radio 的 class 上搭配 group 的其他 group-*: 變體 |
指示器是應用程式的內容:可以是帶圓點的圓環、勾號,或醒目標示的卡片。在 radio 的 class 上設定 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 }),寫法與 view 的 hitSlop 相同。
<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",並帶有其狀態:是否已勾選,以及勾選時為已選取,因為有些螢幕閱讀器朗讀前者,有些則朗讀後者(UI Automation 的 radio 屬於選取項目)。它的指示器與標籤都是它的一部分;其標籤取自子元素的文字,或accessibilityLabel。 RadioGroup的角色為"radiogroup":螢幕閱讀器會將其中的 radio 視為一組,但每個 radio 仍是各自獨立的元素。disabled會依各平台自身的方式朗讀(VoiceOver、TalkBack、UI Automation、瀏覽器的無障礙樹)。- 在 macOS 與 web 上,
RadioGroup是鍵盤焦點的單一停駐點,方向鍵可在其 radio 之間移動並勾選(見下文)。
關於標籤、角色與朗讀內容的一般說明,請參閱 Accessibility。
#鍵盤
在 macOS 與 web 上,Tab 可到達接受按壓的 view,此時 RadioGroup 遵循 WAI-ARIA radio group pattern:
| 按鍵 | 作用 |
|---|---|
| Tab、Shift+Tab | 從已勾選的 radio 進入群組(若沒有已勾選的,則進入第一個未停用的 radio),並一步離開群組。 |
| Down、Right | 將焦點移到下一個 radio,從最後一個繞回第一個,並勾選它。 |
| Up、Left | 將焦點移到上一個 radio,從第一個繞回最後一個,並勾選它。 |
| Space | 勾選目前取得焦點的 radio。 |
方向鍵會略過已停用的 radio;它們移往的 radio 會透過 onChange 要求被勾選,再由應用程式完成勾選。在由右至左的版面配置中,Left 與 Right 的作用互換:Left 移到下一個 radio,Right 移到上一個;Up 與 Down 則維持不變。focus: 樣式會顯示焦點所在位置。位於 RadioGroup 之外的 Radio 是獨立的停駐點,Tab 可以到達,並可用 Space 與 Return 勾選。
#平台說明
- iOS、Android 與 Mac Catalyst:按壓是指在 radio 上結束的點選,包含其標籤範圍。
- 桌面平台(macOS、Windows、Linux、GPUI)與 web:按壓是指點擊。在 macOS 與 web 上,Space 與 Return 也可勾選取得鍵盤焦點的 radio,方向鍵則可移動焦點(鍵盤)。
- 各平台都會繪製應用程式所設定的樣式:指示器、標籤及其轉場效果。