本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

Collapsible

Collapsible 是內容可展開與收合的區域:摘要下方的詳細內容、「顯示更多」,或是一段進階設定。它一律渲染其子元素,並在 open 時於子元素之後渲染 content。應用程式擁有 open 以及切換它的觸發器(子元素之一),外觀也由應用程式決定:Collapsible 本身沒有觸發器、箭頭或標籤。啟用 reveal 時,內容會以動態效果展開與收合:其區域的高度會以彈簧動畫從零變為內容本身的高度,再變回零,位於 Collapsible 之後的內容也會隨之移動。

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 來自 nsh/base,也就是 Rust 中的 nsh-base crate。值由應用程式持有:其觸發器切換 open,而 Collapsible 顯示應用程式傳入的值。在 Rust 中,collapsible() 會建立一個 Collapsible,每個屬性各有一個 setter(.open(..)、.reveal(..)、.content(..)),另有用於區域的具型別樣式方法,以及用於一律顯示內容的 .child(..)。

#屬性

屬性Rust型別預設值說明
open.open(..)booleanfalse已展開:content 位於樹中,在子元素之後。值由應用程式持有。
reveal.reveal(..)booleanfalse以展開動畫開啟與關閉:內容位於會裁切它的區域中,該區域的高度在 open 開啟時以彈簧動畫從零增長到內容本身的高度,關閉時再縮回,採臨界阻尼(約 265 毫秒,無過衝)。位於 Collapsible 之後的內容會隨之移動,且內容會一直留在樹中,直到區域完全關閉。啟用減少動態效果時,則會立即開啟與關閉。區域在關閉時也仍在版面中,高度為零,因此 Collapsible 的 gap 也會出現在它之前。
content.content(..)JSX.Element無要展開的內容。它只建立一次,收合時仍會保留,因此其中的內容(輸入框的文字、捲動位置、開關)在每次開啟之間都會保持狀態。
children.child(..)JSX.Element無一律顯示的內容:摘要,以及切換 open 的觸發器。
class、style樣式方法string、StyleProp<ViewStyle>無包住子元素與內容的區域(gap-2 用於設定它們的間距)。

#事件

Collapsible 本身不回報任何事件:觸發器屬於應用程式,其按下動作(帶有 role="button" 的 View 的 onPress)就是應用程式切換 open 的時機。

#組成部分

部分位置樣式設定方式
區域class、style
子元素區域內,最先各自的元素
展開動畫的區域區域內,位於子元素之後,搭配 reveal無:它裁切內容並取用內容的高度
內容區域內,位於子元素之後(搭配 reveal 時位於展開動畫的區域內),在 open 期間各自的元素(content)

沒有 reveal 時,內容會立即出現與消失,各平台會在內容加入或離開時重新排版該區域。有 reveal 時,這個變化會成為變更 open 的那次 commit 的版面動畫(作為 LayoutAnimation.configureNext,在原生層執行,因此移動期間不會執行任何 JavaScript):展開動畫的區域從零增長到內容的高度,再縮回;同一次變更所移動的任何東西,其 frame 也會一併移動,包括位於 Collapsible 之後的 view。應用程式自己的 transition 也會同步進行:觸發器的箭頭會旋轉(transition-transform),內容也可以用它自己的 class 淡入。

#無障礙

  • 對螢幕閱讀器而言,區域本身不是獨立的元素:其子元素與內容會照原樣朗讀。
  • 觸發器會說明區域是否已展開:請為它設定 accessibilityState={{ expanded: open }} 與按鈕角色(在 View 上使用 role="button"),螢幕閱讀器便會連同其標籤一起朗讀「已展開」或「已收合」。
  • 在 macOS 與網頁上,當觸發器接受按下操作時,它是鍵盤焦點的停駐點:Tab 可以移到它,Space 與 Return 可切換區域。

有關標籤、角色與朗讀播報的一般說明,請參閱無障礙。

#平台說明

  • 每個平台都會在 open 時於子元素之後顯示內容,其餘時候則將它排除在版面之外。
  • 啟用 reveal 時,每個平台都會以各自的動畫器對區域的高度施加彈簧動畫(iOS、Android、macOS、Windows、Linux、GPUI 與網頁),而在系統要求減少動態效果時,則會立即開啟與關閉。
  • iOS、Android 與 Mac Catalyst:觸發器的按下動作是點按(tap)。桌面平台(macOS、Windows、Linux、GPUI)與網頁:點擊(click),而在 macOS 與網頁上,對取得焦點的觸發器按 Space 或 Return 也可以。