Elementos
Collapsible
Un collapsible es una región cuyo contenido se expande y se contrae: detalles bajo un resumen, un “mostrar más”, una sección de ajustes avanzados. Renderiza siempre a sus hijos y, después de ellos, su content mientras open. La app es dueña de open y del disparador que lo cambia (uno de los hijos), y el aspecto es de la app: un Collapsible no tiene disparador, chevron ni etiqueta propios. Con reveal, el contenido se abre y se cierra con movimiento: la altura de su región pasa con un efecto de resorte de cero a la altura del propio contenido y viceversa, y lo que sigue al collapsible se mueve con él.
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 proviene de nsh/base, el crate nsh-base de Rust. El valor es de la app: su disparador cambia open, y el collapsible muestra el valor que la app le pasa. En Rust, collapsible() crea uno con un setter por cada prop (.open(..), .reveal(..), .content(..)), los métodos de estilo tipados para la región y .child(..) para lo que se muestra siempre.
#Props
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
open |
.open(..) |
boolean |
false |
Expandido: el content está en el árbol, después de los hijos. El valor es de la app. |
reveal |
.reveal(..) |
boolean |
false |
Se abre y se cierra con una revelación: el contenido está en una región que lo recorta, cuya altura pasa con efecto de resorte de cero a la del propio contenido cuando open se activa, y vuelve cuando se desactiva, con amortiguamiento crítico (unos 265 ms, sin rebote). Lo que sigue al collapsible se mueve con él, y el contenido permanece en el árbol hasta que la región se ha cerrado. Con movimiento reducido se abre y se cierra de inmediato. La región también está en el layout cuando está cerrada, con altura cero, así que un gap del collapsible va también antes de ella. |
content |
.content(..) |
JSX.Element |
ninguno | Lo que se expande. Se crea una sola vez y se conserva mientras está contraído, de modo que lo que hay dentro mantiene su estado (el texto de un input, una posición de scroll, un switch) de una apertura a la siguiente. |
children |
.child(..) |
JSX.Element |
ninguno | Lo que se muestra en cualquier caso: un resumen, el disparador que cambia open. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | La región alrededor de los hijos y del contenido (gap-2 los separa). |
#Eventos
Un collapsible no informa de nada por sí mismo: el disparador es de la app, y su pulsación (onPress del View con role="button") es donde la app cambia open.
#Partes
| Parte | Dentro de | Estilo con |
|---|---|---|
| La región | class, style |
|
| Los hijos | la región, primero | sus propios elementos |
| La región de la revelación | la región, después de los hijos, con reveal |
ninguno: recorta el contenido y toma su altura |
| El contenido | la región, después de los hijos (en la región de la revelación con reveal), mientras open |
sus propios elementos (content) |
Sin reveal, el contenido aparece y desaparece de inmediato, y cada plataforma vuelve a calcular el layout de la región cuando llega o se va. Con reveal, el cambio es una animación de layout del commit que cambia open (como LayoutAnimation.configureNext, ejecutada de forma nativa, por lo que no se ejecuta JavaScript mientras se mueve): la región de la revelación crece de cero a la altura del contenido y se encoge de nuevo, y los frames de todo lo que mueve el mismo cambio, entre ellos las vistas posteriores al collapsible, se mueven con ella. Una transición de la app la acompaña: el chevron del disparador gira (transition-transform), y el contenido puede aparecer con un fundido mediante sus propias clases.
#Accesibilidad
- La región no es un elemento propio para los lectores de pantalla: sus hijos y su contenido se leen tal como son.
- El disparador indica si la región está expandida: asígnale
accessibilityState={{ expanded: open }}y un rol de botón (role="button"en unView), y los lectores de pantalla anuncian “expandido” o “contraído” junto con su etiqueta. - En macOS y en la web, el disparador es una parada del foco de teclado cuando recibe pulsaciones: Tab llega hasta él, y Espacio y Retorno cambian la región.
Consulta Accesibilidad para etiquetas, roles y anuncios en general.
#Notas por plataforma
- Todas las plataformas muestran el contenido después de los hijos mientras
open, y lo dejan fuera del layout en caso contrario. - Con
reveal, todas las plataformas aplican el efecto de resorte a la altura de la región con su propio animador (iOS, Android, macOS, Windows, Linux, GPUI y la web), y la abren y la cierran de inmediato mientras el sistema solicita movimiento reducido. - iOS, Android y Mac Catalyst: la pulsación del disparador es un tap. Escritorio (macOS, Windows, Linux, GPUI) y la web: un clic, y Espacio o Retorno sobre el disparador con foco en macOS y la web.