Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

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.

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 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 un View), 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.