Элементы
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 | Входит в список на выбранной вкладке (на первой неотключённой, если ни одна не выбрана) и покидает его за один шаг. |
| Right, Down | Перемещает фокус на следующую вкладку, с последней на первую. |
| Left, Up | Перемещает фокус на предыдущую вкладку, с первой на последнюю. |
| Home, End | Перемещает фокус на первую или последнюю вкладку. |
| Space, Return | Нажимает вкладку, на которой находится фокус. |
Стрелки пропускают отключённые вкладки, а вкладка, на которую они переходят, сообщает onPress, поэтому приложение выбирает её по мере перемещения фокуса: раздел следует за фокусом. Работают обе пары стрелок независимо от раскладки списка, которая задаётся классом приложения. В раскладке справа налево строка идёт справа, поэтому Left и Right меняются местами: Left переходит к следующей вкладке, а Right — к предыдущей; Up, Down, Home и End остаются прежними. Стили focus: показывают, где находится фокус. Tab вне Tabs — отдельная остановка.
#Особенности платформ
- iOS, Android и Mac Catalyst: нажатие — это касание, заканчивающееся на вкладке, включая её подпись.
- Настольные платформы (macOS, Windows, Linux, GPUI) и веб: нажатие — это клик. На macOS и в вебе Space и Return также нажимают вкладку, у которой фокус клавиатуры, а стрелки, Home и End перемещают его (Клавиатура).
- На каждой платформе рисуется то, что стилизует приложение: вкладки, поверхность выбранной вкладки, индикатор и содержимое разделов. Перемещение индикатора — нативная анимация раскладки на каждой платформе.