このページは機械翻訳のため、誤りを含む場合があります。 英語の原文を読む

要素

Toggle

Toggle は、オンまたはオフの状態を保つボタンです。テキストツールバーの太字、お気に入りの星、表示オプションなどに使います。Toggle は、押下、キーボードによる操作、無効状態、そしてスクリーンリーダーがトグルボタンとして読み上げるための、押下状態を持つボタンロールといった、トグルの振る舞いを受け持ちます。複数のトグルを ToggleGroup で囲むとツールバーになります。スクリーンリーダーはそれを 1 つのツールバーとして読み上げ、キーボードでは 1 回のフォーカスで到達でき、矢印キーでトグル間を移動できます。見た目はアプリ側が決めます。トグル自体には、色、サイズ、アイコン、ラベルはありません。子要素はアプリ側のアイコンやラベルで、トグルに付けた 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 は、Rust の nsh-base クレートである nsh/base に含まれています。値はアプリ側が持ちます。各トグルには押下状態が渡され、押下すると、トグルが求める値が onChange で通知され、トグルはアプリが返した pressed を表示します。グループも値を持たないため、同時に押下状態にできるトグルの数もアプリ側で決まります。任意の数(太字、斜体、下線)にすることも、ちょうど 1 つ(配置、ビューの切り替え)にすることもできます。後者では、アプリが求められたトグルを押下状態にし、ほかのトグルを解除し、押下状態のトグルは押下状態のままにします。Rust では、toggle() と toggle_group() でこれらを作成します。プロパティごとのセッター(.pressed(..))、イベントごとのリスナー(.on_change(..))、型付きのスタイルメソッド、そして内容を指定する .child(..) が用意されています。

#プロパティ

#Toggle

プロパティRust型デフォルト説明
pressed.pressed(..)booleanfalseオンまたはオフ。スクリーンリーダーは押下状態と読み上げ、aria-pressed: スタイルが適用されます。押下すると、反対の値が onChange で通知されます。トグルは、アプリが返した値を表示します。
disabled.disabled(..)booleanfalse押下を受け付けず、押下フィードバックもありません。disabled: スタイルが適用され、スクリーンリーダーは減光状態と読み上げます。無効な ToggleGroup の中のトグルも無効になります。
pressedStyle.pressed_style(|s| ..)StyleProp<ViewStyle>なし押下状態かつ無効でないときのトグルのスタイル。aria-pressed: クラスで指定します。
accessibilityLabel.accessibility_label(..)stringなしスクリーンリーダーが読み上げる内容。子要素に内容を表すテキストがない場合(アイコンなど)に、トグルが何をオンにするかを示します。
hitSlop.hit_slop(..)number | Partial<Edges>なし境界の外側で、どこまで押下として扱うか(単位はポイント)。全辺に対する 1 つの数値、または辺ごとの指定ができます。ビューの 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(..)booleanfalseグループ内のすべてのトグルを、それぞれの 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, stylearia-pressed:, disabled:, active:, hover:, focus:(または pressedStyle, disabledStyle, …)
アイコンまたはラベルトグルの中それ自身の要素(children)トグルの class に group を付けた group-aria-pressed:, group-disabled: などの group-*: バリアント

aria-pressed: はトグルがオンの状態、active: はオンかオフかを問わず押下が進行中の状態です。押下状態のトグルは、塗りつぶし、浮き上がったピル、強調したアイコンなど、トグル自身のサーフェスで示してください(デザインガイドを参照)。

#Segmented

トグルを連結し、一度に 1 つだけを押下状態にするグループは、セグメントコントロール(ビューの切り替え)です。アプリが 1 つの値を保持し、その値に対応するトグルを押下状態にします。連結した見た目は、アプリのクラスで作ります。

<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

無効なトグルは押下を受け付けず、スクリーンリーダーは減光状態と読み上げます。その 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 を使うと、レイアウトや見た目を変えずに、トグルの近くで行われた押下を有効にできます。単位は各辺の外側へのポイントで、全辺に対する 1 つの数値、または辺ごとの指定({ top: 8, bottom: 8 })ができます。これはビューの hitSlopと同じ指定方法です。

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

#アクセシビリティ

  • トグルは、スクリーンリーダーにとって 1 つの要素で、ロールは "button" で、押下状態を持ちます。つまりトグルボタンです。ラベルは、子要素のテキスト、または accessibilityLabel です。
  • ToggleGroup のロールは "toolbar" です。スクリーンリーダーは、内側のトグルを 1 つのツールバーとして読み上げ、各トグルはそれぞれ独立した要素のままです。
  • disabled は、各プラットフォームの方式で読み上げられます(VoiceOver、TalkBack、UI Automation、ブラウザーのアクセシビリティツリー)。
  • macOS と web では、グループはキーボードフォーカスの 1 つの停止位置になり、矢印キーでトグル間を移動します(下記参照)。

ラベル、ロール、読み上げ全般については、アクセシビリティを参照してください。

#キーボード

macOS と web では、Tab で押下を受け付けるビューに到達でき、ToggleGroup は WAI-ARIA toolbar pattern に従います。

キー動作
Tab, Shift+Tab押下状態の最初のトグルでグループに入ります(押下状態のものがなければ、無効でない最初のトグル)。1 回の操作でグループから出ます。
Right, Downフォーカスを次のトグルに移動します。最後のトグルからは最初に戻ります。
Left, Upフォーカスを前のトグルに移動します。最初のトグルからは最後に移動します。
Home, Endフォーカスを最初または最後のトグルに移動します。
Space, Returnフォーカス中のトグルを押下します。

矢印キーは無効なトグルを飛ばし、何も押下しません。トグルが変わるのは、押下されたときだけです。グループのレイアウトはアプリのクラスで決まりますが、それに関係なく、2 組の矢印キーのどちらでも移動できます。右から左へ進むレイアウトでは、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 でフォーカスを移動できます(キーボード)。
  • どのプラットフォームでも、アプリがスタイル指定したものが描画されます。トグル、押下状態のトグルのサーフェス、グループのトラックが該当します。