元素
Collapsible
Collapsible 是內容可展開和收合的區域,例如摘要下的詳細資料、「顯示更多」,或是進階設定區段。它一律渲染子元素,並在 open 時於子元素之後渲染 content。open 和切換它的觸發元件(子元素之一)由應用程式擁有,外觀亦由應用程式決定:Collapsible 本身沒有觸發元件、箭咀圖示或標籤。啟用 reveal 後,內容會以動畫展開和收合:區域的高度會以彈簧動畫由零變為內容本身的高度,收合時則反向變回零,而 collapsible 之後的內容亦會隨之移動。
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 來自 nsh/base,即 Rust 的 nsh-base crate。數值由應用程式擁有:觸發元件會切換 open,而 collapsible 會顯示應用程式傳入的值。在 Rust 中,collapsible() 會建立 collapsible,每個 prop 各有一個 setter(.open(..)、.reveal(..)、.content(..)),並提供用於區域的具類型樣式方法,以及用於常駐顯示內容的 .child(..)。
#Props
| Prop | Rust | 類型 | 預設值 | 說明 |
|---|---|---|---|---|
open |
.open(..) |
boolean |
false |
是否展開:展開時,content 會位於樹中,排在子元素之後。數值由應用程式擁有。 |
reveal |
.reveal(..) |
boolean |
false |
以揭示動畫展開和收合:內容放在一個會裁切它的區域內,當 open 開啟時,該區域的高度會由零以彈簧動畫變為內容本身的高度,關閉時則變回零,採用臨界阻尼(約 265 毫秒,無過衝)。collapsible 之後的內容會隨之移動,而內容會保留在樹中,直至區域完全收合為止。若啟用減少動態效果,則會即時展開和收合。區域在收合時仍然處於佈局中,高度為零,因此 collapsible 的 gap 也會排在它之前。 |
content |
.content(..) |
JSX.Element |
無 | 展開的內容。內容只會建立一次,並在收合期間保留,因此其中的狀態(輸入框的文字、捲動位置、開關)會在每次展開之間保持不變。 |
children |
.child(..) |
JSX.Element |
無 | 無論展開與否都會顯示的內容:摘要,以及切換 open 的觸發元件。 |
class、style |
樣式方法 | string、StyleProp<ViewStyle> |
無 | 包住子元素和內容的區域(gap-2 會設定它們之間的間距)。 |
#Events
Collapsible 本身不會回報任何事件:觸發元件由應用程式擁有,應用程式會在其按下事件(帶有 role="button" 的 View 的 onPress)中切換 open。
#Parts
| Part | 位置 | 樣式設定 |
|---|---|---|
| 區域 | class、style |
|
| 子元素 | 區域內,最先 | 各自的元素 |
| 揭示區域 | 區域內,位於子元素之後,需配合 reveal |
無:它負責裁切內容,並取用內容的高度 |
| 內容 | 區域內,位於子元素之後(使用 reveal 時位於揭示區域內),於 open 時顯示 |
各自的元素(content) |
未使用 reveal 時,內容會即時出現和消失,而各平台會在內容出現或消失時重新佈局區域。使用 reveal 時,這個變化會成為更改 open 的該次 commit 的佈局動畫(作為 LayoutAnimation.configureNext,在原生層執行,因此動畫進行期間不會執行 JavaScript):揭示區域會由零增長至內容的高度,並再收縮回去,而同一變更所移動的任何內容的 frame,包括 collapsible 之後的 view,都會隨之移動。應用程式自己的 transition 亦會一併執行:觸發元件的箭咀圖示會旋轉(transition-transform),而內容亦可用自己的 class 淡入。
#Accessibility
- 對螢幕閱讀器而言,區域本身不是獨立的元素:其子元素和內容會按原樣朗讀。
- 觸發元件會說明區域是否已展開:請為它設定
accessibilityState={{ expanded: open }}和按鈕角色(在View上使用role="button"),螢幕閱讀器便會連同其標籤宣告「已展開」或「已收合」。 - 在 macOS 和網頁上,當觸發元件可接受按下操作時,它會成為鍵盤焦點的停駐點:可用 Tab 鍵移到它,並以 Space 和 Return 切換區域。
有關標籤、角色和宣告的一般說明,請參閱 Accessibility。
#平台備註
- 所有平台都會在
open時,於子元素之後顯示內容,否則會將它移出佈局。 - 使用
reveal時,所有平台都會以各自的動畫器對區域高度施加彈簧動畫(iOS、Android、macOS、Windows、Linux、GPUI 和網頁),並在系統要求減少動態效果時即時展開和收合。 - iOS、Android 和 Mac Catalyst:觸發元件的按下操作是輕按。桌面平台(macOS、Windows、Linux、GPUI)和網頁:點按,而在 macOS 和網頁上,亦可在已取得焦點的觸發元件上按 Space 或 Return。