Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

Toggle

Un toggle est un bouton qui reste activé ou désactivé : le gras dans une barre d’outils de texte, une étoile de favori, une option d’affichage. Toggle gère son comportement : les pressions, l’activation au clavier, l’état désactivé, ainsi que le rôle de bouton avec son état pressé pour les lecteurs d’écran, qui l’annoncent comme un bouton à bascule. Un ToggleGroup autour de plusieurs toggles forme une barre d’outils : les lecteurs d’écran l’annoncent comme telle, et le clavier y accède en un seul arrêt puis se déplace entre ses toggles avec les touches fléchées. L’apparence est celle de l’app : un toggle n’a ni couleurs, ni tailles, ni icône, ni libellé propres. Ses enfants sont l’icône ou le libellé de l’app, et les classes aria-pressed: sur le toggle, ou les classes group-aria-pressed: sur son contenu, suivent son état.

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 et ToggleGroup viennent de nsh/base, le crate nsh-base en Rust. La valeur appartient à l’app : chaque toggle sait s’il est pressé, une pression signale la valeur qu’elle demande avec onChange, et le toggle affiche le pressed que l’app renvoie. Le groupe ne détient pas non plus de valeur, de sorte que le nombre de toggles pouvant être pressés relève aussi de l’app : un nombre quelconque (gras, italique et souligné), ou exactement un (un alignement, un sélecteur de vue), auquel cas l’app presse le toggle demandé, relâche les autres et garde pressé celui qui l’est déjà. En Rust, toggle() et toggle_group() les construisent, avec un setter par prop (.pressed(..)), un écouteur par événement (.on_change(..)), les méthodes de style typées et .child(..) pour leur contenu.

#Props

#Toggle

PropRustTypeDéfautDescription
pressed.pressed(..)booleanfalseActivé ou désactivé : les lecteurs d’écran l’annoncent comme pressé, et les styles aria-pressed: s’appliquent. Une pression signale l’autre valeur avec onChange ; le toggle affiche la valeur que l’app renvoie.
disabled.disabled(..)booleanfalseAucune pression et aucun retour visuel de pression ; les styles disabled: s’appliquent, et les lecteurs d’écran l’annoncent comme grisé. Un toggle dans un ToggleGroup désactivé est lui aussi désactivé.
pressedStyle.pressed_style(|s| ..)StyleProp<ViewStyle>aucunLe style du toggle lorsqu’il est pressé et non désactivé, sous forme de classes aria-pressed:.
accessibilityLabel.accessibility_label(..)stringaucunCe que disent les lecteurs d’écran : ce que le toggle active, lorsque ses enfants n’ont pas de texte qui l’indique (une icône).
hitSlop.hit_slop(..)number | Partial<Edges>aucunJusqu’où, en dehors de ses limites, les pressions comptent encore, en points : un nombre pour tous les côtés, ou les côtés nommés. Comme le hitSlop d’une vue.
children.child(..)JSX.ElementaucunL’icône ou le libellé de l’app, que les lecteurs d’écran annoncent.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunLe toggle lui-même : la surface autour de son contenu.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>aucunLe style du toggle pendant une pression, au survol, avec le focus ou lorsqu’il est désactivé, sous forme de classes <state>:.

#ToggleGroup

PropRustTypeDéfautDescription
disabled.disabled(..)booleanfalseTous les toggles du groupe sont désactivés, quel que soit leur propre disabled.
children.child(..)JSX.ElementaucunLes toggles du groupe, et tout ce que l’app dispose entre eux (un séparateur).
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunLa disposition et l’apparence du groupe : une colonne par défaut, flex-row pour une ligne.

#Événements

ÉvénementRustDescription
onChange (Toggle).on_change(|pressed: &bool| ..)Le toggle a été pressé : un toucher, un clic, Espace ou Entrée lorsqu’il a le focus clavier, ou une activation par un lecteur d’écran. { pressed } est la valeur demandée, l’opposé de pressed. L’événement s’exécute avant tout changement : le toggle affiche le pressed que l’app renvoie.

Une touche fléchée dans un ToggleGroup ne fait que déplacer le focus clavier : elle ne presse rien. Un toggle désactivé ne signale rien.

#Parties

PartieDansStylée avecÉtats
Le groupeclass, style de ToggleGroup
Le togglele groupe, ou n’importe oùclass, style de Togglearia-pressed:, disabled:, active:, hover:, focus: (ou pressedStyle, disabledStyle, …)
L’icône ou le libelléle toggleses propres éléments (children)group-aria-pressed:, group-disabled: et les autres variantes group-*: avec group sur le class du toggle

aria-pressed: correspond au toggle activé ; active: correspond à une pression en cours, que le toggle soit activé ou non. Montrez un toggle pressé par sa propre surface : un remplissage, une pastille surélevée, une icône plus marquée (voir Guides de conception).

#Segmented

Un groupe dont les toggles sont joints, un seul étant pressé à la fois, est un contrôle segmenté (un sélecteur de vue) : l’app conserve une valeur et presse le toggle qui la nomme. L’apparence jointe vient des classes de l’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>

#Disabled

Un toggle désactivé ne reçoit aucune pression, et les lecteurs d’écran l’annoncent comme grisé. Ses classes disabled: s’appliquent par-dessus ses classes aria-pressed:, et les classes group-disabled: à l’intérieur suivent. disabled sur un ToggleGroup désactive tous les toggles qu’il contient, soit une barre d’outils que l’app verrouille :

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

#Une petite cible avec hitSlop

Un toggle d’icône peut être plus petit qu’un doigt. hitSlop fait compter les pressions qui tombent à proximité, en points au-delà de chaque côté, sans modifier sa disposition ni son apparence : un nombre pour tous les côtés, ou les côtés nommés ({ top: 8, bottom: 8 }), comme les accepte le hitSlop d’une vue.

<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 est un seul élément pour les lecteurs d’écran, avec le rôle "button" et son état pressé : un bouton à bascule. Son libellé est le texte de ses enfants, ou accessibilityLabel.
  • ToggleGroup a le rôle "toolbar" : les lecteurs d’écran perçoivent les toggles qu’il contient comme une seule barre d’outils, chacun restant un élément à part entière.
  • disabled est annoncé comme le fait la plateforme (VoiceOver, TalkBack, UI Automation, l’arbre d’accessibilité du navigateur).
  • Sur macOS et le web, le groupe constitue un seul arrêt du focus clavier, et les touches fléchées se déplacent entre ses toggles (voir ci-dessous).

Voir Accessibilité pour les libellés, les rôles et les annonces en général.

#Clavier

Sur macOS et le web, où Tab atteint les vues qui reçoivent des pressions, un ToggleGroup suit le motif de barre d’outils WAI-ARIA :

ToucheEffet
Tab, Maj+TabEntre dans le groupe au premier toggle pressé (le premier toggle non désactivé si aucun ne l’est), et en sort en une seule étape.
Droite, BasDéplace le focus vers le toggle suivant, du dernier au premier.
Gauche, HautDéplace le focus vers le toggle précédent, du premier au dernier.
Début, FinDéplace le focus vers le premier ou le dernier toggle.
Espace, EntréePresse le toggle qui a le focus.

Les touches fléchées ignorent les toggles désactivés et ne pressent rien : un toggle ne change que lorsqu’il est pressé. Les deux paires de flèches déplacent le focus, quelle que soit la disposition du groupe, qui relève des classes de l’app. Dans une disposition de droite à gauche, Gauche et Droite s’inversent : Gauche passe au toggle suivant et Droite au précédent ; Haut, Bas, Début et Fin gardent leur effet. Les styles focus: montrent où se trouve le focus. Un Toggle en dehors d’un ToggleGroup constitue un arrêt à part entière.

#Notes sur les plateformes

  • iOS, Android et Mac Catalyst : une pression est un toucher qui se termine sur le toggle, contenu compris.
  • Ordinateurs de bureau (macOS, Windows, Linux, GPUI) et web : une pression est un clic. Sur macOS et le web, Espace et Entrée pressent aussi le toggle qui a le focus clavier, et les touches fléchées, Début et Fin le déplacent (Clavier).
  • Chaque plateforme dessine ce que l’app stylise : les toggles, la surface de ceux qui sont pressés et la piste du groupe.