本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

Radio

Radio 讓用戶從一組選項中只揀選一個,例如方案、排序方式或主題。每個 Radio 負責一個選項的行為:按下、鍵盤啟動、停用狀態,以及供螢幕閱讀器使用的 radio 角色和其勾選狀態。包住它們的 RadioGroup 就是整組選項,螢幕閱讀器會將它們當作一個群組讀出。外觀由應用程式決定:radio 本身沒有圓圈、圓點、顏色、尺寸或標籤。它的子元素就是應用程式的指示器和標籤,而 radio 上的 checked: 類別,或其內部元素上的 group-checked: 類別,會跟隨它的狀態。

el-radio.tsx
el-radio.tsxLive
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"))
}

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,方向鍵則可移動焦點(鍵盤)。
  • 每個平台都會繪製應用程式所設定的樣式:指示器、標籤及其過渡效果。