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

元素

Toggle

toggle 是一個保持開啟或關閉的按鈕:文字工具列中的粗體、我的最愛星號、檢視選項。Toggle 負責它的行為:按下、鍵盤啟動、停用狀態,以及帶有按下狀態的按鈕角色,螢幕閱讀器會將其播報為切換按鈕。多個 toggle 外面的 ToggleGroup 就是工具列:螢幕閱讀器會將其播報為工具列,鍵盤只需一個停駐點即可到達,並以方向鍵在其 toggle 之間移動。外觀由應用程式決定:toggle 本身沒有顏色、尺寸、圖示或標籤。它的子元素是應用程式的圖示或標籤,toggle 上的 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。值屬於應用程式:每個 toggle 都會被告知自己是否被按下,按下時以 onChange 回報它所要求的值,而 toggle 會顯示應用程式回傳的 pressed。群組同樣不保存任何值,因此可以同時按下多少個 toggle 也由應用程式決定:可以是任意數量(粗體、斜體與底線),也可以恰好一個(對齊方式、檢視切換器),後者由應用程式按下被要求的 toggle,放開其他的,並讓已按下的那個保持按下。在 Rust 中,toggle() 與 toggle_group() 用來建立它們,每個屬性有一個 setter(.pressed(..)),每個事件有一個監聽器(.on_change(..)),並提供具型別的樣式方法,以及用於內容的 .child(..)。

#屬性

#Toggle

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

#ToggleGroup

屬性Rust型別預設值說明
disabled.disabled(..)booleanfalse群組中的每個 toggle 都會被停用,無論其自身的 disabled 為何。
children.child(..)JSX.Element無群組中的 toggle,以及應用程式在它們之間安排的任何內容(例如分隔線)。
class、style樣式方法string、StyleProp<ViewStyle>無群組的版面與外觀:預設為直欄,flex-row 則為橫列。

#事件

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

ToggleGroup 的方向鍵只會移動鍵盤焦點:不會按下任何東西。已停用的 toggle 不會回報任何事件。

#組成部分

部分位於樣式來源狀態
群組ToggleGroup 的 class、style
toggle群組內,或任何位置Toggle 的 class、stylearia-pressed:、disabled:、active:、hover:、focus:(或 pressedStyle、disabledStyle 等)
圖示或標籤toggle 內其自身的元素(children)group-aria-pressed:、group-disabled: 以及其他在 toggle 的 class 上帶有 group 的 group-*: 變體

aria-pressed: 表示 toggle 處於開啟;active: 表示按下正在進行,無論開啟或關閉。請透過 toggle 自身的表面來呈現已按下的狀態:填色、浮起的膠囊形狀、較強烈的圖示(請參閱 Design guides)。

#Segmented

toggle 彼此相連、一次只按下一個的群組,就是分段控制(檢視切換器):應用程式保存一個值,並按下與該值對應的 toggle。相連的外觀由應用程式的類別決定:

<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>

#停用

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

<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 放大小型目標

圖示 toggle 可能比手指還小。hitSlop 會讓落在它附近的按下也算數,單位為 point,向每一邊外延伸,而不改變它的版面或外觀:所有邊使用同一個數字,或分別指定各邊({ 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>

#無障礙

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

標籤、角色與播報的一般說明,請參閱 Accessibility。

#鍵盤

在 macOS 與網頁上,Tab 可到達接受按下的 view,此時 ToggleGroup 遵循 WAI-ARIA toolbar pattern:

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

方向鍵會略過已停用的 toggle,也不會按下任何東西:toggle 只有在被按下時才會改變。無論群組的版面為何(那是應用程式的類別),兩組方向鍵都會移動焦點。在由右至左的版面中,Left 與 Right 會互換:Left 移到下一個 toggle,Right 移到上一個;Up、Down、Home 與 End 則維持不變。focus: 樣式會顯示焦點所在位置。位於 ToggleGroup 之外的 Toggle 是自己的一個停駐點。

#平台說明

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