العناصر
Tabs
تنقل علامات التبويب بين أقسام مكان واحد: نظرة عامة على مشروع ونشاطه، أو عروض مستند.
يتولى كل Tab سلوك علامة تبويب واحدة: الضغطات، والتفعيل بلوحة المفاتيح، وحالة التعطيل، ودور علامة التبويب وحالة تحديدها لقارئات الشاشة. ويشكّل Tabs المحيط بها قائمة علامات التبويب، وتعلنها قارئات الشاشة كمجموعة واحدة. المظهر من شأن التطبيق: فلا ألوان لعلامة التبويب ولا أحجام ولا تسمية خاصة بها. أبناؤها هم تسمية التطبيق، وتتبع حالتها فئات selected: على علامة التبويب، أو فئات group-selected: على ما بداخلها. ومحتوى كل قسم من شأن التطبيق أيضًا: يعرض القسم الذي تسميه قيمته، بجانب القائمة. أما مؤشر القائمة، وهو خط سفلي أو حبة ينسّقها التطبيق، فيوضع على علامة التبويب المحددة وينتقل أصليًا إلى التالية (المؤشر).
import { mount, signal, Text, View } from "nsh";
import { Tab, Tabs } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, size, Split, tint, ui } from "./palette";
// What each section shows: the app switches the content by its own value.
const sections = [
{
id: "overview",
label: "Overview",
rows: [
["Visitors", "12,480"],
["Sign-ups", "342"],
["Conversion", "2.74%"],
],
},
{
id: "activity",
label: "Activity",
rows: [
["Deploy to production", "09:41"],
["Invoice paid", "08:15"],
["New member joined", "07:02"],
],
},
{
id: "settings",
label: "Settings",
rows: [
["Region", "Frankfurt"],
["Plan", "Pro"],
["Seats", "8 of 10"],
],
},
];
const folders = [
{ id: "inbox", label: "Inbox", count: 12 },
{ id: "archive", label: "Archive", count: 0 },
{ id: "spam", label: "Spam", count: 3, locked: true },
];
function App() {
const section = signal("overview");
const folder = signal("inbox");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 3));
const current = () => sections.find((s) => s.id === section())!;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>tabs</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click a tab, or Tab to a list and move with the arrow keys" : "Tap a tab to show its section"}
</Text>
<Split gap={14} inspector={<Log title={`onPress · ${section()}, ${folder()}`} lines={events()} minHeight={76} />}>
{/* A segmented tab list: each tab is a segment, and the list's indicator is the raised one, inside the
list's padding, which springs to the tab the app selects. */}
<Tabs
style={{ flexDirection: "row", gap: 4, padding: 3, borderRadius: 10, ...ui().inset, ...(desktop ? { alignSelf: "flex-start" } : {}) }}
indicatorStyle={{ top: 3, bottom: 3, borderRadius: 7, ...ui().raised }}
>
<Each of={sections}>
{(s) => (
<Tab
selected={section() === s().id}
onPress={() => {
section.set(s().id);
log(`${s().label} pressed`);
}}
style={
desktop
? { height: 26, paddingHorizontal: 14, borderRadius: 7, alignItems: "center", justifyContent: "center" }
: { flex: 1, height: 30, borderRadius: 7, alignItems: "center", justifyContent: "center" }
}
class="active:opacity-70"
hoverStyle={section() === s().id ? undefined : hoverFill()}
focusStyle={focusRing()}
>
<Text style={{ fontSize: 13, fontWeight: "500", color: section() === s().id ? ui().text : ui().muted }}>
{s().label}
</Text>
</Tab>
)}
</Each>
</Tabs>
{/* The selected section's content, which the app shows by its value. */}
<View style={{ ...ui().card, borderRadius: 14, overflow: "hidden" }}>
<Each of={current().rows}>
{(row, i) => (
<>
{i() > 0 ? <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} /> : null}
<View
style={
desktop
? { flexDirection: "row", alignItems: "center", paddingHorizontal: 12, height: size.row }
: { flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingVertical: 12 }
}
>
<Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>{row()[0]}</Text>
<Text style={{ fontSize: 14, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().muted }}>{row()[1]}</Text>
</View>
</>
)}
</Each>
</View>
{/* Tabs with counts: the selected one's own fill, a disabled one dimmed. */}
<Tabs style={{ flexDirection: "row", gap: 6 }}>
<Each of={folders}>
{(f) => (
<Tab
selected={folder() === f().id}
disabled={f().locked}
onPress={() => {
folder.set(f().id);
log(`${f().label} pressed`);
}}
class="active:opacity-70 disabled:opacity-40"
hoverStyle={f().locked || folder() === f().id ? undefined : hoverFill()}
focusStyle={focusRing()}
style={{ flexDirection: "row", alignItems: "center", gap: 6, height: desktop ? size.controlSm : 30, paddingHorizontal: desktop ? 10 : 12, borderRadius: 8 }}
selectedStyle={{ backgroundColor: tint(ui().accent, 0.16) }}
>
<Text style={{ fontSize: 13, fontWeight: "500", color: folder() === f().id ? ui().accent : ui().muted }}>
{f().label}
</Text>
{f().count > 0 ? (
<Text style={{ fontSize: 12, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().faint }}>{f().count}</Text>
) : null}
</Tab>
)}
</Each>
</Tabs>
</Split>
</View>
);
}
mount(App);use nsh_base::{tab, tabs, theme};
use nsh_core::prelude::*;
fn sections() -> impl IntoElement {
let section = signal("overview");
let item = move |value: &'static str, label: &'static str| {
let selected = move || section.get() == value;
tab()
.selected(selected)
.on_press(move |_: &()| section.set(value))
.rounded(move || theme().radius)
.px_3()
.py_2()
.child(text(label).text_sm().text_color(move || {
if selected() { theme().foreground } else { theme().muted_foreground }
}))
};
view()
.gap_4()
.child(
tabs()
.flex_row()
.self_start()
.gap_1()
.p_1()
.rounded(move || theme().radius)
.bg(move || theme().muted)
.indicator_style(|s| {
s.top_1()
.bottom_1()
.rounded(move || theme().radius)
.bg(move || theme().background)
})
.child(item("overview", "Overview"))
.child(item("activity", "Activity")),
)
.child(text(move || {
if section.get() == "overview" {
"Visitors, sign-ups and conversion this week."
} else {
"Deploys, invoices and new members, newest first."
}
}).text_color(move || theme().foreground))
}import { mount, signal, Text, View } from "nsh";
import { Tab, Tabs } from "nsh/base";
function App() {
const section = signal("overview");
const item = (value: string, label: string) => (
<Tab
selected={section() === value}
onPress={() => section.set(value)}
class="group rounded-(--radius) px-3 py-2"
>
<Text class="text-sm text-muted-foreground group-selected:text-foreground">{label}</Text>
</Tab>
);
return (
<View class="gap-4">
<Tabs
class="flex-row self-start gap-1 rounded-(--radius-lg) bg-muted p-1"
indicatorClass="inset-y-1 rounded-(--radius) bg-background"
>
{item("overview", "Overview")}
{item("activity", "Activity")}
</Tabs>
<Text class="text-foreground">
{section() === "overview"
? "Visitors, sign-ups and conversion this week."
: "Deploys, invoices and new members, newest first."}
</Text>
</View>
);
}
mount(App);يأتي Tab وTabs من nsh/base، وهي حزمة nsh-base في Rust. القيمة من شأن التطبيق: فالقائمة لا تحتفظ بأي قيمة. تُبلَّغ كل علامة تبويب بما إذا كانت محددة؛ وتُبلِّغ الضغطة عن onPress، فيضبط التطبيق قيمته ويعرض ذلك القسم، وتعرض كل علامة تبويب selected الذي يمرره التطبيق. زوايا القائمة وزوايا علامات التبويب متحدة المركز: حشوة القائمة (p-1) هي الفرق بين radius.lg وradius. وتعبئة علامة التبويب المحددة هي مؤشر القائمة (indicatorClass، داخل تلك الحشوة: inset-y-1)، وهو ينتقل بحركة نابضة إلى علامة التبويب التي يحددها التطبيق. في Rust، يُنشئها tab() وtabs()، مع دالة ضبط لكل خاصية (.selected(..))، ومستمع لكل حدث (.on_press(..))، ودوال التنسيق المطبوعة و.child(..) لمحتواها.
#الخصائص
#Tab
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
selected | .selected(..) | boolean | false | علامة التبويب الحالية في قائمتها: تعلن قارئات الشاشة أنها محددة، وتُطبَّق تنسيقات selected:. تُبلِّغ الضغطة عن onPress؛ وتعرض علامة التبويب القيمة التي يمررها التطبيق. |
disabled | .disabled(..) | boolean | false | لا ضغطات ولا استجابة بصرية للضغط؛ وتُطبَّق تنسيقات disabled:، وتعلن قارئات الشاشة أنها خافتة. |
selectedStyle | .selected_style(|s| ..) | StyleProp<ViewStyle> | لا شيء | تنسيق علامة التبويب أثناء تحديدها وعدم تعطيلها، على شكل فئات selected:. |
accessibilityLabel | .accessibility_label(..) | string | لا شيء | ما تقوله قارئات الشاشة: اسم علامة التبويب، عندما لا يكون في أبنائها نص يقوله (كأيقونة). |
children | .child(..) | JSX.Element | لا شيء | تسمية التطبيق أو أيقونته أو شارته، وتقرؤها قارئات الشاشة. |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | علامة التبويب نفسها: السطح المحيط بتسميتها. |
activeStyle، hoverStyle، focusStyle، disabledStyle | .active(|s| ..)، .hover(..)، .focus(..)، .disabled_style(..) | StyleProp<ViewStyle> | لا شيء | تنسيق علامة التبويب أثناء الضغط عليها أو تمرير المؤشر فوقها أو تركيزها أو تعطيلها، على شكل فئات <state>:. |
#Tabs
| الخاصية | Rust | النوع | الافتراضي | الوصف |
|---|---|---|---|---|
indicatorClass | .indicator_class(..) | string | لا شيء | فئات Tailwind الخاصة بالمؤشر على علامة التبويب المحددة: مظهره ومكانه عبر القائمة (bottom-0 h-0.5 لخط سفلي في صف، inset-y-1 لحبة). وعلى امتداد القائمة يغطي علامة التبويب المحددة، داخل هوامشه الخاصة على ذلك المحور. مع indicatorClass أو indicatorStyle يكون للقائمة مؤشر. |
indicatorStyle | .indicator_style(|s| ..) | StyleProp<ViewStyle> | لا شيء | تنسيق المؤشر، فوق indicatorClass. |
children | .child(..) | JSX.Element | لا شيء | علامات تبويب القائمة، وما يضعه التطبيق بينها. |
class، style | دوال التنسيق | string، StyleProp<ViewStyle> | لا شيء | تخطيط القائمة ومظهرها: عمود افتراضيًا، وflex-row للصف. |
#الأحداث
| الحدث | Rust | الوصف |
|---|---|---|
onPress (Tab) | .on_press(|_: &()| ..) | ضُغطت علامة التبويب: نقرة لمس أو نقرة بالفأرة، أو Space أو Return أثناء امتلاكها تركيز لوحة المفاتيح، أو تفعيل من قارئ شاشة؛ أو نقل مفتاح سهم أو Home أو End في قائمتها تركيز لوحة المفاتيح إليها. يعمل قبل أن يتغير أي شيء: تعرض علامة التبويب selected الذي يمرره التطبيق. |
تُبلِّغ علامة التبويب المحددة عن ضغطاتها أيضًا، ليتمكن التطبيق من التصرف عند ضغطة ثانية (كتمرير قسمها إلى الأعلى مثلًا). لا تُبلِّغ علامة التبويب المعطلة عن أي شيء.
#الأجزاء
| الجزء | داخل | التنسيق بـ | الحالات |
|---|---|---|---|
| القائمة | class وstyle في Tabs | ||
| المؤشر | القائمة، خلف علامات التبويب | indicatorClass، indicatorStyle | على علامة التبويب المحددة |
| علامة التبويب | القائمة | class وstyle في Tab | selected:، disabled:، active:، hover:، focus: (أو selectedStyle، disabledStyle، …) |
| التسمية | علامة التبويب | عناصرها الخاصة (children) | group-selected: وgroup-disabled: وبقية متغيرات group-*: مع group على class الخاص بعلامة التبويب |
| محتوى القسم | بجانب القائمة | عناصر التطبيق | قيمة التطبيق |
أظهر علامة التبويب المحددة عبر سطحها الخاص: تعبئة، أو تسمية أقوى، أو وزن أثقل (راجع أدلة التصميم).
#المؤشر
الخط السفلي تحت علامة التبويب المحددة، أو الحبة خلفها، أو القطعة البارزة في عنصر تحكم مقسّم، هو
مؤشر القائمة: عرض ينسّقه التطبيق بـ indicatorClass وindicatorStyle، ويُرسم خلف علامات التبويب ويُوضع
بموضع مطلق في القائمة. يضعه Native Shell على علامة التبويب المحددة بعد كل تخطيط. وعلى امتداد القائمة (عرض الصف،
أو ارتفاع العمود) يغطي تلك العلامة، داخل هوامش المؤشر الخاصة على ذلك المحور (mx-3 لخط سفلي
أقصر من علامته)؛ أما عبر القائمة فتحدد فئاته مكانه:
// An underline at the bottom of a row of tabs.
<Tabs class="flex-row gap-4 border-b border-border" indicatorClass="bottom-0 h-0.5 bg-foreground">
{item("overview", "Overview")}
{item("activity", "Activity")}
</Tabs>
// A pill behind the selected tab, inside the list's padding.
<Tabs class="flex-row self-start gap-1 rounded-(--radius-lg) bg-muted p-1" indicatorClass="inset-y-1 rounded-(--radius) bg-background shadow-sm">
{item("overview", "Overview")}
{item("activity", "Activity")}
</Tabs>
عندما يحدد التطبيق علامة تبويب أخرى، ينتقل المؤشر إليها بحركة نابضة أصلية، كما يفعل مؤشر شريط علامات التبويب في GPUI Kit؛ ومع
تقليل الحركة ينتقل فورًا. ويكون مخفيًا ما دامت لا توجد علامة محددة؛ وإذا كانت عدة علامات محددة فهو على الأولى. ويحرّكه تغيير حجم النافذة أو تغيّر عرض إحدى العلامات في الإطار نفسه مع العلامات.
لا يحتاج المؤشر إلى أحداث تخطيط ولا إلى أي شيفرة من التطبيق: علامة التبويب المحددة هي التي يكون selected فيها صحيحًا. وفي
قائمة قابلة للتمرير، يحيط عرض التمرير بـ Tabs، ويتمرر المؤشر مع علامات التبويب.
#معطّل
لا تقبل علامة التبويب المعطلة أي ضغطات، وتعلنها قارئات الشاشة بأنها خافتة. تُطبَّق فئات disabled: فوق فئات
selected:، وتتبعها فئات group-disabled: بداخلها:
<Tab
selected={section() === "billing"}
disabled={!isOwner()}
onPress={() => section.set("billing")}
class="group rounded-(--radius) px-3 py-2 selected:bg-background disabled:opacity-50"
>
<Text class="text-sm text-muted-foreground group-selected:text-foreground">Billing</Text>
</Tab>
#إمكانية الوصول
- علامة التبويب عنصر واحد لقارئات الشاشة، بدور
"tab"وحالة تحديدها. وتسميتها هي نص أبنائها، أوaccessibilityLabel. Tabsله الدور"tablist": تسمع قارئات الشاشة علامات التبويب بداخله كمجموعة واحدة، وتبقى كل منها عنصرًا مستقلًا.- يُعلَن
disabledبالطريقة التي تعلنه بها المنصة (VoiceOver وTalkBack وUI Automation وشجرة إمكانية الوصول في المتصفح). - على macOS والويب تمثل القائمة محطة واحدة لتركيز لوحة المفاتيح، وتنقل مفاتيح الأسهم بين علامات التبويب فيها (أدناه).
- المؤشر مخفي عن قارئات الشاشة ولا يأخذ التركيز: علامات التبويب وحدها هي عناصر القائمة.
راجع إمكانية الوصول للاطلاع على التسميات والأدوار والإعلانات بوجه عام.
#لوحة المفاتيح
على macOS والويب، حيث يصل Tab إلى العروض التي تقبل الضغطات، تتبع قائمة Tabs
نمط علامات التبويب في WAI-ARIA:
| المفتاح | ما يفعله |
|---|---|
| Tab، Shift+Tab | يدخل القائمة عند علامة التبويب المحددة (أول علامة غير معطلة إن لم تكن هناك علامة محددة)، ويغادرها بخطوة واحدة. |
| السهم الأيمن، السهم السفلي | ينقل التركيز إلى علامة التبويب التالية، ومن الأخيرة إلى الأولى. |
| السهم الأيسر، السهم العلوي | ينقل التركيز إلى علامة التبويب السابقة، ومن الأولى إلى الأخيرة. |
| Home، End | ينقل التركيز إلى أول علامة تبويب أو آخرها. |
| Space، Return | يضغط علامة التبويب التي عليها التركيز. |
تتخطى مفاتيح الأسهم علامات التبويب المعطلة، وتُبلِّغ العلامة التي تنتقل إليها عن onPress، فيحددها التطبيق
مع انتقال التركيز: يتبع القسم التركيز. يعمل زوجا الأسهم معًا مهما كان تخطيط القائمة، فهو من فئات
التطبيق. وفي التخطيط من اليمين إلى اليسار يبدأ الصف من اليمين، فيتبادل اليسار واليمين موضعيهما: ينقل اليسار إلى
علامة التبويب التالية واليمين إلى السابقة؛ أما السهم العلوي والسفلي وHome وEnd فتبقى على حالها. تُظهر تنسيقات focus: موضع التركيز. وأي Tab خارج Tabs هو محطة مستقلة بذاته.
#ملاحظات المنصات
- iOS وAndroid وMac Catalyst: الضغطة هي لمسة تنتهي على علامة التبويب، بما في ذلك تسميتها.
- أنظمة سطح المكتب (macOS وWindows وLinux وGPUI) والويب: الضغطة هي نقرة. وعلى macOS والويب، يضغط Space وReturn أيضًا علامة التبويب التي عليها تركيز لوحة المفاتيح، وتنقله مفاتيح الأسهم وHome وEnd (لوحة المفاتيح).
- كل منصة ترسم ما ينسّقه التطبيق: علامات التبويب، وسطح العلامة المحددة، والمؤشر، ومحتوى الأقسام. وانتقال المؤشر هو حركة تخطيط أصلية على كل منصة.