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.
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))
}import { mount, signal, Text, View } from "nsh";
import { Collapsible } from "nsh/base";
function App() {
const open = signal(false);
return (
<Collapsible
open={open()}
reveal
class="gap-2"
content={<Text class="text-muted-foreground">Photos 120 GB · Apps 54 GB · System 38 GB</Text>}
>
<View
role="button"
accessibilityState={{ expanded: open() }}
onPress={() => open.set(!open())}
class="flex-row items-center justify-between active:opacity-70"
>
<Text class="font-medium">Storage</Text>
<Text class={`transition-transform ${open() ? "rotate-90" : ""}`}>›</Text>
</View>
</Collapsible>
);
}
mount(App);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 einemView), 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
reveallä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.