Elemente
Tabs
Tabs wechseln zwischen den Abschnitten eines Ortes: der Übersicht und der Aktivität eines Projekts, den Ansichten eines Dokuments.
Jedes Tab steuert das Verhalten eines Tabs: Drücken, Tastaturaktivierung, den deaktivierten Zustand sowie die Tab-Rolle und ihren ausgewählten Zustand für Screenreader. Ein Tabs darum herum ist die Tab-Liste, die Screenreader als eine Gruppe ansagen. Das Aussehen bestimmt die App: Ein Tab hat keine eigenen Farben, Größen oder Beschriftungen. Seine Kinder sind die Beschriftung der App, und selected:-Klassen am Tab oder group-selected:-Klassen auf dem, was darin liegt, folgen seinem Zustand. Auch der Inhalt jedes Abschnitts gehört der App: Sie zeigt neben der Liste den Abschnitt, den ihr Wert benennt. Der Indikator der Liste, eine Unterstreichung oder eine Pille, die die App gestaltet, sitzt auf dem ausgewählten Tab und wandert nativ zum nächsten (Indikator).
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 und Tabs stammen aus nsh/base, dem nsh-base-Crate in Rust. Der Wert gehört der App: Die Liste hält keinen. Jedem Tab wird mitgeteilt, ob er ausgewählt ist; ein Druck meldet onPress, die App setzt ihren Wert und zeigt diesen Abschnitt, und jeder Tab zeigt den selected, den die App zurückgibt. Die Ecken der Liste und die der Tabs sind konzentrisch: Das Padding der Liste (p-1) ist die Differenz zwischen radius.lg und radius. Die Füllung des ausgewählten Tabs ist der Indikator der Liste (indicatorClass, innerhalb dieses Paddings: inset-y-1), der zu dem Tab springt, den die App auswählt. In Rust erzeugen tab() und tabs() sie, mit einem Setter pro Prop (.selected(..)), einem Listener pro Ereignis (.on_press(..)), den typisierten Stilmethoden und .child(..) für ihren Inhalt.
#Props
#Tab
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
selected |
.selected(..) |
boolean |
false |
Der aktuelle Tab seiner Liste: Screenreader sagen, dass er ausgewählt ist, und selected:-Stile gelten. Ein Druck meldet onPress; der Tab zeigt den Wert, den die App zurückgibt. |
disabled |
.disabled(..) |
boolean |
false |
Keine Drücke und kein Druck-Feedback; disabled:-Stile gelten, und Screenreader sagen, dass er abgeblendet ist. |
selectedStyle |
.selected_style(|s| ..) |
StyleProp<ViewStyle> |
keiner | Der Stil des Tabs, solange er ausgewählt und nicht deaktiviert ist, als selected:-Klassen. |
accessibilityLabel |
.accessibility_label(..) |
string |
keiner | Was Screenreader ansagen: der Name des Tabs, wenn seine Kinder keinen Text enthalten, der ihn nennt (ein Icon). |
children |
.child(..) |
JSX.Element |
keiner | Die Beschriftung, das Icon oder das Badge der App, das Screenreader ansagen. |
class, style |
Stilmethoden | string, StyleProp<ViewStyle> |
keiner | Der Tab selbst: die Fläche um seine Beschriftung. |
activeStyle, hoverStyle, focusStyle, disabledStyle |
.active(|s| ..), .hover(..), .focus(..), .disabled_style(..) |
StyleProp<ViewStyle> |
keiner | Der Stil des Tabs, solange er gedrückt, überfahren, fokussiert oder deaktiviert ist, als <state>:-Klassen. |
#Tabs
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
indicatorClass |
.indicator_class(..) |
string |
keiner | Die Tailwind-Klassen des Indikators auf dem ausgewählten Tab: sein Aussehen und seine Position quer zur Liste (bottom-0 h-0.5 für eine Unterstreichung in einer Zeile, inset-y-1 für eine Pille). Entlang der Liste überspannt er den ausgewählten Tab, innerhalb seiner eigenen Ränder auf dieser Achse. Mit indicatorClass oder indicatorStyle hat die Liste einen Indikator. |
indicatorStyle |
.indicator_style(|s| ..) |
StyleProp<ViewStyle> |
keiner | Der Stil des Indikators, über indicatorClass. |
children |
.child(..) |
JSX.Element |
keiner | Die Tabs der Liste und alles, was die App dazwischen anordnet. |
class, style |
Stilmethoden | string, StyleProp<ViewStyle> |
keiner | Layout und Aussehen der Liste: standardmäßig eine Spalte, flex-row für eine Zeile. |
#Ereignisse
| Ereignis | Rust | Beschreibung |
|---|---|---|
onPress (Tab) |
.on_press(|_: &()| ..) |
Der Tab wurde gedrückt: ein Tippen, ein Klick, Leertaste oder Return, während er den Tastaturfokus hat, oder eine Aktivierung durch einen Screenreader; oder eine Pfeiltaste, Pos1 oder Ende seiner Liste hat den Tastaturfokus auf ihn bewegt. Es wird ausgeführt, bevor sich etwas ändert: Der Tab zeigt den selected, den die App zurückgibt. |
Der ausgewählte Tab meldet seine Drücke ebenfalls, sodass die App auf einen zweiten Druck reagieren kann (zum Beispiel ihren Abschnitt an den Anfang scrollen). Ein deaktivierter Tab meldet nichts.
#Teile
| Teil | Enthalten in | Gestaltet mit | Zustände |
|---|---|---|---|
| Die Liste | Tabs: class, style |
||
| Der Indikator | die Liste, hinter den Tabs | indicatorClass, indicatorStyle |
auf dem ausgewählten Tab |
| Der Tab | die Liste | Tab: class, style |
selected:, disabled:, active:, hover:, focus: (oder selectedStyle, disabledStyle, …) |
| Die Beschriftung | der Tab | eigene Elemente (children) |
group-selected:, group-disabled: und die anderen group-*:-Varianten mit group am class des Tabs |
| Der Inhalt des Abschnitts | neben der Liste | die Elemente der App | der Wert der App |
Zeigen Sie den ausgewählten Tab über seine eigene Fläche: eine Füllung, eine kräftigere Beschriftung, eine stärkere Schriftstärke (siehe Design-Leitfäden).
#Indikator
Eine Unterstreichung unter dem ausgewählten Tab, eine Pille dahinter oder das erhabene Segment eines segmentierten Controls ist der Indikator der Liste: eine View, die die App mit indicatorClass und indicatorStyle gestaltet, hinter den Tabs gezeichnet und in der Liste absolut positioniert. Native Shell platziert ihn nach jedem Layout auf dem ausgewählten Tab. Entlang der Liste (die Breite einer Zeile, die Höhe einer Spalte) überspannt er diesen Tab, innerhalb der eigenen Ränder des Indikators auf dieser Achse (mx-3 für eine Unterstreichung, die kürzer als ihr Tab ist); quer zur Liste platzieren ihn seine Klassen:
// 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>
Wählt die App einen anderen Tab aus, springt der Indikator nativ dorthin, so wie der Indikator der Tab-Leiste von GPUI Kit; bei reduzierter Bewegung wechselt er sofort. Solange kein Tab ausgewählt ist, ist er ausgeblendet; sind mehrere ausgewählt, sitzt er auf dem ersten. Eine Größenänderung oder eine Änderung der Breite eines Tabs bewegt ihn im selben Frame wie die Tabs. Der Indikator braucht keine Layout-Ereignisse und keinen Code der App: Der ausgewählte Tab ist der, dessen selected wahr ist. In einer Liste, die scrollt, umschließt die Scroll-View das Tabs, und der Indikator scrollt mit den Tabs.
#Deaktiviert
Ein deaktivierter Tab nimmt keine Drücke an, und Screenreader sagen ihn als abgeblendet an. Seine disabled:-Klassen gelten über seinen selected:-Klassen, und group-disabled:-Klassen darin folgen:
<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>
#Barrierefreiheit
- Ein Tab ist für Screenreader ein Element mit der Rolle
"tab"und seinem ausgewählten Zustand. Seine Beschriftung ist der Text seiner Kinder oderaccessibilityLabel. Tabshat die Rolle"tablist": Screenreader hören die Tabs darin als eine Gruppe, jeder davon bleibt ein eigenes Element.disabledwird so angesagt, wie die Plattform es ansagt (VoiceOver, TalkBack, UI Automation, der Accessibility-Tree des Browsers).- Unter macOS und im Web ist die Liste ein Haltepunkt des Tastaturfokus, und die Pfeiltasten bewegen zwischen ihren Tabs (siehe unten).
- Der Indikator ist für Screenreader ausgeblendet und nimmt keinen Fokus an: Nur die Tabs sind die Elemente der Liste.
Allgemeines zu Beschriftungen, Rollen und Ansagen finden Sie unter Barrierefreiheit.
#Tastatur
Unter macOS und im Web, wo Tab die Views erreicht, die Drücke annehmen, folgt eine Tabs-Liste dem
WAI-ARIA-Tabs-Muster:
| Taste | Wirkung |
|---|---|
| Tab, Umschalt+Tab | Betritt die Liste am ausgewählten Tab (am ersten Tab, der nicht deaktiviert ist, solange keiner ausgewählt ist) und verlässt sie in einem Schritt. |
| Rechts, Runter | Bewegt den Fokus zum nächsten Tab, vom letzten zum ersten. |
| Links, Hoch | Bewegt den Fokus zum vorherigen Tab, vom ersten zum letzten. |
| Pos1, Ende | Bewegt den Fokus zum ersten oder zum letzten Tab. |
| Leertaste, Return | Drückt den fokussierten Tab. |
Die Pfeiltasten überspringen deaktivierte Tabs, und der Tab, zu dem sie bewegen, meldet onPress, sodass die App ihn auswählt, während der Fokus wandert: Der Abschnitt folgt dem Fokus. Beide Pfeilpaare bewegen den Fokus, unabhängig vom Layout der Liste, das über die Klassen der App festgelegt wird. In einem Rechts-nach-links-Layout läuft die Zeile von rechts, sodass Links und Rechts die Plätze tauschen: Links bewegt zum nächsten Tab und Rechts zum vorherigen; Hoch, Runter, Pos1 und Ende behalten ihre Bedeutung. focus:-Stile zeigen, wo der Fokus ist. Ein Tab außerhalb eines Tabs ist ein eigener Haltepunkt.
#Hinweise zu Plattformen
- iOS, Android und Mac Catalyst: Ein Druck ist ein Tippen, das auf dem Tab endet, seine Beschriftung eingeschlossen.
- Desktops (macOS, Windows, Linux, GPUI) und das Web: Ein Druck ist ein Klick. Unter macOS und im Web drücken außerdem Leertaste und Return den Tab, der den Tastaturfokus hat, und die Pfeiltasten, Pos1 und Ende bewegen ihn (Tastatur).
- Jede Plattform zeichnet, was die App gestaltet: die Tabs, die Fläche des ausgewählten Tabs, den Indikator und den Inhalt der Abschnitte. Die Bewegung des Indikators ist auf jeder Plattform eine native Layout-Animation.