Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Elemente

Collapsible

Ein Collapsible ist ein Bereich, dessen Inhalt sich auf- und zuklappt: Details unter einer Zusammenfassung, ein „Mehr anzeigen“, ein Abschnitt mit erweiterten Einstellungen. Es rendert seine Children immer und danach sein content, solange open. Die App besitzt open und den Auslöser, der ihn umschaltet (eines der Children), und das Aussehen liegt bei der App: Ein Collapsible hat keinen eigenen Auslöser, kein Chevron und kein Label. Mit reveal öffnet und schließt sich der Inhalt mit Bewegung: Die Höhe seines Bereichs federt von null auf die Eigenhöhe des Inhalts und zurück, und was auf das Collapsible folgt, bewegt sich mit.

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 stammt aus nsh/base, dem nsh-base-Crate in Rust. Der Wert gehört der App: Ihr Auslöser schaltet open um, und das Collapsible zeigt den Wert, den die App übergibt. In Rust erzeugt collapsible() eines mit je einem Setter pro Prop (.open(..), .reveal(..), .content(..)), den typisierten Style-Methoden für den Bereich und .child(..) für das, was immer sichtbar ist.

#Props

Prop Rust Typ Standard Beschreibung
open .open(..) boolean false Aufgeklappt: Das content ist im Baum, nach den Children. Die App besitzt den Wert.
reveal .reveal(..) boolean false Öffnet und schließt mit einem Reveal: Der Inhalt sitzt in einem Bereich, der ihn beschneidet. Dessen Höhe federt von null auf die Eigenhöhe des Inhalts, sobald open eingeschaltet wird, und beim Ausschalten wieder zurück, kritisch gedämpft (etwa 265 ms, ohne Überschwingen). Was auf das Collapsible folgt, bewegt sich mit, und der Inhalt bleibt im Baum, bis der Bereich geschlossen ist. Bei reduzierter Bewegung öffnet und schließt es sofort. Der Bereich liegt auch im geschlossenen Zustand im Layout, mit der Höhe null, sodass auch ein gap des Collapsible davor liegt.
content .content(..) JSX.Element keiner Was sich aufklappt. Es wird einmal erzeugt und im zugeklappten Zustand beibehalten, sodass der Inhalt seinen Zustand (den Text einer Eingabe, eine Scrollposition, einen Switch) von einem Öffnen zum nächsten behält.
children .child(..) JSX.Element keiner Was in beiden Zuständen sichtbar ist: eine Zusammenfassung, der Auslöser, der open umschaltet.
class, style Style-Methoden string, StyleProp<ViewStyle> keiner Der Bereich um die Children und den Inhalt (gap-2 sorgt für Abstände zwischen ihnen).

#Events

Ein Collapsible meldet selbst nichts: Der Auslöser gehört der App, und sein Press (onPress des View mit role="button") ist die Stelle, an der die App open umschaltet.

#Parts

Part Enthalten in Gestaltet mit
Der Bereich class, style
Die Children dem Bereich, zuerst ihren eigenen Elementen
Der Bereich des Reveals dem Bereich, nach den Children, mit reveal keiner: Er beschneidet den Inhalt und übernimmt dessen Höhe
Der Inhalt dem Bereich, nach den Children (mit reveal im Bereich des Reveals), solange open seinen eigenen Elementen (content)

Ohne reveal erscheint und verschwindet der Inhalt sofort, und jede Plattform legt den Bereich neu aus, wenn er hinzukommt oder entfällt. Mit reveal ist die Änderung eine Layout-Animation des Commits, der open ändert (als LayoutAnimation.configureNext, nativ ausgeführt, sodass während der Bewegung kein JavaScript läuft): Der Bereich des Reveals wächst von null auf die Höhe des Inhalts und schrumpft wieder zurück, und die Frames von allem, was dieselbe Änderung verschiebt, darunter die Views nach dem Collapsible, bewegen sich mit. Eine Transition der App läuft mit: Das Chevron des Auslösers dreht sich (transition-transform), und der Inhalt kann mit eigenen Klassen eingeblendet werden.

#Barrierefreiheit

  • Der Bereich ist für Screenreader kein eigenes Element: Seine Children und sein Inhalt werden so vorgelesen, wie sie sind.
  • Der Auslöser gibt an, ob der Bereich aufgeklappt ist: Gib ihm accessibilityState={{ expanded: open }} und eine Button-Rolle (role="button" auf einem View), dann kündigen Screenreader zusammen mit seinem Label „aufgeklappt“ oder „zugeklappt“ an.
  • Unter macOS und im Web ist der Auslöser ein Tastaturfokus-Stopp, wenn er Presses annimmt: Mit Tab erreicht man ihn, mit der Leertaste und der Eingabetaste schaltet man den Bereich um.

Siehe Barrierefreiheit für Labels, Rollen und Ansagen im Allgemeinen.

#Hinweise zu den Plattformen

  • Jede Plattform zeigt den Inhalt nach den Children, solange open, und lässt ihn andernfalls aus dem Layout heraus.
  • Mit reveal lässt jede Plattform die Höhe des Bereichs auf ihrem eigenen Animator federn (iOS, Android, macOS, Windows, Linux, GPUI und das Web) und öffnet und schließt ihn sofort, solange das System reduzierte Bewegung verlangt.
  • iOS, Android und Mac Catalyst: Der Press des Auslösers ist ein Tap. Desktops (macOS, Windows, Linux, GPUI) und das Web: ein Klick sowie die Leertaste oder die Eingabetaste auf dem fokussierten Auslöser unter macOS und im Web.