이 페이지는 아직 번역되지 않았습니다. 영어 원문 보기

요소

Collapsible

A collapsible is a region whose content expands and collapses: details under a summary, a “show more”, a section of advanced settings. It renders its children always and its content after them while open. The app owns open and the trigger that flips it (one of the children), and the look is the app’s: a Collapsible has no trigger, chevron or label of its own. With reveal, the content opens and closes with motion: its region’s height springs from nothing to the content’s own and back, and what follows the collapsible moves along.

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 comes from nsh/base, the nsh-base crate in Rust. The value is the app’s: its trigger flips open, and the collapsible shows the value the app passes. In Rust, collapsible() makes one with a setter per prop (.open(..), .reveal(..), .content(..)), the typed style methods for the region and .child(..) for what shows always.

#Props

PropRustTypeDefaultDescription
open.open(..)booleanfalseExpanded: the content is in the tree, after the children. The app owns the value.
reveal.reveal(..)booleanfalseOpens and closes with a reveal: the content sits in a region that clips it, whose height springs from zero to the content’s own as open turns on and back as it turns off, critically damped (about 265 ms, no overshoot). What follows the collapsible moves with it, and the content stays in the tree until the region has closed. With reduced motion it opens and closes at once. The region is in the layout while closed too, zero high, so a gap of the collapsible’s also comes before it.
content.content(..)JSX.ElementnoneWhat expands. It is made once and kept while collapsed, so what is inside keeps its state (an input’s text, a scroll position, a switch) from one opening to the next.
children.child(..)JSX.ElementnoneWhat shows either way: a summary, the trigger that flips open.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe region around the children and the content (gap-2 spaces them).

#Events

A collapsible reports nothing of its own: the trigger is the app’s, and its press (onPress of the View with role="button") is where the app flips open.

#Parts

PartInsideStyled with
The regionclass, style
The childrenthe region, firsttheir own elements
The reveal’s regionthe region, after the children, with revealnone: it clips the content and takes its height
The contentthe region, after the children (in the reveal’s region with reveal), while openits own elements (content)

Without reveal, the content comes and goes at once, and each platform lays the region out again when it arrives or leaves. With reveal, the change is a layout animation of the commit that changes open (as LayoutAnimation.configureNext, run natively, so no JavaScript runs while it moves): the reveal’s region grows from zero to the content’s height and shrinks back, and the frames of whatever the same change moves, the views after the collapsible among them, move with it. A transition of the app’s goes along: the trigger’s chevron turns (transition-transform), and the content can fade in with its own classes.

#Accessibility

  • The region is no element of its own for screen readers: its children and content are read as they are.
  • The trigger says whether the region is expanded: give it accessibilityState={{ expanded: open }} and a button role (role="button" on a View), and screen readers announce “expanded” or “collapsed” with its label.
  • On macOS and the web the trigger is a stop of the keyboard focus when it takes presses: Tab reaches it, Space and Return flip the region.

See Accessibility for labels, roles and announcements in general.

#Platform notes

  • Every platform shows the content after the children while open, and leaves it out of the layout otherwise.
  • With reveal, every platform springs the region’s height on its own animator (iOS, Android, macOS, Windows, Linux, GPUI and the web), and opens and closes it at once while the system asks for reduced motion.
  • iOS, Android and Mac Catalyst: the trigger’s press is a tap. Desktops (macOS, Windows, Linux, GPUI) and the web: a click, and Space or Return on the focused trigger on macOS and the web.