العناصر
Collapsible
الـ Collapsible منطقة يتوسّع محتواها وينطوي: تفاصيل تحت ملخّص، أو “عرض المزيد”، أو قسم من الإعدادات المتقدمة. يعرض أبناءه دائمًا، ويعرض content بعدهم عندما يكون open. يملك التطبيق 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، وهي الـ crate nsh-base في Rust. القيمة ملك للتطبيق: محفّزه يقلب open، ويعرض الـ Collapsible القيمة التي يمرّرها التطبيق. في Rust ينشئ collapsible() واحدًا بدالة ضبط (setter) لكل خاصية (.open(..)، .reveal(..)، .content(..))، ودوال الأنماط المطبوعة للمنطقة، و.child(..) لما يظهر دائمًا.
#الخصائص
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
open | .open(..) | boolean | false | موسَّع: يكون content في الشجرة بعد الأبناء. يملك التطبيق هذه القيمة. |
reveal | .reveal(..) | boolean | false | يُفتح ويُغلق بكشف تدريجي: يقع المحتوى في منطقة تقصّه، ينتقل ارتفاعها نابضيًا من الصفر إلى ارتفاع المحتوى نفسه عند تفعيل open ويعود عند إيقافه، بتخميد حرج (نحو 265 ملّي ثانية، دون تجاوز). وما يلي الـ Collapsible يتحرك معه، ويبقى المحتوى في الشجرة حتى تُغلق المنطقة. مع تقليل الحركة يُفتح ويُغلق فورًا. المنطقة موجودة في التخطيط حتى وهي مغلقة، بارتفاع صفر، ولذلك فإن gap الخاص بالـ Collapsible يسبقها أيضًا. |
content | .content(..) | JSX.Element | لا يوجد | ما يتوسّع. يُنشأ مرة واحدة ويبقى أثناء الانطواء، فيحتفظ ما بداخله بحالته (نص حقل إدخال، أو موضع تمرير، أو مفتاح تبديل) من فتحة إلى التي تليها. |
children | .child(..) | JSX.Element | لا يوجد | ما يظهر في الحالتين: ملخّص، وهو المحفّز الذي يقلب open. |
class، style | دوال الأنماط | string، StyleProp<ViewStyle> | لا يوجد | المنطقة المحيطة بالأبناء والمحتوى (gap-2 يضبط المسافات بينها). |
#الأحداث
لا يُبلغ الـ Collapsible عن أي شيء بنفسه: المحفّز ملك للتطبيق، وضغطته (onPress الخاص بـ View مع role="button") هي المكان الذي يقلب فيه التطبيق open.
#الأجزاء
| الجزء | بداخل | يُنسَّق بـ |
|---|---|---|
| المنطقة | class، style | |
| الأبناء | المنطقة، أولًا | عناصرهم الخاصة |
| منطقة الكشف | المنطقة، بعد الأبناء، مع reveal | لا يوجد: تقصّ المحتوى وتأخذ ارتفاعه |
| المحتوى | المنطقة، بعد الأبناء (وفي منطقة الكشف مع reveal)، أثناء open | عناصره الخاصة (content) |
بدون reveal يظهر المحتوى ويختفي فورًا، وتعيد كل منصة تخطيط المنطقة عند وصوله أو مغادرته. ومع reveal يكون التغيير حركة تخطيط (layout animation) في الـ commit الذي يغيّر open (مثل LayoutAnimation.configureNext، وتُنفَّذ أصليًا، فلا تعمل JavaScript أثناء الحركة): تنمو منطقة الكشف من الصفر إلى ارتفاع المحتوى وتنكمش عائدة، وتتحرك معها إطارات كل ما يحرّكه التغيير نفسه، ومنها العناصر التي تلي الـ Collapsible. وتنضم إليها أي transition خاصة بالتطبيق: يدور سهم المحفّز (transition-transform)، ويمكن للمحتوى أن يظهر تدريجيًا بأصنافه (classes) الخاصة.
#إمكانية الوصول
- المنطقة ليست عنصرًا مستقلًا لقارئات الشاشة: يُقرأ أبناؤها ومحتواها كما هم.
- يُعلن المحفّز ما إذا كانت المنطقة موسَّعة: امنحه
accessibilityState={{ expanded: open }}ودور زر (role="button"علىView)، فتعلن قارئات الشاشة “موسَّع” أو “منطوٍ” مع تسميته. - على macOS والويب يكون المحفّز محطة في تركيز لوحة المفاتيح عندما يستقبل الضغطات: يصل إليه Tab، ويقلب Space وReturn المنطقة.
انظر Accessibility للتسميات والأدوار والإعلانات بوجه عام.
#ملاحظات المنصات
- تعرض كل منصة المحتوى بعد الأبناء عندما يكون
open، وتُخرجه من التخطيط في غير ذلك. - مع
revealتُحرّك كل منصة ارتفاع المنطقة نابضيًا بمحرّك الحركة الخاص بها (iOS وAndroid وmacOS وWindows وLinux وGPUI والويب)، وتفتحها وتغلقها فورًا عندما يطلب النظام تقليل الحركة. - iOS وAndroid وMac Catalyst: ضغطة المحفّز هي نقرة لمس (tap). أنظمة سطح المكتب (macOS وWindows وLinux وGPUI) والويب: نقرة (click)، وSpace أو Return على المحفّز المركَّز عليه في macOS والويب.