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

元素

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

PropRust型別預設值說明
checked.checked(..)booleanfalse是否已勾選。按壓未勾選的 radio 時會回報 true,其值為 onChange;radio 顯示的是應用程式回傳的值。
disabled.disabled(..)booleanfalse不接受按壓,也沒有按壓回饋;會套用 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

PropRust型別預設值說明
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、stylechecked:、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,方向鍵則可移動焦點(鍵盤)。
  • 各平台都會繪製應用程式所設定的樣式:指示器、標籤及其轉場效果。