要素
Collapsible
Collapsible は、コンテンツが展開・折りたたみされる領域です。要約の下に表示される詳細、「もっと見る」、詳細設定のセクションなどに使います。子要素は常に描画し、open の間はその後ろに content を描画します。open と、それを切り替えるトリガー(子要素の 1 つ)はアプリが持ち、見た目もアプリ側で決めます。Collapsible 自体には、トリガー、シェブロン、ラベルはありません。reveal を指定すると、コンテンツはモーション付きで開閉します。領域の高さが 0 からコンテンツ本来の高さまでスプリングで変化し、元に戻り、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 は、Rust の nsh-base クレートである nsh/base に由来します。値はアプリが持ちます。トリガーが open を切り替え、Collapsible はアプリから渡された値を表示します。Rust では、collapsible() が各 prop に対応するセッター(.open(..)、.reveal(..)、.content(..))、領域用の型付きスタイルメソッド、常に表示する要素用の .child(..) を備えたものを生成します。
#Props
| Prop | Rust | 型 | 既定値 | 説明 |
|---|---|---|---|---|
open | .open(..) | boolean | false | 展開状態です。content が子要素の後ろでツリーに含まれます。値はアプリが持ちます。 |
reveal | .reveal(..) | boolean | false | リビール(reveal)で開閉します。コンテンツは、はみ出しをクリップする領域に置かれ、その高さは open がオンになると 0 からコンテンツ本来の高さまで、オフになると元に戻る方向にスプリングで変化します。減衰は臨界減衰です(約 265 ms、オーバーシュートなし)。Collapsible の後ろにある要素も一緒に動き、コンテンツは領域が閉じきるまでツリーに残ります。モーション低減が有効な場合は、即座に開閉します。この領域は閉じている間もレイアウトに含まれ、高さは 0 です。そのため、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
| パーツ | 内側 | スタイルの指定方法 |
|---|---|---|
| 領域 | class、style | |
| 子要素 | 領域内の先頭 | それぞれの要素自身 |
| リビールの領域 | 領域内の子要素の後ろ(reveal 指定時) | なし。コンテンツをクリップし、その高さを取ります |
| コンテンツ | 領域内の子要素の後ろ(reveal 指定時はリビールの領域内)、open の間 | それぞれの要素自身(content) |
reveal がない場合、コンテンツは即座に現れたり消えたりし、各プラットフォームはコンテンツの出現時と消滅時に領域のレイアウトをやり直します。reveal を指定すると、この変化は open を変更するコミットのレイアウトアニメーションになります(LayoutAnimation.configureNext として、ネイティブで実行されるため、動いている間 JavaScript は実行されません)。リビールの領域は 0 からコンテンツの高さまで伸び、元に戻ります。同じ変更で動く他のものすべてのフレーム、たとえば Collapsible の後ろにあるビューも、一緒に動きます。アプリ側のトランジションも連動します。トリガーのシェブロンが回転し(transition-transform)、コンテンツは独自のクラスでフェードインさせることもできます。
#Accessibility
- 領域自体は、スクリーンリーダーにとって独立した要素ではありません。子要素とコンテンツは、そのまま読み上げられます。
- トリガーは、領域が展開されているかどうかを伝えます。
accessibilityState={{ expanded: open }}とボタンのロール(Viewではrole="button")を指定すると、スクリーンリーダーがそのラベルとともに「展開済み」または「折りたたみ済み」と読み上げます。 - macOS と Web では、トリガーがプレスを受け付ける場合、キーボードフォーカスの停止位置になります。Tab で到達でき、Space と Return で領域を切り替えられます。
ラベル、ロール、読み上げ全般については、Accessibility を参照してください。
#Platform notes
- どのプラットフォームでも、
openの間はコンテンツを子要素の後ろに表示し、それ以外のときはレイアウトから外します。 revealを指定すると、どのプラットフォーム(iOS、Android、macOS、Windows、Linux、GPUI、Web)でも、それぞれ独自のアニメーターで領域の高さをスプリング駆動します。システムがモーション低減を求めている間は、即座に開閉します。- iOS、Android、Mac Catalyst: トリガーのプレスはタップです。デスクトップ(macOS、Windows、Linux、GPUI)と Web: クリックです。macOS と Web では、フォーカスされたトリガー上の Space または Return も同様に働きます。