Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

Toggle

Un toggle è un pulsante che resta attivo o disattivo: il grassetto in una barra degli strumenti di testo, una stella dei preferiti, un’opzione di visualizzazione. Toggle gestisce il comportamento di un toggle: pressioni, attivazione da tastiera, stato disabilitato, e il ruolo di pulsante con il suo stato di pressione per gli screen reader, che lo annunciano come pulsante toggle. Un ToggleGroup attorno a più toggle è una barra degli strumenti: gli screen reader la annunciano come tale, e la tastiera la raggiunge in un’unica sosta e si sposta tra i suoi toggle con i tasti freccia. L’aspetto è dell’app: un toggle non ha colori, dimensioni, icona o etichetta propri. I suoi figli sono l’icona o l’etichetta dell’app, e le classi aria-pressed: sul toggle, o le classi group-aria-pressed: su ciò che contiene, ne seguono lo stato.

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 provengono da nsh/base, il crate nsh-base in Rust. Il valore è dell’app: a ogni toggle viene comunicato se è premuto, una pressione riporta il valore che richiede con onChange, e il toggle mostra il pressed che l’app restituisce. Nemmeno il gruppo conserva un valore, quindi anche quanti toggle possano essere premuti è deciso dall’app: un numero qualsiasi (grassetto, corsivo e sottolineato), oppure esattamente uno (un allineamento, un selettore di vista), dove l’app preme il toggle richiesto, rilascia gli altri e mantiene premuto quello già premuto. In Rust, toggle() e toggle_group() li creano, con un setter per ogni prop (.pressed(..)), un listener per ogni evento (.on_change(..)), i metodi di stile tipizzati e .child(..) per il loro contenuto.

#Props

#Toggle

PropRustTipoDefaultDescrizione
pressed.pressed(..)booleanfalseAttivo o disattivo: gli screen reader dicono che è premuto, e si applicano gli stili aria-pressed:. Una pressione riporta l’altro valore con onChange; il toggle mostra il valore che l’app restituisce.
disabled.disabled(..)booleanfalseNessuna pressione e nessun feedback di pressione; si applicano gli stili disabled:, e gli screen reader dicono che è disattivato. Anche un toggle in un ToggleGroup disabilitato è disabilitato.
pressedStyle.pressed_style(|s| ..)StyleProp<ViewStyle>nessunoLo stile del toggle mentre è premuto e non disabilitato, come classi aria-pressed:.
accessibilityLabel.accessibility_label(..)stringnessunoCiò che dicono gli screen reader: che cosa attiva il toggle, quando i suoi figli non hanno un testo che lo dica (un’icona).
hitSlop.hit_slop(..)number | Partial<Edges>nessunoQuanto all’esterno dei suoi limiti le pressioni vengono ancora conteggiate, in punti: un numero per tutti i lati, oppure i lati indicati per nome. Come hitSlop di una vista.
children.child(..)JSX.ElementnessunoL’icona o l’etichetta dell’app, che gli screen reader leggono.
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoIl toggle stesso: la superficie attorno al suo contenuto.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>nessunoLo stile del toggle mentre una pressione è in corso, con il mouse sopra, con il focus o disabilitato, come le classi <state>:.

#ToggleGroup

PropRustTipoDefaultDescrizione
disabled.disabled(..)booleanfalseOgni toggle del gruppo è disabilitato, indipendentemente dal proprio disabled.
children.child(..)JSX.ElementnessunoI toggle del gruppo, e qualsiasi cosa l’app disponga tra di essi (un separatore).
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoIl layout e l’aspetto del gruppo: una colonna per impostazione predefinita, flex-row per una riga.

#Eventi

EventoRustDescrizione
onChange (Toggle).on_change(|pressed: &bool| ..)Il toggle è stato premuto: un tocco, un clic, Spazio o Invio mentre ha il focus della tastiera, o l’attivazione da parte di uno screen reader. { pressed } è il valore che richiede, l’opposto di pressed. Viene eseguito prima che cambi qualsiasi cosa: il toggle mostra il pressed che l’app restituisce.

Un tasto freccia di un ToggleGroup sposta soltanto il focus della tastiera: non preme nulla. Un toggle disabilitato non riporta nulla.

#Parti

ParteDentroStile tramiteStati
Il gruppoToggleGroup: class, style
Il toggleil gruppo, o ovunqueToggle: class, stylearia-pressed:, disabled:, active:, hover:, focus: (o pressedStyle, disabledStyle, …)
L’icona o l’etichettail togglei propri elementi (children)group-aria-pressed:, group-disabled: e le altre varianti group-*: con group sul class del toggle

aria-pressed: indica che il toggle è attivo; active: indica una pressione in corso, da attivo o da disattivo. Mostra un toggle premuto attraverso la sua superficie: un riempimento, una pillola in rilievo, un’icona più marcata (vedi Linee guida di design).

#Segmented

Un gruppo i cui toggle sono uniti, con uno solo premuto alla volta, è un controllo segmentato (un selettore di vista): l’app conserva un solo valore e preme il toggle che lo nomina. L’aspetto unito è dato dalle classi dell’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>

#Disabilitato

Un toggle disabilitato non accetta pressioni, e gli screen reader lo annunciano come disattivato. Le sue classi disabled: si applicano sopra quelle aria-pressed:, e le classi group-disabled: al suo interno le seguono. disabled su un ToggleGroup disabilita ogni toggle al suo interno, una barra degli strumenti bloccata dall’app:

<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 bersaglio piccolo con hitSlop

Un toggle con icona può essere più piccolo di un dito. hitSlop fa sì che le pressioni che cadono vicino a esso vengano conteggiate, in punti oltre ciascun lato, senza cambiarne il layout o l’aspetto: un numero per tutti i lati, oppure i lati indicati per nome ({ top: 8, bottom: 8 }), come li accetta il hitSlop di una vista.

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

#Accessibilità

  • Un toggle è un unico elemento per gli screen reader, con il ruolo "button" e il suo stato di pressione: un pulsante toggle. La sua etichetta è il testo dei suoi figli, oppure accessibilityLabel.
  • ToggleGroup ha il ruolo "toolbar": gli screen reader sentono i toggle al suo interno come un’unica barra degli strumenti, ciascuno comunque un elemento a sé.
  • disabled viene annunciato come lo annuncia la piattaforma (VoiceOver, TalkBack, UI Automation, l’albero di accessibilità del browser).
  • Su macOS e sul web il gruppo è una sola sosta del focus della tastiera, e i tasti freccia si spostano tra i suoi toggle (vedi sotto).

Vedi Accessibilità per etichette, ruoli e annunci in generale.

#Tastiera

Su macOS e sul web, dove Tab raggiunge le viste che accettano pressioni, un ToggleGroup segue il pattern toolbar di WAI-ARIA:

TastoCosa fa
Tab, Shift+TabEntra nel gruppo sul primo toggle premuto (il primo toggle non disabilitato se nessuno è premuto), e lo lascia in un solo passaggio.
Freccia destra, Freccia giùSposta il focus sul toggle successivo, dall’ultimo al primo.
Freccia sinistra, Freccia suSposta il focus sul toggle precedente, dal primo all’ultimo.
Home, EndSposta il focus sul primo o sull’ultimo toggle.
Spazio, InvioPreme il toggle con il focus.

I tasti freccia saltano i toggle disabilitati e non premono nulla: un toggle cambia solo quando viene premuto. Entrambe le coppie di frecce spostano il focus, qualunque sia il layout del gruppo, che è una classe dell’app. In un layout da destra a sinistra, Freccia sinistra e Freccia destra si scambiano: Freccia sinistra passa al toggle successivo e Freccia destra al precedente; Freccia su, Freccia giù, Home ed End restano invariati. Gli stili focus: mostrano dove si trova il focus. Un Toggle fuori da un ToggleGroup è una sosta a sé.

#Note sulle piattaforme

  • iOS, Android e Mac Catalyst: una pressione è un tocco che termina sul toggle, contenuto incluso.
  • Desktop (macOS, Windows, Linux, GPUI) e web: una pressione è un clic. Su macOS e sul web, anche Spazio e Invio premono il toggle che ha il focus della tastiera, e i tasti freccia, Home ed End lo spostano (Tastiera).
  • Ogni piattaforma disegna ciò che l’app stila: i toggle, la superficie di quelli premuti e la traccia del gruppo.