元素
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 |
無 | 螢幕閱讀器讀出的內容:該選項;當其子元素沒有文字說明時使用。 |
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 之間移動並勾選它們(見下文)。
有關標籤、角色和朗讀內容的一般說明,請參閱無障礙。
#鍵盤
在 macOS 和 web 上,Tab 可以到達接受按下的 view,RadioGroup 遵循 WAI-ARIA radio group 模式:
| 按鍵 | 作用 |
|---|---|
| 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,方向鍵則可移動焦點(鍵盤)。
- 每個平台都會繪製應用程式所設定的樣式:指示器、標籤及其過渡效果。