元素

Collapsible

Collapsible 是内容可展开和收起的区域:摘要下面的详情、“显示更多”、一组高级设置。它始终渲染子元素,open 时在子元素之后渲染 content。open 和切换它的触发控件(子元素之一)由应用持有,外观也由应用决定:Collapsible 没有自己的触发控件、箭头、动效或文字。

el-collapsible.tsx
el-collapsible.tsx运行中
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 shows while the
// app has it open. 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 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} 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={{ transform: [{ 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={(next) => {
                  originals.set(next);
                }}
                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, ui::prelude::*};

fn storage() -> impl IntoElement {
    let open = signal(false);
    collapsible()
        .open(open)
        .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 来自 nsh/base,在 Rust 里是 nsh-base crate。值由应用持有:触发控件切换 open, collapsible 显示应用传入的值。在 Rust 里,collapsible() 创建一个 collapsible,每个属性一个 setter(.open(..)、 .content(..)),区域本身用类型化的样式方法,始终显示的部分用 .child(..)。

#属性

属性Rust类型默认值说明
open.open(..)booleanfalse展开:content 在树中,位于子元素之后。值由应用持有。
content.content(..)JSX.Element无展开的内容。它只创建一次,收起时保留,所以里面的状态(输入框的文字、滚动位置、开关)在下次展开时还在。
children.child(..)JSX.Element无始终显示的部分:摘要、切换 open 的触发控件。
class、style样式方法string、StyleProp<ViewStyle>无包住子元素和内容的区域(gap-2 设置它们的间距)。

#事件

collapsible 本身不报告事件:触发控件是应用的,应用在它的按压(带 role="button" 的 View 的 onPress)里切换 open。

#部件

部件位置样式
区域class、style
子元素区域里,最先它们自己的元素
内容区域里,子元素之后,open 时它自己的元素(content)

内容立即出现和离开。应用自己的过渡表现这个变化:触发控件的箭头转动(transition-transform),内容也可以用它自己的类淡入。内容出现或离开时, 每个平台都会重新布局这个区域。

#无障碍

  • 区域本身不是屏幕阅读器的独立元素:子元素和内容按原样朗读。
  • 触发控件报告区域是否展开:给它 accessibilityState={{ expanded: open }} 和按钮角色(View 上的 role="button"),屏幕阅读器会连同它的标签播报“已展开”或“已收起”。
  • 在 macOS 和 Web 上,可按压的触发控件是键盘焦点的停靠点:Tab 可以到达它,空格和 Return 切换区域。

通用的标签、角色和播报见无障碍。

#平台说明

  • 每个平台都在 open 时把内容显示在子元素之后,否则把它留在布局之外。
  • iOS、Android 和 Mac Catalyst:触发控件的按压是轻点。桌面(macOS、Windows、Linux、GPUI)和 Web:点击;在 macOS 和 Web 上,获得焦点的触发控件也响应空格或 Return。