Esta página foi traduzida automaticamente e pode conter erros. Ler o original em inglês

Elementos

Toggle

Um toggle é um botão que fica ligado ou desligado: negrito em uma barra de ferramentas de texto, uma estrela de favorito, uma opção de visualização. Toggle cuida do comportamento de um toggle: pressionamentos, ativação pelo teclado, o estado desabilitado e o papel de botão com seu estado pressionado para leitores de tela, que o anunciam como botão de alternância. Um ToggleGroup em volta de vários toggles é uma barra de ferramentas: leitores de tela a anunciam como tal, e o teclado a alcança em uma única parada e se move entre seus toggles com as setas. A aparência é do app: um toggle não tem cores, tamanhos, ícone ou rótulo próprios. Seus filhos são o ícone ou o rótulo do app, e as classes aria-pressed: no toggle, ou as classes group-aria-pressed: no que está dentro dele, acompanham seu estado.

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 e ToggleGroup vêm de nsh/base, o crate nsh-base em Rust. O valor é do app: cada toggle recebe a informação de se está pressionado, um pressionamento informa o valor que pede com onChange, e o toggle mostra o pressed que o app devolve. O grupo também não guarda valor, então quantos toggles podem ficar pressionados também é decisão do app: qualquer quantidade (negrito, itálico e sublinhado) ou exatamente um (um alinhamento, um alternador de visualização), caso em que o app pressiona o toggle solicitado, solta os outros e mantém o pressionado pressionado. Em Rust, toggle() e toggle_group() os criam, com um setter por prop (.pressed(..)), um listener por evento (.on_change(..)), os métodos de estilo tipados e .child(..) para o conteúdo.

#Props

#Toggle

Prop Rust Tipo Padrão Descrição
pressed .pressed(..) boolean false Ligado ou desligado: leitores de tela dizem que está pressionado, e os estilos aria-pressed: se aplicam. Um pressionamento informa o outro valor com onChange; o toggle mostra o valor que o app devolve.
disabled .disabled(..) boolean false Sem pressionamentos e sem feedback de pressão; os estilos disabled: se aplicam, e leitores de tela dizem que está esmaecido. Um toggle em um ToggleGroup desabilitado também fica desabilitado.
pressedStyle .pressed_style(|s| ..) StyleProp<ViewStyle> nenhum O estilo do toggle enquanto está pressionado e não desabilitado, como classes aria-pressed:.
accessibilityLabel .accessibility_label(..) string nenhum O que leitores de tela dizem: o que o toggle liga, quando seus filhos não têm texto que diga isso (um ícone).
hitSlop .hit_slop(..) number | Partial<Edges> nenhum Até onde, fora de seus limites, os pressionamentos ainda contam, em pontos: um número para todos os lados, ou os lados nomeados. Como o hitSlop de uma view.
children .child(..) JSX.Element nenhum O ícone ou rótulo do app, que os leitores de tela leem.
class, style métodos de estilo string, StyleProp<ViewStyle> nenhum O próprio toggle: a superfície em volta do conteúdo.
activeStyle, hoverStyle, focusStyle, disabledStyle .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) StyleProp<ViewStyle> nenhum O estilo do toggle enquanto um pressionamento está em andamento, com o ponteiro sobre ele, com foco ou desabilitado, como as classes <state>:.

#ToggleGroup

Prop Rust Tipo Padrão Descrição
disabled .disabled(..) boolean false Todos os toggles do grupo ficam desabilitados, independentemente de seu próprio disabled.
children .child(..) JSX.Element nenhum Os toggles do grupo e o que o app dispuser entre eles (um separador).
class, style métodos de estilo string, StyleProp<ViewStyle> nenhum O layout e a aparência do grupo: uma coluna por padrão, flex-row para uma linha.

#Eventos

Evento Rust Descrição
onChange (Toggle) .on_change(|pressed: &bool| ..) O toggle foi pressionado: um toque, um clique, Espaço ou Return enquanto ele tem o foco do teclado, ou a ativação por um leitor de tela. { pressed } é o valor que ele pede, o oposto de pressed. Roda antes de qualquer mudança: o toggle mostra o pressed que o app devolve.

Uma tecla de seta de um ToggleGroup apenas move o foco do teclado: não pressiona nada. Um toggle desabilitado não informa nada.

#Partes

Parte Dentro de Estilizada com Estados
O grupo class, style de ToggleGroup
O toggle o grupo, ou qualquer lugar class, style de Toggle aria-pressed:, disabled:, active:, hover:, focus: (ou pressedStyle, disabledStyle, …)
O ícone ou rótulo o toggle seus próprios elementos (children) group-aria-pressed:, group-disabled: e as outras variantes group-*: com group no class do toggle

aria-pressed: é o toggle estar ligado; active: é um pressionamento em andamento, ligado ou desligado. Mostre um toggle pressionado pela própria superfície dele: um preenchimento, uma pílula elevada, um ícone mais forte (veja Guias de design).

#Segmentado

Um grupo cujos toggles são unidos, com um pressionado por vez, é um controle segmentado (um alternador de visualização): o app mantém um valor e pressiona o toggle que o nomeia. A aparência unida são as classes do app:

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

#Desabilitado

Um toggle desabilitado não aceita pressionamentos, e leitores de tela o anunciam como esmaecido. Suas classes disabled: se aplicam sobre as aria-pressed:, e as classes group-disabled: dentro dele as acompanham. disabled em um ToggleGroup desabilita todos os toggles dentro dele, uma barra de ferramentas que o app trava:

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

#Um alvo pequeno com hitSlop

Um toggle de ícone pode ser menor que um dedo. hitSlop faz com que pressionamentos que caem perto dele contem, em pontos além de cada lado, sem mudar seu layout nem sua aparência: um número para todos os lados, ou os lados nomeados ({ top: 8, bottom: 8 }), como o hitSlop de uma view os recebe.

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

#Acessibilidade

  • Um toggle é um único elemento para leitores de tela, com o papel "button" e seu estado pressionado: um botão de alternância. Seu rótulo é o texto de seus filhos, ou accessibilityLabel.
  • ToggleGroup tem o papel "toolbar": leitores de tela ouvem os toggles dentro dele como uma única barra de ferramentas, cada um ainda como um elemento próprio.
  • disabled é anunciado como a plataforma o anuncia (VoiceOver, TalkBack, UI Automation, a árvore de acessibilidade do navegador).
  • No macOS e na web, o grupo é uma única parada do foco do teclado, e as setas se movem entre seus toggles (abaixo).

Veja Acessibilidade para rótulos, papéis e anúncios em geral.

#Teclado

No macOS e na web, onde Tab alcança as views que aceitam pressionamentos, um ToggleGroup segue o padrão de barra de ferramentas do WAI-ARIA:

Tecla O que faz
Tab, Shift+Tab Entra no grupo pelo primeiro toggle pressionado (se nenhum estiver pressionado, pelo primeiro toggle que não esteja desabilitado) e sai dele em um único passo.
Direita, Baixo Move o foco para o próximo toggle, do último para o primeiro.
Esquerda, Cima Move o foco para o toggle anterior, do primeiro para o último.
Home, End Move o foco para o primeiro ou para o último toggle.
Espaço, Return Pressiona o toggle com foco.

As setas ignoram toggles desabilitados e não pressionam nada: um toggle só muda quando é pressionado. Os dois pares de setas movem o foco, qualquer que seja o layout do grupo, que é uma classe do app. Em um layout da direita para a esquerda, Esquerda e Direita trocam de lugar: Esquerda move para o próximo toggle e Direita para o anterior; Cima, Baixo, Home e End mantêm o seu comportamento. Os estilos focus: mostram onde está o foco. Um Toggle fora de um ToggleGroup é uma parada própria.

#Notas de plataforma

  • iOS, Android e Mac Catalyst: um pressionamento é um toque que termina sobre o toggle, incluindo seu conteúdo.
  • Desktops (macOS, Windows, Linux, GPUI) e web: um pressionamento é um clique. No macOS e na web, Espaço e Return também pressionam o toggle que tem o foco do teclado, e as setas, Home e End o movem (Teclado).
  • Todas as plataformas desenham o que o app estiliza: os toggles, a superfície dos pressionados e a trilha do grupo.