Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Elementos

Toggle

Un toggle es un botón que permanece activado o desactivado: la negrita en una barra de herramientas de texto, una estrella de favoritos, una opción de vista. Toggle se encarga del comportamiento de uno: pulsaciones, activación con el teclado, el estado deshabilitado y el rol de botón con su estado de pulsado para los lectores de pantalla, que lo anuncian como un botón de alternancia. Un ToggleGroup alrededor de varios es una barra de herramientas: los lectores de pantalla la anuncian como tal, y el teclado llega a ella en una sola parada y se mueve entre sus toggles con las teclas de flecha. El aspecto es el de la app: un toggle no tiene colores, tamaños, icono ni etiqueta propios. Sus hijos son el icono o la etiqueta de la app, y las clases aria-pressed: en el toggle, o las clases group-aria-pressed: en lo que contiene, siguen su 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 y ToggleGroup provienen de nsh/base, el crate nsh-base en Rust. El valor pertenece a la app: a cada toggle se le dice si está pulsado, una pulsación informa del valor que solicita con onChange, y el toggle muestra el pressed que la app le devuelve. El grupo tampoco guarda ningún valor, así que cuántos toggles pueden estar pulsados también depende de la app: cualquier cantidad (negrita, cursiva y subrayado), o exactamente uno (una alineación, un selector de vistas), donde la app pulsa el toggle solicitado, suelta los demás y mantiene pulsado el que ya lo estaba. En Rust, toggle() y toggle_group() los crean, con un setter por prop (.pressed(..)), un listener por evento (.on_change(..)), los métodos de estilo tipados y .child(..) para su contenido.

#Props

#Toggle

Prop Rust Tipo Valor por defecto Descripción
pressed .pressed(..) boolean false Activado o desactivado: los lectores de pantalla indican que está pulsado, y se aplican los estilos aria-pressed:. Una pulsación informa del otro valor con onChange; el toggle muestra el valor que la app le devuelve.
disabled .disabled(..) boolean false Sin pulsaciones ni respuesta visual al pulsar; se aplican los estilos disabled:, y los lectores de pantalla indican que está atenuado. Un toggle dentro de un ToggleGroup deshabilitado también queda deshabilitado.
pressedStyle .pressed_style(|s| ..) StyleProp<ViewStyle> ninguno El estilo del toggle mientras está pulsado y no deshabilitado, como clases aria-pressed:.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla: lo que activa el toggle, cuando sus hijos no tienen texto que lo diga (un icono).
hitSlop .hit_slop(..) number | Partial<Edges> ninguno Hasta qué distancia fuera de sus límites siguen contando las pulsaciones, en puntos: un número para todos los lados, o los lados por nombre. Como el hitSlop de una vista.
children .child(..) JSX.Element ninguno El icono o la etiqueta de la app, que los lectores de pantalla leen.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El propio toggle: la superficie que rodea su contenido.
activeStyle, hoverStyle, focusStyle, disabledStyle .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) StyleProp<ViewStyle> ninguno El estilo del toggle mientras hay una pulsación en curso, con el cursor encima, con el foco o deshabilitado, como las clases <state>:.

#ToggleGroup

Prop Rust Tipo Valor por defecto Descripción
disabled .disabled(..) boolean false Todos los toggles del grupo quedan deshabilitados, sea cual sea su propio disabled.
children .child(..) JSX.Element ninguno Los toggles del grupo, y lo que la app disponga entre ellos (un separador).
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El diseño y el aspecto del grupo: una columna por defecto, flex-row para una fila.

#Eventos

Evento Rust Descripción
onChange (Toggle) .on_change(|pressed: &bool| ..) Se pulsó el toggle: un toque, un clic, Espacio o Return mientras tiene el foco del teclado, o la activación de un lector de pantalla. { pressed } es el valor que solicita, el opuesto de pressed. Se ejecuta antes de que cambie nada: el toggle muestra el pressed que la app le devuelve.

Una tecla de flecha de un ToggleGroup solo mueve el foco del teclado: no pulsa nada. Un toggle deshabilitado no informa de nada.

#Partes

Parte Dentro de Estilizado con Estados
El grupo ToggleGroup: class, style
El toggle el grupo, o cualquier lugar Toggle: class, style aria-pressed:, disabled:, active:, hover:, focus: (o pressedStyle, disabledStyle, …)
El icono o la etiqueta el toggle sus propios elementos (children) group-aria-pressed:, group-disabled: y las demás variantes group-*: con group en el class del toggle

aria-pressed: es el toggle activado; active: es una pulsación en curso, esté activado o desactivado. Muestra un toggle pulsado mediante su propia superficie: un relleno, una píldora elevada, un icono más marcado (consulta Guías de diseño).

#Segmentado

Un grupo cuyos toggles están unidos, con uno pulsado a la vez, es un control segmentado (un selector de vistas): la app mantiene un valor y pulsa el toggle que lo nombra. El aspecto unido son las clases de la 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>

#Deshabilitado

Un toggle deshabilitado no admite pulsaciones, y los lectores de pantalla lo anuncian como atenuado. Sus clases disabled: se aplican sobre las aria-pressed:, y las clases group-disabled: de su interior las siguen. disabled en un ToggleGroup deshabilita todos los toggles que contiene, una barra de herramientas que la app bloquea:

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

#Un objetivo pequeño con hitSlop

Un toggle de icono puede ser más pequeño que un dedo. hitSlop hace que cuenten las pulsaciones que caen cerca de él, en puntos más allá de cada lado, sin cambiar su diseño ni su aspecto: un número para todos los lados, o los lados por nombre ({ top: 8, bottom: 8 }), tal como los acepta el hitSlop de una vista.

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

#Accesibilidad

  • Un toggle es un único elemento para los lectores de pantalla, con el rol "button" y su estado de pulsado: un botón de alternancia. Su etiqueta es el texto de sus hijos, o accessibilityLabel.
  • ToggleGroup tiene el rol "toolbar": los lectores de pantalla oyen los toggles que contiene como una sola barra de herramientas, cada uno aún como un elemento propio.
  • disabled se anuncia como lo anuncia la plataforma (VoiceOver, TalkBack, UI Automation, el árbol de accesibilidad del navegador).
  • En macOS y la web el grupo es una sola parada del foco del teclado, y las teclas de flecha se mueven entre sus toggles (más abajo).

Consulta Accesibilidad para etiquetas, roles y anuncios en general.

#Teclado

En macOS y la web, donde Tab llega a las vistas que admiten pulsaciones, un ToggleGroup sigue el patrón de barra de herramientas de WAI-ARIA:

Tecla Qué hace
Tab, Shift+Tab Entra en el grupo por el primer toggle pulsado (el primer toggle no deshabilitado si no hay ninguno pulsado), y sale de él en un solo paso.
Derecha, Abajo Mueve el foco al siguiente toggle, del último al primero.
Izquierda, Arriba Mueve el foco al toggle anterior, del primero al último.
Inicio, Fin Mueve el foco al primer o al último toggle.
Espacio, Return Pulsa el toggle con el foco.

Las teclas de flecha omiten los toggles deshabilitados y no pulsan nada: un toggle solo cambia cuando se pulsa. Los dos pares de flechas mueven el foco, sea cual sea el diseño del grupo, que es una clase de la app. En un diseño de derecha a izquierda, Izquierda y Derecha se intercambian: Izquierda pasa al siguiente toggle y Derecha al anterior; Arriba, Abajo, Inicio y Fin mantienen las suyas. Los estilos focus: muestran dónde está el foco. Un Toggle fuera de un ToggleGroup es una parada propia.

#Notas de plataforma

  • iOS, Android y Mac Catalyst: una pulsación es un toque que termina en el toggle, incluido su contenido.
  • Escritorio (macOS, Windows, Linux, GPUI) y la web: una pulsación es un clic. En macOS y la web, Espacio y Return también pulsan el toggle que tiene el foco del teclado, y las flechas, Inicio y Fin lo mueven (Teclado).
  • Cada plataforma dibuja lo que la app estiliza: los toggles, la superficie de los pulsados y la pista del grupo.