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

Elementi

Collapsible

Un collapsible è una regione il cui contenuto si espande e si comprime: i dettagli sotto un riepilogo, un “mostra altro”, una sezione di impostazioni avanzate. Mostra sempre i suoi figli e, dopo di essi, il suo content finché open. Lo stato open e il trigger che lo inverte (uno dei figli) appartengono all’app, e l’aspetto è deciso dall’app: un Collapsible non ha un trigger, un chevron o un’etichetta propri. Con reveal, il contenuto si apre e si chiude con movimento: l’altezza della sua regione passa con un effetto molla da zero all’altezza propria del contenuto e viceversa, e ciò che segue il collapsible si sposta di conseguenza.

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 proviene da nsh/base, il crate nsh-base di Rust. Il valore appartiene all’app: il suo trigger inverte open, e il collapsible mostra il valore che l’app gli passa. In Rust, collapsible() ne crea uno con un setter per ogni prop (.open(..), .reveal(..), .content(..)), i metodi di stile tipizzati per la regione e .child(..) per ciò che è sempre visibile.

#Props

PropRustTipoPredefinitoDescrizione
open.open(..)booleanfalseEspanso: il content è nell’albero, dopo i figli. Il valore appartiene all’app.
reveal.reveal(..)booleanfalseSi apre e si chiude con una rivelazione: il contenuto si trova in una regione che lo ritaglia, la cui altezza passa con un effetto molla da zero all’altezza propria del contenuto quando open si attiva e torna a zero quando si disattiva, con smorzamento critico (circa 265 ms, senza overshoot). Ciò che segue il collapsible si sposta insieme ad esso, e il contenuto resta nell’albero finché la regione non si è chiusa. Con movimento ridotto si apre e si chiude all’istante. La regione è nel layout anche da chiusa, con altezza zero, quindi anche un gap del collapsible viene prima di essa.
content.content(..)JSX.ElementnessunoCiò che si espande. Viene creato una sola volta e mantenuto mentre è compresso, quindi ciò che contiene conserva il proprio stato (il testo di un input, una posizione di scorrimento, uno switch) da un’apertura alla successiva.
children.child(..)JSX.ElementnessunoCiò che è visibile in ogni caso: un riepilogo, il trigger che inverte open.
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoLa regione attorno ai figli e al contenuto (gap-2 li distanzia).

#Eventi

Un collapsible non riporta alcun evento proprio: il trigger appartiene all’app, e la sua pressione (onPress del View con role="button") è il punto in cui l’app inverte open.

#Parti

ParteDentroStilizzata con
La regioneclass, style
I figlila regione, per primii loro elementi
La regione della rivelazionela regione, dopo i figli, con revealnessuno: ritaglia il contenuto e ne assume l’altezza
Il contenutola regione, dopo i figli (nella regione della rivelazione con reveal), mentre openi suoi elementi (content)

Senza reveal, il contenuto compare e scompare all’istante, e ogni piattaforma ricalcola il layout della regione quando arriva o se ne va. Con reveal, la modifica è un’animazione di layout del commit che cambia open (come LayoutAnimation.configureNext, eseguita in modo nativo, quindi nessun JavaScript viene eseguito durante il movimento): la regione della rivelazione cresce da zero all’altezza del contenuto e si restringe di nuovo, e i frame di tutto ciò che la stessa modifica sposta, comprese le viste dopo il collapsible, si muovono con essa. Una transizione dell’app si aggiunge al movimento: il chevron del trigger ruota (transition-transform), e il contenuto può comparire in dissolvenza con le proprie classi.

#Accessibilità

  • La regione non è un elemento a sé per gli screen reader: i suoi figli e il suo contenuto vengono letti così come sono.
  • Il trigger indica se la regione è espansa: assegnagli accessibilityState={{ expanded: open }} e un ruolo di pulsante (role="button" su un View), e gli screen reader annunciano “espanso” o “compresso” insieme alla sua etichetta.
  • Su macOS e sul web il trigger è una tappa del focus da tastiera quando riceve pressioni: Tab lo raggiunge, Spazio e Invio invertono la regione.

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

#Note sulle piattaforme

  • Ogni piattaforma mostra il contenuto dopo i figli mentre open, e altrimenti lo esclude dal layout.
  • Con reveal, ogni piattaforma fa passare l’altezza della regione con un effetto molla sul proprio animatore (iOS, Android, macOS, Windows, Linux, GPUI e il web), e la apre e la chiude all’istante quando il sistema richiede il movimento ridotto.
  • iOS, Android e Mac Catalyst: la pressione del trigger è un tap. Desktop (macOS, Windows, Linux, GPUI) e web: un clic, e Spazio o Invio sul trigger con il focus su macOS e sul web.