العناصر
Accordion
يكدّس الأكورديون (Accordion) أقسامًا تتوسع وتنطوي تحت عناوينها: أسئلة الأسئلة الشائعة، ومجموعات صفحة الإعدادات، وخطوات الدفع. يتكوّن من خمسة أجزاء، يملك كل منها جزءًا واحدًا من السلوك ولا يملك شيئًا من المظهر:
import { mount, signal, Text, View } from "nsh";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from "nsh/base";
import { desktop, focusRing, hoverFill, Icon, Log, Panel, Segmented, size, Split, ui } from "./palette";
// An FAQ: each item a heading whose trigger (a button that says whether it
// is expanded) opens its answer. The app owns which items are open
// (`value`) and sets what `onChange` reports; one at a time, or several
// with `multiple`. The panels spring open and shut (`reveal`) and the
// items below move with them; the chevron turns with `group-aria-expanded:`.
// The pointer's hover and the keyboard's focus ring come from the palette;
// the reveal and the chevron stay still with reduced motion.
const pad = desktop ? "px-3" : "px-4";
const FAQ: { value: string; question: string; answer: string; disabled?: boolean }[] = [
{ value: "shipping", question: "How long does shipping take?", answer: "Orders leave the warehouse within a day and arrive in two to five working days." },
{ value: "returns", question: "Can I return an item?", answer: "Within 30 days, unused and in its box: print the label from your order and drop it off." },
{ value: "warranty", question: "What does the warranty cover?", answer: "Two years against defects in materials and workmanship, parts and labour included." },
{ value: "gift", question: "Can I send it as a gift?", answer: "Gift wrapping opens with the holiday season.", disabled: true },
];
const MODES = ["one at a time", "several"] as const;
function App() {
const open = signal<string[]>(["shipping"]);
const mode = signal<(typeof MODES)[number]>("one at a time");
const events = signal<string[]>([]);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>accordion</Text>
<Text style={{ color: ui().muted }}>{desktop ? "Click a question, or Tab to it and press Space" : "Tap a question to read its answer"}</Text>
<Split
gap={14}
inspector={
<>
<Panel title="multiple">
<Segmented
options={MODES}
value={mode()}
onChange={(value) => {
mode.set(value);
// One at a time keeps the item opened last.
if (value === "one at a time") open.set(open().slice(-1));
}}
/>
</Panel>
<Panel title="value">
<Text style={{ fontSize: 13, color: ui().text }}>{open().length ? open().join(", ") : "none open"}</Text>
</Panel>
<Log title="onChange({ value })" lines={events()} minHeight={92} />
</>
}
>
<Accordion
value={open()}
multiple={mode() === "several"}
onChange={({ value }) => {
open.set(value);
events.set([`[${value.join(", ")}]`, ...events()].slice(0, 4));
}}
style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}
>
{FAQ.map((item, i) => (
<AccordionItem value={item.value} disabled={item.disabled}>
<View style={{ height: i === 0 ? 0 : 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />
<AccordionHeader>
<AccordionTrigger
hoverStyle={item.disabled ? undefined : hoverFill()}
focusStyle={focusRing()}
class={`group flex-row items-center gap-3 ${pad} active:opacity-70 disabled:opacity-40`}
style={{ minHeight: size.row + 8 }}
>
<Text style={{ flex: 1, fontSize: size.label, fontWeight: "500", color: ui().text }}>{item.question}</Text>
<View class="transition-transform duration-200 motion-reduce:transition-none group-aria-expanded:rotate-180">
<Icon name="chevron-down" size={14} tint={ui().muted} />
</View>
</AccordionTrigger>
</AccordionHeader>
<AccordionPanel reveal accessibilityLabel={item.question}>
<View class={`${pad} pb-3`}>
<Text style={{ fontSize: size.label, lineHeight: size.label * 1.45, color: ui().muted }}>{item.answer}</Text>
</View>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</Split>
</View>
);
}
mount(App);Accordion، المكدّس: يحملvalueالتطبيق، وهي قيم العناصر المفتوحة، ويُبلغ بالقيمة التالية عبرonChange. يُفتح عنصر واحد في كل مرة، أو عدة عناصر باستخدامmultiple.AccordionItem، قسم واحد، يُعرَّف بـvalue. يكون مفتوحًا ما دامتvalueالأكورديون تحتوي عليه.AccordionHeader، عنوان العنصر، منlevelتعلنه قارئات الشاشة.AccordionTrigger، الزر في العنوان الذي يوسّع عنصره ويطويه، ويُعلن ما إذا كان موسّعًا.AccordionPanel، محتوى العنصر، يظهر ما دام مفتوحًا؛ ومعrevealيتمدد ارتفاعه وينكمش بحركة نابضية.
use nsh_base::{accordion, accordion_header, accordion_item, accordion_panel, accordion_trigger, theme};
use nsh_core::prelude::*;
const FAQ: [(&str, &str, &str); 2] = [
("shipping", "When will my order ship?", "Orders placed before 2 pm leave the same day."),
("returns", "How do I return an item?", "Print the label from your order within 30 days."),
];
fn faq() -> impl IntoElement {
let open = signal(vec![SharedString::from("shipping")]);
let mut list = accordion()
.value(open)
.on_change(move |value: &Vec<SharedString>| open.set(value.clone()));
for (value, question, answer) in FAQ {
list = list.child(
accordion_item()
.value(value)
.border_b_1()
.border_color(move || theme().border)
.child(
accordion_header().child(
accordion_trigger()
.flex_row()
.items_center()
.justify_between()
.py_3()
.child(text(question).font_weight(FontWeight::MEDIUM))
.child(text("⌄").transition_transform().rotate(move || {
let open = open.get().iter().any(|v| &**v == value);
if open { deg(180.0) } else { deg(0.0) }
})),
),
)
.child(
accordion_panel()
.reveal(true)
.accessibility_label(question)
.child(view().pb_3().child(text(answer).text_color(move || theme().muted_foreground))),
),
);
}
list
}import { mount, signal, Text, View } from "nsh";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from "nsh/base";
const FAQ = [
{ value: "shipping", question: "When will my order ship?", answer: "Orders placed before 2 pm leave the same day." },
{ value: "returns", question: "How do I return an item?", answer: "Print the label from your order within 30 days." },
];
function App() {
const open = signal(["shipping"]);
return (
<Accordion value={open()} onChange={({ value }) => open.set(value)}>
{FAQ.map((item) => (
<AccordionItem value={item.value} class="border-b border-border">
<AccordionHeader>
<AccordionTrigger class="group flex-row items-center justify-between py-3 active:opacity-70">
<Text class="font-medium">{item.question}</Text>
<Text class="transition-transform group-aria-expanded:rotate-180">⌄</Text>
</AccordionTrigger>
</AccordionHeader>
<AccordionPanel reveal accessibilityLabel={item.question}>
<View class="pb-3">
<Text class="text-muted-foreground">{item.answer}</Text>
</View>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
);
}
mount(App);تأتي الأجزاء من nsh/base، وهي حزمة nsh-base في Rust. القيمة ملك التطبيق: فضغطة المشغّل تُبلغ بالقيمة value التالية عبر onChange، ويفتح الأكورديون العناصر التي تعيد إليه التطبيقُ قيمها. يجد كل جزء الجزء المحيط به من تلقاء نفسه، فيرتّبها التطبيق بحرية: فواصل بين العناصر، وشارة في عنوان، وأيقونة قبل العنوان. في Rust تُنشئ accordion() وaccordion_item() وaccordion_header() وaccordion_trigger() وaccordion_panel() هذه الأجزاء، مع دالة ضبط لكل خاصية و.child(..) لما بداخلها.
#الخصائص
#Accordion
| الخاصية | Rust | النوع | القيمة الافتراضية | الوصف |
|---|---|---|---|---|
value | .value(..) | string[] | [] | value العناصر المفتوحة. يملكها التطبيق: فضغطة المشغّل تُبلغ بالقيم التالية عبر onChange، ويعرض الأكورديون القيم التي يعيدها التطبيق. |
multiple | .multiple(..) | boolean | false | عدة عناصر مفتوحة في آن واحد: فتح عنصر يترك العناصر الأخرى مفتوحة. وبدونها، يُغلق فتحُ عنصرٍ العنصرَ الذي كان مفتوحًا. |
disabled | .disabled(..) | boolean | false | جميع العناصر معطّلة: لا يقبل أي مشغّل ضغطات، وتعلن قارئات الشاشة أن كل عنصر باهت. |
children | .child(..) | JSX.Element | لا شيء | العناصر، وما يرتّبه التطبيق بينها (فواصل). |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | تخطيط المكدّس ومظهره: بطاقة حول العناصر، والمسافة بينها. |
#AccordionItem
| الخاصية | Rust | النوع | القيمة الافتراضية | الوصف |
|---|---|---|---|---|
value | .value(..) | string | مطلوبة | ما يُعرّف العنصر في value الأكورديون: معرّف التطبيق نفسه، وليس عنوانه أبدًا، لأن العنوان يتغير باللغة. |
disabled | .disabled(..) | boolean | false | لا يقبل مشغّله ضغطات، وتعلن قارئات الشاشة أنه باهت. ويبقى مفتوحًا أو مغلقًا بحسب ما تقوله value الأكورديون. |
children | .child(..) | JSX.Element | لا شيء | ترويسة العنصر ولوحته، وما يرتّبه التطبيق فيه. |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | القسم: فاصل تحته، وبطاقة حوله. |
#AccordionHeader
| الخاصية | Rust | النوع | القيمة الافتراضية | الوصف |
|---|---|---|---|---|
level | .level(..) | number | 3 | مستوى العنوان بين عناوين الصفحة، لقارئات الشاشة (1 هو الأعلى). |
children | .child(..) | JSX.Element | لا شيء | مشغّل العنصر، وما تحمله الترويسة أيضًا (شارة، إجراء خاص بها). |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | تخطيط العنوان. |
#AccordionTrigger
| الخاصية | Rust | النوع | القيمة الافتراضية | الوصف |
|---|---|---|---|---|
expandedStyle | .expanded_style(|s| ..) | StyleProp<ViewStyle> | لا شيء | تنسيق المشغّل أثناء فتح عنصره، على هيئة فئات aria-expanded:. |
accessibilityLabel | .accessibility_label(..) | string | لا شيء | ما تقوله قارئات الشاشة، حين لا يكون في العناصر الفرعية نص يقوله. |
children | .child(..) | JSX.Element | لا شيء | العنوان والسهم؛ وتتبع فئات group-aria-expanded: بداخلها المشغّلَ باستخدام group على class الخاص به. |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | المشغّل نفسه: الصف الذي يضغطه المستخدم. |
activeStyle، hoverStyle، focusStyle، disabledStyle | .active(|s| ..)، .hover(..)، .focus(..)، .disabled_style(..) | StyleProp<ViewStyle> | لا شيء | تنسيق المشغّل أثناء الضغط أو التمرير أو التركيز أو التعطيل، على هيئة فئات <state>:. |
#AccordionPanel
| الخاصية | Rust | النوع | القيمة الافتراضية | الوصف |
|---|---|---|---|---|
reveal | .reveal(..) | boolean | false | يُفتح ويُغلق بكشف تدريجي، كما في Collapsible: تقصّ اللوحة محتواها، ويتمدد ارتفاعها بحركة نابضية من الصفر إلى ارتفاع المحتوى وبالعكس، بتخميد حرج (نحو 265 ملّي ثانية، بلا تجاوز)، وتتحرك العناصر التي بعدها معها. ومع تقليل الحركة تُفتح وتُغلق فورًا. |
keepMounted | .keep_mounted(..) | boolean | false | يبقى المحتوى في الشجرة أثناء إغلاق العنصر، بعيدًا عن الأنظار وعن تركيز لوحة المفاتيح وعن قارئات الشاشة: فيحتفظ الفيديو بموضعه، وتحتفظ عروض الويب بصفحتها. وبدونها يغادر المحتوى الشجرة عند إغلاق اللوحة (بعد أن يُنهي الكشف إغلاقها) ويعود كما كان. |
accessibilityLabel | .accessibility_label(..) | string | لا شيء | ما تسمّي به قارئات الشاشة المنطقة: عادةً عنوان العنصر، كما تقوله ترويسته. |
children | .child(..) | JSX.Element | لا شيء | المحتوى. |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | تخطيط المنطقة ومظهرها. ومع reveal تبقى اللوحة في التخطيط وهي مغلقة بارتفاع صفر، لذا توضع الحشوات والحدود على عرض (View) يحتوي المحتوى. |
#الأحداث
| الحدث | Rust | الوصف |
|---|---|---|
onChange({ value }) (Accordion) | .on_change(|value: &Vec<SharedString>| ..) | ضُغط مشغّل: نقرة لمسية، أو نقرة بالفأرة، أو Space أو Return أثناء تركيز لوحة المفاتيح عليه، أو تفعيل من قارئ شاشة. value هي قيم العناصر المفتوحة كما ستكون: قيمة العنصر المفتوح تغادرها، وقيمة العنصر المغلق تنضم إليها في النهاية، أو، بدون multiple، تحل محلها. ويعمل قبل أن يتغير أي شيء: اضبط value لتتبعه. |
#الأجزاء
| الجزء | داخل | يُنسَّق بواسطة | الحالات |
|---|---|---|---|
| المكدّس | Accordion الخاصة بـclass، style | ||
| عنصر | المكدّس | AccordionItem الخاصة بـclass، style | قيمة التطبيق |
| العنوان | العنصر | AccordionHeader الخاصة بـclass، style | |
| المشغّل | العنوان | AccordionTrigger الخاصة بـclass، style | aria-expanded:، disabled:، active:، hover:، focus: (أو expandedStyle، disabledStyle، …) |
| العنوان الفرعي والسهم | المشغّل | عناصرها الخاصة (children) | group-aria-expanded:، group-disabled: مع group على class الخاص بالمشغّل |
| اللوحة | العنصر، بعد العنوان | AccordionPanel الخاصة بـclass، style | |
| المحتوى | اللوحة | عناصره الخاصة (children) |
بدون reveal، تخرج اللوحة المغلقة من التخطيط ويخرج محتواها من الشجرة. ومع reveal، يكون التغيير حركة تخطيط (layout animation) في الإيداع الذي يغيّر value (كما في LayoutAnimation.configureNext، وتُنفَّذ أصلية، فلا تعمل JavaScript أثناء الحركة): تنمو اللوحة التي تُفتح من الصفر إلى ارتفاع محتواها، وتنكمش التي تُغلق إلى الوراء، كلتاهما بالنابض نفسه، وتتحرك العناصر التي بعدهما معهما. وتصحبها انتقالة من التطبيق: يدور السهم (transition-transform group-aria-expanded:rotate-180).
#التعطيل
لا يقبل مشغّل العنصر المعطّل ضغطات، وتعلنه قارئات الشاشة باهتًا؛ وتتبعه فئات disabled: على المشغّل وgroup-disabled: بداخله. وتعطّل disabled الأكورديون جميع العناصر. ويظل العنصر المعطّل يعرض ما تقوله value التطبيق: إذ يستطيع التطبيق فتحه.
#إمكانية الوصول
- المكدّس مجموعة، وكل ترويسة عنوان من
levelالخاص بها، وكل مشغّل زر بداخلها يعلن ما إذا كان عنصره موسّعًا. وتستطيع قارئات الشاشة الانتقال من عنوان إلى عنوان. - اللوحة المفتوحة منطقة (region) تُسمّى بـ
accessibilityLabel؛ أما المغلقة فمخفية عن قارئات الشاشة، بما في ذلك المحتوى المُبقى فيها. - مظهر العنصر المفتوح من شأن التطبيق، ولا ينبغي أن يعتمد على اللون وحده: أدِر السهم كما تفعل الأمثلة.
انظر Accessibility للاطلاع على التسميات والأدوار والإعلانات عمومًا.
#لوحة المفاتيح
حيث تكون العروض التي تقبل الضغطات محطات لتركيز لوحة المفاتيح (macOS والويب)، يكون كل مشغّل محطة، بترتيب الصفحة، ويضغطه Space وReturn. تُتخطى المشغّلات المعطّلة، وكذلك محتوى اللوحة المغلقة. وينقل Tab التركيز من مشغّل إلى عناصر التحكم في لوحته المفتوحة ثم إلى المشغّل التالي.
#ملاحظات المنصات
- تعرض كل منصة لوحة العنصر المفتوح تحت عنوانه وتترك المغلقة خارج التخطيط (بارتفاع صفر مع
reveal). - ومع
reveal، تُحرّك كل منصة ارتفاعات اللوحات نابضيًا بمحرّك الحركة الخاص بها (iOS وAndroid وmacOS وWindows وLinux وGPUI والويب)، وتفتحها وتغلقها فورًا ما دام النظام يطلب تقليل الحركة. - تسمع قارئ الشاشة في كل منصة العنوان ومستواه أثناء قراءة العناوين، والمشغّل بداخله زرًّا مع حالته الموسّعة؛ وفي iOS يقرأ VoiceOver المشغّل نفسه.
- iOS وAndroid وMac Catalyst: ضغطة المشغّل نقرة لمسية. أنظمة سطح المكتب (macOS وWindows وLinux وGPUI) والويب: نقرة بالفأرة، وSpace أو Return على المشغّل المركَّز عليه في macOS والويب.