एलिमेंट
Tree
ट्री उन आइटम को दिखाता है जिनमें अन्य आइटम होते हैं: किसी प्रोजेक्ट की फ़ाइलें और फ़ोल्डर, सेटिंग्स साइडबार के सेक्शन, किसी दस्तावेज़ की रूपरेखा। Tree ऐसे ट्री के व्यवहार को संभालता है: कौन-से आइटम एक्सपैंड हैं, कौन-सा चुना गया है, कीबोर्ड कैसे काम करता है और स्क्रीन रीडर क्या बोलते हैं। इसकी पंक्तियाँ वर्चुअलाइज़्ड हैं: केवल दिखाई देने वाली पंक्तियाँ बनाई जाती हैं, और कोलैप्स किए गए आइटम के चाइल्ड कभी बनाए ही नहीं जाते, इसलिए हज़ारों आइटम वाला ट्री भी तुरंत खुलता और स्क्रॉल होता है। रूप-रंग ऐप का अपना होता है: ट्री खुद कुछ नहीं बनाता, ऐप हर पंक्ति की स्टाइल (itemClass) तय करता है और उसकी सामग्री (renderItem: एक आइकन, आइटम की गहराई के अनुसार इंडेंट किया गया लेबल, एक शेवरॉन, एक बैज) बनाता है।
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()))
})
}import { mount, signal, Text, View } from "nsh";
import { Tree, type TreeItem } from "nsh/base";
const files: TreeItem[] = [
{
id: "src",
label: "src",
children: [
{ id: "src/main.rs", label: "main.rs" },
{ id: "src/ui", label: "ui", children: [{ id: "src/ui/tree.rs", label: "tree.rs" }] },
],
},
{ id: "Cargo.toml", label: "Cargo.toml" },
];
function App() {
const expanded = signal(["src"]);
const selected = signal<string | null>(null);
return (
<Tree
items={files}
expanded={expanded()}
selected={selected()}
onExpandedChange={({ expanded: next }) => expanded.set(next)}
onSelectedChange={({ selected: next }) => selected.set(next)}
accessibilityLabel="Files"
class="h-80 w-64 rounded-lg border border-border"
listClass="p-1"
itemClass="group h-8 rounded-md hover:bg-accent selected:bg-primary/10 focus:outline-2 focus:-outline-offset-2 focus:outline-ring disabled:opacity-50"
renderItem={(entry) => (
<View class="h-8 flex-row items-center gap-2" style={{ paddingLeft: 8 + entry.depth * 16 }}>
<Text class="w-3 text-muted-foreground transition-transform group-aria-expanded:rotate-90">
{entry.folder ? "›" : ""}
</Text>
<Text class="text-sm text-foreground">{entry.label}</Text>
</View>
)}
/>
);
}
mount(App);Tree nsh/base से आता है, जो Rust में nsh-base क्रेट है। जब ऐप expanded और selected सेट करता है, तब एक्सपैंड किए गए आइटम और चुना गया आइटम ऐप के होते हैं, जिन्हें वह onExpandedChange और onSelectedChange में अपडेट करता है; इनके बिना ट्री अपनी स्थिति खुद रखता है, जो defaultExpanded और defaultSelected से शुरू होती है। आइटम उनके id से पहचाने जाते हैं, जो ऐप के अपने होते हैं (एक पाथ, किसी रिकॉर्ड की कुंजी), कभी उनके लेबल से नहीं। Rust में tree() इसे बनाता है, जिसमें हर प्रॉप के लिए एक सेटर (.items(..), .expanded(..), .render_item(..)), हर इवेंट के लिए एक लिसनर मेथड और टाइप्ड स्टाइल मेथड होते हैं।
#आइटम
items सबसे ऊपर के आइटम हैं, जिनमें से हर एक TreeItem है:
| फ़ील्ड | टाइप | विवरण |
|---|---|---|
id | string | expanded, selected और इवेंट में जो इसे पहचानता है: ट्री में अद्वितीय होना चाहिए (एक ही id दो बार देने पर लॉग किया जाता है, और केवल उसका पहला आइटम एक्सपैंड या चुना जाता है)। |
label | string | इसकी पंक्ति जो दिखाती है, जब तक renderItem उसे न बनाए, स्क्रीन रीडर जो बोलते हैं और टाइपअहेड जिससे मिलान करता है। |
children | TreeItem[] | इसके नीचे के आइटम, क्रम में। यदि कोई हो, तो यह एक फ़ोल्डर है: यह एक्सपैंड और कोलैप्स होता है। |
disabled | boolean | उपयोगकर्ता इसे चुन, एक्सपैंड या कोलैप्स नहीं कर सकता: प्रेस और कीज़ इसे छोड़कर आगे बढ़ जाती हैं, disabled: स्टाइल लागू होती हैं। इसके चाइल्ड वैसे ही रहते हैं जैसा expanded कहता है। |
renderItem को दिखाई देने वाली हर पंक्ति के लिए एक TreeEntry मिलता है: आइटम का id, label और disabled, उसकी depth (सबसे ऊपर 0, ऊपरी आइटम के नीचे 1, …) और यह कि वह folder है या नहीं। यह वह है जो आइटम है, और पंक्ति की स्थितियाँ उसकी क्लास हैं: ड्रॉइंग हर आइटम के लिए एक बार बनती है और पंक्ति के चुने जाने, एक्सपैंड या कोलैप्स होने के दौरान अपने व्यू बनाए रखती है, इसलिए शेवरॉन ट्रांज़िशन के साथ घूम सकता है।
#प्रॉप्स
| प्रॉप | Rust | टाइप | डिफ़ॉल्ट | विवरण |
|---|---|---|---|---|
items | .items(..) | TreeItem[] | [] | सबसे ऊपर के आइटम, हर एक के नीचे के आइटम के साथ। |
expanded | .expanded(..) | string[] | कोई नहीं | एक्सपैंड किए गए आइटम की id (नियंत्रित)। कोई आइटम तब दिखता है जब उसके ऊपर का हर आइटम एक्सपैंड हो। |
defaultExpanded | .default_expanded(..) | string[] | [] | शुरुआत में एक्सपैंड किए गए आइटम, जब तक expanded सेट न हो। |
selected | .selected(..) | string | null | कोई नहीं | चुने गए आइटम की id (नियंत्रित), किसी को न चुनने के लिए null। |
defaultSelected | .default_selected(..) | string | कोई नहीं | शुरुआत में चुना गया आइटम, जब तक selected सेट न हो। |
disabled | .disabled(..) | boolean | false | केवल-पढ़ने योग्य ट्री: कोई प्रेस या कीज़ नहीं, कीबोर्ड फ़ोकस का कोई पड़ाव नहीं, हर पंक्ति पर disabled:; यह वही दिखाता है जो expanded और selected कहते हैं। |
reveal | .reveal(..) | boolean | false | एक्सपैंड और कोलैप्स करने पर आइटम के बाद की पंक्तियाँ स्प्रिंग के साथ अपनी नई जगह पर जाती हैं, जो नेटिव रूप से चलता है। रिड्यूस्ड मोशन में बंद रहता है। |
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): एक्सपैंड की गई id जैसी वे होतीं। |
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, itemStyle | selected:, aria-expanded: (खुला हुआ फ़ोल्डर), focus: (जब ट्री के पास कीबोर्ड फ़ोकस हो, वह पंक्ति जिस पर कीज़ काम करती हैं: चुनी गई पंक्ति, वह कोलैप्स किया गया आइटम जिसके नीचे छिपा हुआ चयन है, या पहली पंक्ति जब कुछ भी चुना न गया हो), disabled:, hover:, active: |
| पंक्ति की सामग्री | एक पंक्ति | renderItem | group-selected:, group-aria-expanded:, group-focus:, group-disabled:, itemClass में group के अंतर्गत |
चुनी गई पंक्ति को 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");
#डिसेबल्ड
डिसेबल्ड आइटम कोई प्रेस नहीं लेता, ऐरो कीज़ उसे छोड़कर आगे बढ़ जाती हैं, और स्क्रीन रीडर उसे धुँधले (dimmed) के रूप में बताते हैं। ट्री पर disabled उसे केवल-पढ़ने योग्य बना देता है: वह वही दिखाता है जो ऐप की स्थिति कहती है और कोई इनपुट नहीं लेता।
<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />
#एक्सेसिबिलिटी
- ट्री की भूमिका
"tree"है, जिसका नामaccessibilityLabelसे तय होता है; हर पंक्ति एक"treeitem"है, जिसका नाम उसके आइटम के लेबल से तय होता है, साथ में उसका स्तर (उसकी गहराई + 1), यह कि वह एक्सपैंड है या नहीं (फ़ोल्डर), चुनी गई है और डिसेबल्ड है। - जैसे ही ट्री को कीबोर्ड फ़ोकस मिलता है और कीज़ चयन को आगे बढ़ाती हैं, वह पंक्ति जिस पर कीज़ काम करती हैं स्क्रीन रीडर का फ़ोकस ले लेती है।
लेबल, रोल और घोषणाओं के बारे में सामान्य जानकारी के लिए एक्सेसिबिलिटी देखें।
#कीबोर्ड
डेस्कटॉप और वेब पर, जहाँ Tab उन व्यू तक पहुँचता है जो प्रेस लेते हैं, Tree WAI-ARIA ट्री व्यू पैटर्न का पालन करता है:
| कुंजी | क्या करती है |
|---|---|
| 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) और वेब: क्लिक वही करता है और ट्री को कीबोर्ड फ़ोकस देता है, और ऊपर दी गई कीज़ उसमें चलती हैं; डबल क्लिक आइटम पर कार्रवाई करता है।
- हर प्लेटफ़ॉर्म वही बनाता है जो ऐप स्टाइल करता है: ट्री, उसकी पंक्तियाँ और उनकी सामग्री।