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

Éléments

Collapsible

Un collapsible est une région dont le contenu se déploie et se replie : des détails sous un résumé, un « afficher plus », une section de réglages avancés. Il rend toujours ses enfants et, après eux, son content tant que open. L’app possède open et le déclencheur qui l’inverse (l’un des enfants), et l’apparence relève de l’app : un Collapsible n’a ni déclencheur, ni chevron, ni libellé propres. Avec reveal, le contenu s’ouvre et se ferme avec du mouvement : la hauteur de sa région passe, comme sur un ressort, de zéro à celle du contenu et inversement, et ce qui suit le collapsible se déplace avec elle.

el-collapsible.tsx
el-collapsible.tsxLive
import { type JSX, mount, signal, Text, View } from "nsh";
import { Collapsible, Switch } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Panel, size, Split, ui } from "./palette";

// Each section: a summary row, the app's trigger (it says whether it is
// expanded to screen readers), and details the collapsible reveals while the
// app has it open: its height springs open and shut (`reveal`), and the
// sections below move with it. The chevron turns with a transition of the
// app's. One inset per card, leading and trailing: the summary's chevron
// lane is as wide as the turned chevron, so it ends where the values below
// it end.
// The pointer's hover and the keyboard's focus ring come from the palette;
// the reveal, the chevron and the switch stay still with reduced motion.
const pad = desktop ? "px-3" : "px-4";

function Section(props: { label: string; detail: string; open: boolean; onToggle: () => void; content: JSX.Element }) {
  return (
    <Collapsible open={props.open} reveal content={props.content}>
      <View
        role="button"
        accessibilityState={{ expanded: props.open }}
        onPress={props.onToggle}
        hoverStyle={hoverFill()}
        focusStyle={focusRing()}
        class={`flex-row items-center gap-3 ${pad} active:opacity-70`}
        style={{ minHeight: size.row + 8 }}
      >
        <View style={{ flex: 1, gap: 2 }}>
          <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
          <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
        </View>
        <View class="h-5 w-2 items-center justify-center transition-transform duration-150 motion-reduce:transition-none" style={{ rotate: props.open ? "90deg" : "0deg" }}>
          <Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
        </View>
      </View>
    </Collapsible>
  );
}

const USAGE: [string, string][] = [
  ["Photos", "120 GB"],
  ["Apps", "54 GB"],
  ["System", "38 GB"],
];

function App() {
  const storage = signal(true);
  const advanced = signal(false);
  const originals = signal(false);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 4));
  const toggle = (name: string, open: { (): boolean; set: (v: boolean) => void }) => () => {
    open.set(!open());
    log(`${name} → ${open() ? "open" : "closed"}`);
  };
  const line = () => <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>collapsible</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Click a summary, or Tab to it and press Space" : "Tap a summary to show its details"}</Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="open">
              <Text style={{ fontSize: 13, color: ui().text }}>
                Storage {storage() ? "open" : "closed"} · Advanced {advanced() ? "open" : "closed"}
              </Text>
              <Text style={{ fontSize: 12, color: ui().muted }}>Keep originals {originals() ? "on" : "off"}: kept while collapsed</Text>
            </Panel>
            <Log title="onPress (trigger) → open" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
          <Section
            label="Storage"
            detail="212 GB of 256 GB used"
            open={storage()}
            onToggle={toggle("Storage", storage)}
            content={
              <View class={`${pad} pb-2`}>
                {USAGE.map(([name, value]) => (
                  <View class="flex-row items-center" style={{ minHeight: size.rowSm }}>
                    <Text style={{ flex: 1, fontSize: size.label, color: ui().muted }}>{name}</Text>
                    <Text style={{ fontSize: size.label, color: ui().muted, fontVariant: ["tabular-nums"] }}>{value}</Text>
                  </View>
                ))}
              </View>
            }
          />
          {line()}
          <Section
            label="Advanced"
            detail="Options most people leave alone"
            open={advanced()}
            onToggle={toggle("Advanced", advanced)}
            content={
              <Switch
                checked={originals()}
                onChange={({ checked }) => {
                  originals.set(checked);
                }}
                class={`flex-row-reverse items-center justify-between gap-3 ${pad} pb-3 active:opacity-70`}
                hoverStyle={hoverFill()}
                focusStyle={focusRing()}
                trackClass={`${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`}
                trackStyle={originals() ? ui().accentFill : ui().inset}
                thumbClass={desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5"}
                thumbStyle={originals() ? { backgroundColor: ui().onAccent } : ui().raised}
              >
                <Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>Keep originals</Text>
              </Switch>
            }
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{collapsible, theme};
use nsh_core::{AccessibilityState, prelude::*};

fn storage() -> impl IntoElement {
    let open = signal(false);
    collapsible()
        .open(open)
        .reveal(true)
        .gap_2()
        .child(
            view()
                .role(AccessibilityRole::Button)
                .accessibility_state(move || AccessibilityState {
                    expanded: Some(open.get()),
                    ..AccessibilityState::default()
                })
                .on_press(move || open.update(|o| *o = !*o))
                .flex_row()
                .items_center()
                .justify_between()
                .child(text("Storage"))
                .child(text("›").transition_transform().rotate(move || {
                    if open.get() { deg(90.0) } else { deg(0.0) }
                })),
        )
        .content(text("Photos 120 GB · Apps 54 GB · System 38 GB").text_color(move || theme().muted_foreground))
}

Collapsible vient de nsh/base, le crate nsh-base en Rust. La valeur appartient à l’app : son déclencheur inverse open, et le collapsible affiche la valeur que l’app lui transmet. En Rust, collapsible() en construit un avec un setter par prop (.open(..), .reveal(..), .content(..)), les méthodes de style typées pour la région et .child(..) pour ce qui est toujours affiché.

#Props

PropRustTypeDéfautDescription
open.open(..)booleanfalseDéployé : le content est dans l’arbre, après les enfants. L’app possède la valeur.
reveal.reveal(..)booleanfalseS’ouvre et se ferme avec un dévoilement : le contenu se trouve dans une région qui le rogne, dont la hauteur passe en ressort de zéro à celle du contenu lorsque open s’active, et revient à zéro lorsqu’il se désactive, avec un amortissement critique (environ 265 ms, sans dépassement). Ce qui suit le collapsible se déplace avec lui, et le contenu reste dans l’arbre jusqu’à la fermeture de la région. Avec la réduction des animations, il s’ouvre et se ferme instantanément. La région fait aussi partie de la mise en page lorsqu’il est fermé, avec une hauteur nulle, de sorte qu’un gap du collapsible s’applique aussi avant elle.
content.content(..)JSX.ElementaucunCe qui se déploie. Il est créé une seule fois et conservé pendant le repli, de sorte que son contenu garde son état (le texte d’un champ, une position de défilement, un interrupteur) d’une ouverture à l’autre.
children.child(..)JSX.ElementaucunCe qui est affiché dans tous les cas : un résumé, le déclencheur qui inverse open.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunLa région autour des enfants et du contenu (gap-2 les espace).

#Événements

Un collapsible ne signale rien de lui-même : le déclencheur appartient à l’app, et son appui (onPress du View avec role="button") est l’endroit où l’app inverse open.

#Parties

PartieDansStylée avec
La régionclass, style
Les enfantsla région, en premierleurs propres éléments
La région du dévoilementla région, après les enfants, avec revealaucun : elle rogne le contenu et prend sa hauteur
Le contenula région, après les enfants (dans la région du dévoilement avec reveal), tant que openses propres éléments (content)

Sans reveal, le contenu apparaît et disparaît instantanément, et chaque plateforme recalcule la mise en page de la région à son arrivée ou à son départ. Avec reveal, le changement est une animation de mise en page du commit qui modifie open (comme LayoutAnimation.configureNext, exécutée nativement, de sorte qu’aucun JavaScript ne s’exécute pendant le mouvement) : la région du dévoilement passe de zéro à la hauteur du contenu puis revient à zéro, et les frames de tout ce que ce même changement déplace, dont les vues situées après le collapsible, se déplacent avec elle. Une transition de l’app l’accompagne : le chevron du déclencheur pivote (transition-transform), et le contenu peut apparaître en fondu avec ses propres classes.

#Accessibilité

  • La région n’est pas un élément à part entière pour les lecteurs d’écran : ses enfants et son contenu sont lus tels quels.
  • Le déclencheur indique si la région est déployée : donnez-lui accessibilityState={{ expanded: open }} et un rôle de bouton (role="button" sur un View), et les lecteurs d’écran annoncent « déployé » ou « replié » avec son libellé.
  • Sur macOS et le web, le déclencheur est un arrêt du focus clavier lorsqu’il prend en charge les appuis : Tab l’atteint, Espace et Entrée inversent la région.

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

#Notes par plateforme

  • Chaque plateforme affiche le contenu après les enfants tant que open, et le laisse hors de la mise en page sinon.
  • Avec reveal, chaque plateforme anime la hauteur de la région avec un ressort sur son propre animateur (iOS, Android, macOS, Windows, Linux, GPUI et le web), et l’ouvre et la ferme instantanément lorsque le système demande la réduction des animations.
  • iOS, Android et Mac Catalyst : l’appui sur le déclencheur est un tap. Ordinateurs (macOS, Windows, Linux, GPUI) et web : un clic, ainsi qu’Espace ou Entrée sur le déclencheur ayant le focus sur macOS et le web.