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

元素

Toggle

A toggle 是一個保持開或關的按鈕:文字工具列中的粗體、收藏星號、檢視選項都是例子。Toggle 負責掌管它的行為:按下、鍵盤啟動、停用狀態,以及帶有按下狀態的按鈕角色,螢幕閱讀器會將它讀作切換按鈕。以 ToggleGroup 包住多個切換按鈕就成為工具列:螢幕閱讀器會將它讀作工具列,鍵盤只需一個停駐點就能到達它,並可用方向鍵在其切換按鈕之間移動。外觀由應用程式決定:切換按鈕本身沒有顏色、尺寸、圖示或標籤。它的子元素是應用程式的圖示或標籤,而切換按鈕上的 aria-pressed: 類別,或其內部元素上的 group-aria-pressed: 類別,會隨其狀態變化。

el-toggle.tsx
el-toggle.tsxLive
import { Canvas, mount, signal, Text, View } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Split, tint, ui } from "./palette";

type Mark = "bold" | "italic" | "underline";
type Align = "left" | "center" | "right";

const marks: { id: Mark; label: string; glyph: string }[] = [
  { id: "bold", label: "Bold", glyph: "B" },
  { id: "italic", label: "Italic", glyph: "I" },
  { id: "underline", label: "Underline", glyph: "U" },
];
const aligns: { id: Align; label: string }[] = [
  { id: "left", label: "Align left" },
  { id: "center", label: "Align center" },
  { id: "right", label: "Align right" },
];

/** Three lines of a paragraph, set as `align` sets them. */
function Lines(props: { align: Align; color: string }) {
  return (
    <Canvas
      style={{ pointerEvents: "none", width: 16, height: 16 }}
      draw={(cx) => {
        cx.strokeStyle = props.color;
        cx.lineWidth = 1.6;
        cx.lineCap = "round";
        [12, 7, 10].forEach((w, i) => {
          const x = props.align === "center" ? 8 - w / 2 : props.align === "right" ? 14 - w : 2;
          cx.beginPath();
          cx.moveTo(x, 4 + i * 4);
          cx.lineTo(x + w, 4 + i * 4);
          cx.stroke();
        });
      }}
    />
  );
}

function App() {
  const on = signal<Mark[]>(["bold"]);
  const align = signal<Align>("left");
  const starred = signal(false);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 3));
  const has = (m: Mark) => on().includes(m);
  // A toolbar toggle: bare while off, raised while pressed (`pressedStyle`).
  const look = (pressed: boolean) => ({
    style: { width: desktop ? 28 : 34, height: desktop ? 28 : 34, borderRadius: 7, alignItems: "center", justifyContent: "center" } as const,
    class: "transition-colors active:opacity-70 motion-reduce:transition-none",
    pressedStyle: ui().raised,
    hoverStyle: pressed ? undefined : hoverFill(),
    focusStyle: focusRing(),
  });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>toggle</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click a toggle, or Tab to the toolbar and move with the arrow keys" : "Tap a toggle to turn it on or off"}
      </Text>

      <Split gap={14} inspector={<Log title={`onChange · ${on().join(", ") || "none"}, ${align()}`} lines={events()} minHeight={76} />}>
        {/* One toolbar: the marks any number at once, the alignment exactly one. */}
        <ToggleGroup style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 2, padding: 3, borderRadius: 10, ...ui().inset }}>
          <Each of={marks}>
            {(m) => (
              <Toggle
                pressed={has(m().id)}
                accessibilityLabel={m().label}
                onChange={({ pressed }) => {
                  on.set(pressed ? [...on(), m().id] : on().filter((x) => x !== m().id));
                  log(`${m().label}: ${pressed}`);
                }}
                {...look(has(m().id))}
              >
                <Text
                  style={{
                    fontSize: 14,
                    color: has(m().id) ? ui().text : ui().muted,
                    fontWeight: m().id === "bold" ? "800" : "500",
                    fontStyle: m().id === "italic" ? "italic" : "normal",
                    textDecorationLine: m().id === "underline" ? "underline" : "none",
                  }}
                >
                  {m().glyph}
                </Text>
              </Toggle>
            )}
          </Each>
          <View style={{ width: 1, height: 16, marginHorizontal: 4, backgroundColor: ui().line }} />
          <Each of={aligns}>
            {(a) => (
              <Toggle
                pressed={align() === a().id}
                accessibilityLabel={a().label}
                onChange={({ pressed }) => {
                  // Exactly one: the pressed alignment stays pressed.
                  if (pressed) align.set(a().id);
                  log(`${a().label}: ${pressed}`);
                }}
                {...look(align() === a().id)}
              >
                <Lines align={a().id} color={align() === a().id ? ui().text : ui().muted} />
              </Toggle>
            )}
          </Each>
        </ToggleGroup>

        {/* The text the toolbar formats: the app reads its own values. */}
        <View style={{ ...ui().card, borderRadius: 14, padding: 14 }}>
          <Text
            style={{
              fontSize: 15,
              lineHeight: 22,
              color: ui().text,
              fontWeight: has("bold") ? "700" : "400",
              fontStyle: has("italic") ? "italic" : "normal",
              textDecorationLine: has("underline") ? "underline" : "none",
              textAlign: align(),
            }}
          >
            Every toggle reports the value it asks for; the app passes it back.
          </Text>
        </View>

        {/* A toggle on its own, with a label: `aria-pressed:` tints it. */}
        <Toggle
          pressed={starred()}
          onChange={({ pressed }) => {
            starred.set(pressed);
            log(`Star: ${pressed}`);
          }}
          class="transition-colors active:opacity-70 motion-reduce:transition-none"
          style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 6, height: desktop ? 28 : 34, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: ui().line }}
          pressedStyle={{ backgroundColor: tint(ui().accent, 0.16), borderColor: tint(ui().accent, 0.4) }}
          hoverStyle={starred() ? undefined : hoverFill()}
          focusStyle={focusRing()}
        >
          <Text style={{ fontSize: 14, color: starred() ? ui().accent : ui().muted }}>{starred() ? "★" : "☆"}</Text>
          <Text style={{ fontSize: 13, fontWeight: "500", color: starred() ? ui().accent : ui().text }}>{starred() ? "Starred" : "Star"}</Text>
        </Toggle>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{theme, toggle, toggle_group};
use nsh_core::prelude::*;

fn formatting() -> impl IntoElement {
    let bold = signal(true);
    let italic = signal(false);
    let item = move |label: &'static str, on: Signal<bool>| {
        toggle()
            .pressed(on)
            .accessibility_label(label)
            .on_change(move |pressed: &bool| on.set(*pressed))
            .size_8()
            .items_center()
            .justify_center()
            .rounded(move || theme().radius)
            .pressed_style(|s| s.bg(move || theme().accent))
            .child(text(&label[..1]).text_sm().text_color(move || {
                if on.get() { theme().accent_foreground } else { theme().muted_foreground }
            }))
    };
    toggle_group()
        .flex_row()
        .self_start()
        .gap_1()
        .child(item("Bold", bold))
        .child(item("Italic", italic))
}

Toggle 和 ToggleGroup 來自 nsh/base,即 Rust 中的 nsh-base crate。數值由應用程式掌管:每個切換按鈕都會被告知是否處於按下狀態,按下時以 onChange 回報它所要求的數值,而切換按鈕會顯示應用程式回傳的 pressed。群組同樣不持有任何數值,所以可同時按下多少個切換按鈕也由應用程式決定:可以是任意數量(粗體、斜體和底線),或剛好一個(對齊方式、檢視切換器),後者由應用程式按下被要求的切換按鈕、放開其他按鈕,並讓已按下的那個保持按下。在 Rust 中,toggle() 和 toggle_group() 用來建立它們,每個屬性有一個 setter(.pressed(..)),每個事件有一個監聽器(.on_change(..)),另有具型別的樣式方法,以及用於內容的 .child(..)。

#屬性

#Toggle

屬性 Rust 類型 預設值 說明
pressed .pressed(..) boolean false 開或關:螢幕閱讀器會讀出已按下,並套用 aria-pressed: 樣式。按下時以 onChange 回報相反的數值;切換按鈕顯示應用程式回傳的數值。
disabled .disabled(..) boolean false 不接受按下,也沒有按下回饋;套用 disabled: 樣式,螢幕閱讀器會讀出已變暗。位於停用的 ToggleGroup 內的切換按鈕同樣會被停用。
pressedStyle .pressed_style(|s| ..) StyleProp<ViewStyle> 無 切換按鈕在已按下且未停用時的樣式,以 aria-pressed: 類別表示。
accessibilityLabel .accessibility_label(..) string 無 螢幕閱讀器讀出的內容:當子元素沒有可說明用途的文字(例如圖示)時,說明此切換按鈕開啟的是什麼。
hitSlop .hit_slop(..) number | Partial<Edges> 無 在邊界以外多遠的按下仍會被計算,單位為點:所有邊使用同一個數字,或分別指定各邊。與 View 的 hitSlop 相同。
children .child(..) JSX.Element 無 應用程式的圖示或標籤,螢幕閱讀器會讀出它。
class、style 樣式方法 string、StyleProp<ViewStyle> 無 切換按鈕本身:內容周圍的表面。
activeStyle、hoverStyle、focusStyle、disabledStyle .active(|s| ..)、.hover(..)、.focus(..)、.disabled_style(..) StyleProp<ViewStyle> 無 切換按鈕在按下進行中、懸停、獲得焦點或停用時的樣式,以 <state>: 類別表示。

#ToggleGroup

屬性 Rust 類型 預設值 說明
disabled .disabled(..) boolean false 群組內的每個切換按鈕都會被停用,不論其自身的 disabled 為何。
children .child(..) JSX.Element 無 群組的切換按鈕,以及應用程式在它們之間排版的任何內容(例如分隔線)。
class、style 樣式方法 string、StyleProp<ViewStyle> 無 群組的版面配置和外觀:預設為直向排列,使用 flex-row 則為橫向排列。

#事件

事件 Rust 說明
onChange(Toggle) .on_change(|pressed: &bool| ..) 切換按鈕被按下:點按、點擊、在它擁有鍵盤焦點時按 Space 或 Return,或螢幕閱讀器的啟動操作。{ pressed } 是它所要求的數值,即 pressed 的相反值。它在任何改變發生之前執行:切換按鈕顯示應用程式回傳的 pressed。

ToggleGroup 內的方向鍵只會移動鍵盤焦點,不會按下任何按鈕。停用的切換按鈕不會回報任何事件。

#部件

部件 位於 樣式設定 狀態
群組 ToggleGroup 的 class、style
切換按鈕 群組內,或任何位置 Toggle 的 class、style aria-pressed:、disabled:、active:、hover:、focus:(或 pressedStyle、disabledStyle 等)
圖示或標籤 切換按鈕 其自身的元素(children) group-aria-pressed:、group-disabled: 及其他 group-*: 變體,配合切換按鈕 class 上的 group

aria-pressed: 表示切換按鈕處於開啟;active: 表示按下正在進行,不論是開或關。請透過切換按鈕自身的表面來顯示已按下的狀態:填色、凸起的膠囊形狀、更強烈的圖示(參閱設計指南)。

#分段式

切換按鈕連在一起、一次只按下一個的群組,就是分段控制(檢視切換器):應用程式保存一個數值,並按下與它對應的切換按鈕。連接起來的外觀由應用程式的類別決定:

<ToggleGroup class="flex-row self-start">
  <Each of={["grid", "list"]}>
    {(value) => (
      <Toggle
        pressed={view() === value()}
        accessibilityLabel={`${value()} view`}
        onChange={({ pressed }) => pressed && view.set(value())}
        class="-ml-px h-8 px-3 border border-border first:ml-0 first:rounded-l-(--radius) last:rounded-r-(--radius) aria-pressed:bg-accent"
      >
        <Text class="text-sm">{value()}</Text>
      </Toggle>
    )}
  </Each>
</ToggleGroup>

#停用

停用的切換按鈕不接受按下,螢幕閱讀器會讀出它已變暗。它的 disabled: 類別會覆蓋 aria-pressed: 類別,其內部的 group-disabled: 類別也會跟隨。在 ToggleGroup 上使用 disabled 會停用其中每個切換按鈕,也就是由應用程式鎖定的工具列:

<ToggleGroup disabled={readonly()} class="flex-row gap-1">
  <Toggle pressed={bold()} onChange={({ pressed }) => bold.set(pressed)} class="size-8 disabled:opacity-50">
    <Text>B</Text>
  </Toggle>
</ToggleGroup>

#使用 hitSlop 的小型目標

圖示切換按鈕可能比手指還小。hitSlop 讓落在它附近的按下也被計算,每邊向外延伸若干點,而不會改變其版面配置或外觀:所有邊使用同一個數字,或分別指定各邊({ top: 8, bottom: 8 }),與 View 的 hitSlop接受的格式相同。

<Toggle
  pressed={favorite()}
  hitSlop={12}
  accessibilityLabel="Favorite"
  onChange={({ pressed }) => favorite.set(pressed)}
  class="size-5 items-center justify-center"
>
  <Text>★</Text>
</Toggle>

#無障礙

  • 對螢幕閱讀器而言,切換按鈕是一個元素,角色為 "button" 並帶有其按下狀態:即切換按鈕。它的標籤是其子元素的文字,或 accessibilityLabel。
  • ToggleGroup 的角色為 "toolbar":螢幕閱讀器會將其內的切換按鈕讀作一個工具列,而每個切換按鈕仍然是各自獨立的元素。
  • disabled 會按平台自身的方式讀出(VoiceOver、TalkBack、UI Automation、瀏覽器的無障礙樹)。
  • 在 macOS 和 web 上,群組是鍵盤焦點的一個停駐點,方向鍵在其切換按鈕之間移動(見下文)。

有關標籤、角色和朗讀通告的一般說明,請參閱無障礙。

#鍵盤

在 macOS 和 web 上,Tab 可到達接受按下的 View,此時 ToggleGroup 遵循 WAI-ARIA 工具列模式:

按鍵 作用
Tab、Shift+Tab 從第一個已按下的切換按鈕進入群組(若沒有已按下的,則從第一個未停用的切換按鈕進入),並一步離開群組。
Right、Down 將焦點移到下一個切換按鈕,從最後一個移到第一個。
Left、Up 將焦點移到上一個切換按鈕,從第一個移到最後一個。
Home、End 將焦點移到第一個或最後一個切換按鈕。
Space、Return 按下目前擁有焦點的切換按鈕。

方向鍵會略過停用的切換按鈕,也不會按下任何按鈕:切換按鈕只有在被按下時才會改變。無論群組的版面配置為何(這由應用程式的類別決定),兩組方向鍵都會移動焦點。在由右至左的版面配置中,Left 和 Right 互換作用:Left 移到下一個切換按鈕,Right 移到上一個;Up、Down、Home 和 End 則保持不變。focus: 樣式會顯示焦點所在位置。位於 ToggleGroup 以外的 Toggle 自成一個停駐點。

#平台備註

  • iOS、Android 和 Mac Catalyst:按下是指在切換按鈕(包括其內容)上結束的一次點按。
  • 桌面平台(macOS、Windows、Linux、GPUI)和 web:按下是指一次點擊。在 macOS 和 web 上,Space 和 Return 同樣會按下擁有鍵盤焦點的切換按鈕,而方向鍵、Home 和 End 則用來移動焦點(鍵盤)。
  • 每個平台都會繪製應用程式所設定的樣式:切換按鈕、已按下按鈕的表面,以及群組的軌道。