تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

Tree

تعرض الشجرة عناصر تحتوي على عناصر أخرى: ملفات المشروع ومجلداته، وأقسام الشريط الجانبي للإعدادات، وهيكل المستند. يتولى Tree سلوك الشجرة: العناصر الموسَّعة، والعنصر المحدد، ولوحة المفاتيح، وما يسمعه قارئ الشاشة. صفوفها افتراضية (virtualized): لا يُنشأ إلا ما يظهر منها، ولا يُنشأ أبناء العنصر المطوي أصلًا، فتُفتح شجرة من آلاف العناصر وتُمرَّر فورًا. أما المظهر فهو للتطبيق: لا ترسم الشجرة شيئًا بنفسها، بل ينسّق التطبيق كل صف (itemClass) ويرسم محتواه (renderItem: أيقونة، وتسمية مزاحة بحسب عمق العنصر، وسهم، وشارة).

el-tree.tsx
el-tree.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Button, Tree, type TreeItem, type TreeRef } from "nsh/base";
import { button, buttonText, desktop, Log, Panel, Split, TreeRow, treeItemClass, ui, wide } from "./palette";

// A project's files: folders that open with their chevron, files, sizes at
// the end, a folder the app keeps out of reach (`disabled`). The tree owns
// the keys and the semantics; every pixel of a row is `itemClass` and
// `TreeRow` over the item's depth.
const files: TreeItem[] = [
  {
    id: "src",
    label: "src",
    children: [
      { id: "src/main.rs", label: "main.rs" },
      { id: "src/lib.rs", label: "lib.rs" },
      {
        id: "src/ui",
        label: "ui",
        children: [
          { id: "src/ui/mod.rs", label: "mod.rs" },
          { id: "src/ui/tree.rs", label: "tree.rs" },
          { id: "src/ui/sidebar.rs", label: "sidebar.rs" },
          {
            id: "src/ui/icons",
            label: "icons",
            children: [
              { id: "src/ui/icons/folder.svg", label: "folder.svg" },
              { id: "src/ui/icons/file.svg", label: "file.svg" },
            ],
          },
        ],
      },
    ],
  },
  {
    id: "tests",
    label: "tests",
    children: [
      { id: "tests/tree.rs", label: "tree.rs" },
      { id: "tests/keys.rs", label: "keys.rs" },
    ],
  },
  { id: "target", label: "target", disabled: true, children: [{ id: "target/debug", label: "debug" }] },
  { id: "Cargo.toml", label: "Cargo.toml" },
  { id: "README.md", label: "README.md" },
];

const sizes: Record<string, string> = {
  "src/main.rs": "1.2 KB",
  "src/lib.rs": "4.8 KB",
  "src/ui/mod.rs": "640 B",
  "src/ui/tree.rs": "21 KB",
  "src/ui/sidebar.rs": "6.3 KB",
  "src/ui/icons/folder.svg": "412 B",
  "src/ui/icons/file.svg": "388 B",
  "tests/tree.rs": "18 KB",
  "tests/keys.rs": "3.1 KB",
  "Cargo.toml": "906 B",
  "README.md": "2.4 KB",
};

const expanded = signal(["src", "src/ui"]);
const selected = signal<string | null>("src/ui/tree.rs");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
let tree: TreeRef | undefined;

function App() {
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>tree</Text>
      <Text style={{ color: ui().muted }}>
        {desktop
          ? "Click a folder, or Tab in and use the arrows: Right opens and enters, Left closes and leaves; double-click a file or press Return to act on it"
          : "Tap a folder to open it, a file to select it; double-tap a file to act on it"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="selected">
              <Text style={{ fontSize: 13, color: ui().text }}>{selected() ?? "none"}</Text>
            </Panel>
            <Panel title="expanded">
              <Text style={{ fontSize: 13, color: ui().text }}>{expanded().join(", ") || "none"}</Text>
            </Panel>
            <Log title="events" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ width: wide() ? 300 : undefined, alignSelf: wide() ? "center" : "stretch", gap: 10 }}>
          <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
            <Button {...button("secondary", { size: "sm" })} onPress={() => tree?.revealItem("src/ui/icons/file.svg")}>
              <Text style={buttonText()}>Reveal file.svg</Text>
            </Button>
            <Button
              {...button("ghost", { size: "sm" })}
              disabled={expanded().length === 0}
              onPress={() => {
                expanded.set([]);
                log("collapse all");
              }}
            >
              <Text style={buttonText("ghost")}>Collapse all</Text>
            </Button>
          </View>
          <Tree
            ref={(r) => (tree = r)}
            items={files}
            expanded={expanded()}
            selected={selected()}
            reveal
            accessibilityLabel="Project files"
            style={{ height: desktop ? 320 : 360, borderRadius: 12, ...ui().card }}
            listStyle={{ padding: 6 }}
            itemClass={treeItemClass}
            onExpandedChange={({ expanded: next }) => expanded.set(next)}
            onSelectedChange={({ selected: next }) => {
              selected.set(next);
              log(`onSelectedChange({ selected: "${next}" })`);
            }}
            onExpand={({ id }) => log(`onExpand({ id: "${id}" })`)}
            onCollapse={({ id }) => log(`onCollapse({ id: "${id}" })`)}
            onAction={({ id }) => log(`onAction({ id: "${id}" })`)}
            renderItem={(entry) => <TreeRow entry={entry} detail={entry.disabled ? "excluded" : sizes[entry.id]} />}
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{TreeEntry, TreeItem, theme, tree};
use nsh_core::prelude::*;

fn files() -> impl IntoElement {
    let items = vec![
        TreeItem::new("src", "src")
            .child(TreeItem::new("src/main.rs", "main.rs"))
            .child(TreeItem::new("src/ui", "ui").child(TreeItem::new("src/ui/tree.rs", "tree.rs"))),
        TreeItem::new("Cargo.toml", "Cargo.toml"),
    ];
    let selected = signal(None::<SharedString>);
    tree()
        .items(items)
        .default_expanded(vec!["src".into()])
        .selected(move || selected.get())
        .on_selected_change(move |id: &SharedString| selected.set(Some(id.clone())))
        .accessibility_label("Files")
        .h(px(320.0))
        .list_class("p-1")
        .item_class(
            "group rounded-md hover:bg-accent selected:bg-primary/10 \
             focus:outline-2 focus:-outline-offset-2 focus:outline-ring disabled:opacity-50",
        )
        .render_item(|entry: &TreeEntry| {
            view()
                .flex_row()
                .gap_2()
                .h_8()
                .items_center()
                .rounded_md()
                .pl(px(8.0 + 16.0 * entry.depth as f32))
                .child(text(if entry.folder { "▸" } else { " " }).text_color(move || theme().muted_foreground))
                .child(text(entry.label.clone()))
        })
}

يأتي Tree من nsh/base، وهي الحزمة nsh-base في Rust. تكون العناصر الموسَّعة والعنصر المحدد من شأن التطبيق عندما يضبط expanded وselected ويحدّثهما في onExpandedChange وonSelectedChange؛ وبدونهما تحتفظ الشجرة بحالتها الخاصة، بادئةً من defaultExpanded وdefaultSelected. تُعرَّف العناصر بـid الخاص بالتطبيق (مسار، أو مفتاح سجل)، وليس بتسميتها أبدًا. في Rust ينشئه tree()، مع دالة ضبط لكل خاصية (.items(..)، .expanded(..)، .render_item(..))، ودالة مستمع لكل حدث، ودوال التنسيق المطبَّعة.

#العناصر

items هي عناصر المستوى الأعلى، وكل منها TreeItem:

الحقلالنوعالوصف
idstringما يعرّفه في expanded وselected والأحداث: فريد في الشجرة (يُسجَّل المعرّف المكرر، ولا يُوسَّع أو يُحدَّد إلا عنصره الأول).
labelstringما يعرضه صفه ما لم يرسمه renderItem، وما يقوله قارئ الشاشة، وما يطابقه الكتابة السريعة (typeahead).
childrenTreeItem[]العناصر التي تحته بالترتيب. إذا وُجد أي منها فهو مجلد: يتوسع وينطوي.
disabledbooleanلا يستطيع المستخدم تحديده أو توسيعه أو طيّه: تتجاوزه الضغطات والمفاتيح، وتنطبق تنسيقات disabled:. يبقى أبناؤه كما يحدد expanded.

يتلقى renderItem كائن TreeEntry لكل صف ظاهر: id وlabel وdisabled للعنصر، وdepth (0 في المستوى الأعلى، و1 تحت عنصر من المستوى الأعلى، …) وما إذا كان folder. هذا هو ما يمثّله العنصر، أما حالات الصف فهي فئاته (classes): يُرسم المحتوى مرة واحدة لكل عنصر ويحتفظ بعناصر عرضه (views) أثناء تحديد الصف أو توسيعه أو طيّه، فيدور السهم بانتقال سلس.

#الخصائص

الخاصيةRustالنوعالافتراضيالوصف
items.items(..)TreeItem[][]عناصر المستوى الأعلى، لكل منها العناصر التي تحته.
expanded.expanded(..)string[]لا شيءمعرّفات العناصر الموسَّعة (متحكَّم بها). يظهر العنصر ما دام كل عنصر فوقه موسَّعًا.
defaultExpanded.default_expanded(..)string[][]العناصر الموسَّعة في البداية، ما دام expanded غير مضبوط.
selected.selected(..)string | nullلا شيءمعرّف العنصر المحدد (متحكَّم به)، وnull لعدم وجود تحديد.
defaultSelected.default_selected(..)stringلا شيءالعنصر المحدد في البداية، ما دام selected غير مضبوط.
disabled.disabled(..)booleanfalseشجرة للقراءة فقط: لا ضغطات ولا مفاتيح، ولا محطة لتركيز لوحة المفاتيح، وdisabled: على كل صف؛ تعرض ما يقوله expanded وselected.
reveal.reveal(..)booleanfalseيؤدي التوسيع والطي إلى نقل الصفوف التي بعد العنصر إلى أماكنها الجديدة بحركة نابضية تُنفَّذ أصلًا على المنصة. تتوقف عند تقليل الحركة.
accessibilityLabel.accessibility_label(..)stringلا شيءما يسمّي به قارئ الشاشة الشجرة (“Files”).
renderItem.render_item(..)(entry: TreeEntry) => JSX.Elementلا شيءمحتوى الصف، للصفوف الظاهرة؛ وبدونه يعرض الصف تسمية العنصر.
itemClass، itemStyle.item_class(..)، .item_style(..)string، StyleProp<TextStyle>لا شيءكل صف: ارتفاعه وحشوه وسطحه وحالاته (أدناه)؛ وتصل تنسيقات النص فيه إلى التسمية.
listClass، listStyle.list_class(..)، .list_style(..)string، StyleProp<ViewStyle>لا شيءعرض التمرير للصفوف داخل الشجرة: حشوه وفجواته.
class، styleدوال التنسيقstring، StyleProp<ViewStyle>لا شيءالشجرة نفسها. أعطها ارتفاعًا (أو flex-1 داخل أب له ارتفاع): تُمرَّر الصفوف داخلها، ولا يُنشأ إلا الظاهر منها.

#الأحداث

الحدثRustالحمولةمتى
onExpandedChange.on_expanded_change(..){ expanded: string[] }وسّع المستخدم عنصرًا أو طواه (ضغطة، أو Return، أو Space، أو Right أو Left، أو revealItem): المعرّفات الموسَّعة كما ستكون.
onSelectedChange.on_selected_change(..){ selected: string }حدد المستخدم عنصرًا آخر (ضغطة، أو مفاتيح الأسهم، أو Home، أو End، أو الكتابة السريعة).
onExpand.on_expand(..){ id: string }وسّع المستخدم العنصر الذي معرّفه id، بعد onExpandedChange.
onCollapse.on_collapse(..){ id: string }طوى المستخدم العنصر الذي معرّفه id، بعد onExpandedChange.
onAction.on_action(..){ id: string }تصرّف المستخدم على العنصر الذي معرّفه id: Return على العنصر المحدد إذا لم يكن له أبناء، أو نقرة مزدوجة أو لمسة مزدوجة على أي عنصر، بعد أحداث الضغطة نفسها. لا تبلّغ العناصر المعطلة ولا الشجرة المعطلة عنه أبدًا.

يتبع التحديدُ مفاتيحَ الأسهم، لذا يفتح مستكشف الملفات الملف في onAction لا في onSelectedChange: التنقل بين الملفات يعاينها واحدًا واحدًا، وReturn أو النقرة المزدوجة تفتح الملف الذي يريده المستخدم.

<Tree
  items={files}
  onSelectedChange={({ selected }) => preview.set(selected)}
  onAction={({ id }) => openFile(id)}
/>

#الأجزاء

الجزءداخليُنسَّق بـالحالات
الشجرةclass، style
القائمةالشجرةlistClass، listStyle
الصفالقائمةitemClass، itemStyleselected:، aria-expanded: (مجلد مفتوح)، focus: (عندما يكون تركيز لوحة المفاتيح على الشجرة: الصف الذي تعمل عليه المفاتيح، وهو الصف المحدد، أو العنصر المطوي الذي يقع تحته تحديد مخفي، أو الصف الأول إذا لم يكن هناك تحديد)، disabled:، hover:، active:
محتوى الصفصفrenderItemgroup-selected:، group-aria-expanded:، group-focus:، group-disabled: تحت group في itemClass

أظهر الصف المحدد بـselected: ومكان لوحة المفاتيح بـfocus: (راجع أدلة التصميم). ارسم التركيز داخل الصف (focus:outline-2 focus:-outline-offset-2): تقصّ القائمة ما يتجاوز جانبيها.

#Ref

الدالةالوصف
focus()تأخذ تركيز لوحة المفاتيح، كما يفعل وصول Tab إلى الشجرة.
revealItem(id)توسّع العناصر التي فوق العنصر الذي معرّفه id (يُبلَّغ عنها بـonExpandedChange وonExpand، الأبعد أولًا) وتمرّر صفه إلى الظهور بعد أن يظهر: فورًا إذا تابع التطبيق التغيير؛ وإذا لم يتابعه ينتهي الطلب عند هذا الحد.
let files: TreeRef | undefined;

<Tree ref={(r) => (files = r)} items={items} />;
files?.revealItem("src/ui/tree.rs");

#التعطيل

لا يقبل العنصر المعطل أي ضغطات، وتتجاوزه مفاتيح الأسهم، ويعلن قارئ الشاشة أنه باهت. disabled على الشجرة يجعلها للقراءة فقط: تعرض ما تقوله حالة التطبيق ولا تقبل أي إدخال.

<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />

#إمكانية الوصول

  • للشجرة الدور "tree"، وتُسمّى بـaccessibilityLabel؛ وكل صف هو "treeitem" يُسمّى بتسمية عنصره، مع مستواه (عمقه + 1)، وما إذا كان موسَّعًا (للمجلدات)، ومحددًا، ومعطلًا.
  • عندما تأخذ الشجرة تركيز لوحة المفاتيح وعندما تحرّك المفاتيح التحديد، يأخذ الصف الذي تعمل عليه المفاتيح تركيز قارئ الشاشة.

راجع إمكانية الوصول للاطلاع على التسميات والأدوار والإعلانات بوجه عام.

#لوحة المفاتيح

على أنظمة سطح المكتب والويب، حيث يصل Tab إلى العناصر التي تقبل الضغطات، يتبع Tree نمط WAI-ARIA tree view:

المفتاحما يفعله
Tab، Shift+Tabيدخل الشجرة بخطوة واحدة، ويخرج منها بخطوة واحدة؛ وعند العودة تتابع المفاتيح من الصف المحدد. وإذا لم يكن هناك تحديد، يظهر التركيز على الصف الأول، ويحدده Down.
Down، Upيحدد الصف التالي أو السابق، متجاوزًا العناصر المعطلة، ومن الأخير إلى الأول والعكس.
Rightيوسّع المجلد المطوي؛ وعلى المجلد الموسَّع يحدد أول ابن مفعّل له.
Leftيطوي المجلد الموسَّع؛ وعلى أي شيء آخر يحدد أباه.
Home، Endيحدد الصف الأول أو الأخير.
Returnيوسّع المجلد المحدد أو يطويه؛ وعلى عنصر بلا أبناء يتصرف عليه (onAction).
Spaceيوسّع المجلد المحدد أو يطويه.
الحروف، الأرقاميحدد الصف التالي الذي تبدأ تسميته بما كُتب.

عندما يكون التحديد مخفيًا تحت عنصر مطوي (طواه التطبيق)، تعمل المفاتيح على صف ذلك العنصر: Right أو Return يوسّعه، وDown يحدد الصف الذي بعده.

في التخطيط من اليمين إلى اليسار تمتد الصفوف من اليمين، فيتبادل Left وRight موضعيهما، كما ينص النمط: Left يوسّع المجلد ويدخله، وRight يطويه ويحدد الأب. أما Up وDown وHome وEnd فتبقى كما هي.

#ملاحظات حول المنصات

  • iOS وAndroid وMac Catalyst: تحدد اللمسة الصف وتوسّع المجلد أو تطويه؛ واللمسة المزدوجة تتصرف على العنصر (onAction).
  • أنظمة سطح المكتب (macOS وWindows وLinux وGPUI) والويب: تفعل النقرة الشيء نفسه وتعطي الشجرة تركيز لوحة المفاتيح، وتتحرك المفاتيح المذكورة أعلاه داخلها؛ والنقرة المزدوجة تتصرف على العنصر.
  • ترسم كل منصة ما ينسّقه التطبيق: الشجرة وصفوفها ومحتواها.