要素
Tabs
タブは、1か所の中のセクションを切り替えます。プロジェクトの概要とアクティビティ、ドキュメントのビューなどです。
各Tabは1つのタブの動作を担います。具体的には、押下、キーボードによる操作、無効状態、スクリーンリーダー向けのタブロールとその選択状態です。それらを囲むTabsはタブリストで、スクリーンリーダーはこれを1つのまとまりとして読み上げます。見た目はアプリ側のものです。タブ自身には色もサイズもラベルもありません。子要素がアプリのラベルとなり、タブ上のselected:クラスや、タブの内側にある要素のgroup-selected:クラスがその状態に追従します。各セクションのコンテンツもアプリ側のものです。リストの隣に、value が指すセクションを表示します。リストのインジケーターは、アプリがスタイルを指定する下線やピルで、選択中のタブの上に置かれ、次のタブへネイティブにアニメーションしながら移動します
(インジケーター)。
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は、Rust のnsh-baseクレートであるnsh/baseに含まれます。value はアプリ側のものであり、リストは値を持ちません。各タブには選択中かどうかが伝えられ、押下するとonPressが通知されます。アプリは value を設定してそのセクションを表示し、すべてのタブはアプリが返す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を表示します。 |
選択中のタブも押下を通知するため、アプリは2回目の押下に対して処理を行えます(たとえば、そのセクションを先頭までスクロールするなど)。 無効なタブは何も通知しません。
#パーツ
| パーツ | 内側 | スタイルの指定方法 | 状態 |
|---|---|---|---|
| リスト | Tabsのclass、style | ||
| インジケーター | リストの中、タブの背面 | indicatorClass、indicatorStyle | 選択中のタブ上 |
| タブ | リスト | Tabのclass、style | selected:、disabled:、active:、hover:、focus:(またはselectedStyle、disabledStyle など) |
| ラベル | タブ | 独自の要素(children) | group-selected:、group-disabled:、およびタブのclassにgroupを付けたその他のgroup-*:バリアント |
| セクションのコンテンツ | リストの隣 | アプリの要素 | アプリの value |
選択中のタブは、そのタブ自身のサーフェスで示してください。塗り、より強いラベル、より太いウェイトなどです (デザインガイドを参照)。
#インジケーター
選択中のタブの下の下線、背面のピル、セグメントコントロールで浮き上がるセグメントは、いずれもリストのインジケーターです。これは、アプリが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が true のタブのことです。スクロールするリストでは、スクロールビューを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>
#アクセシビリティ
- タブは、スクリーンリーダーにとって1つの要素で、ロールは
"tab"、さらに選択状態を持ちます。ラベルは子要素のテキスト、またはaccessibilityLabelです。 Tabsのロールは"tablist"です。スクリーンリーダーは、その中のタブを1つのまとまりとして読み上げますが、各タブは引き続きそれぞれ独立した要素です。disabledは、各プラットフォームの方法で読み上げられます(VoiceOver、TalkBack、UI Automation、ブラウザーのアクセシビリティツリー)。- macOS とウェブでは、リストはキーボードフォーカスの1つの停止位置となり、矢印キーでそのタブ間を移動します(後述)。
- インジケーターはスクリーンリーダーからは隠され、フォーカスも受けません。リストの要素はタブだけです。
ラベル、ロール、読み上げ全般については、アクセシビリティを参照してください。
#キーボード
Tab キーで押下を受け付けるビューに到達できる macOS とウェブでは、Tabsリストは
WAI-ARIA tabs patternに従います。
| キー | 動作 |
|---|---|
| Tab、Shift+Tab | 選択中のタブ(どれも選択されていない間は、無効でない最初のタブ)でリストに入り、1回の操作でリストを抜けます。 |
| Right、Down | フォーカスを次のタブへ移動します。最後のタブからは最初のタブへ移動します。 |
| Left、Up | フォーカスを前のタブへ移動します。最初のタブからは最後のタブへ移動します。 |
| Home、End | フォーカスを最初または最後のタブへ移動します。 |
| Space、Return | フォーカス中のタブを押下します。 |
矢印キーは無効なタブをスキップし、移動先のタブはonPressを通知するため、アプリはフォーカスの移動に合わせてそのタブを選択します。つまり、セクションはフォーカスに追従します。リストのレイアウトはアプリ側のクラスで決まりますが、どのレイアウトでも、2組の矢印キーはどちらも移動します。右から左へのレイアウトでは行が右から始まるため、Left と Right の役割が入れ替わります。Left で次のタブへ、Right で前のタブへ移動します。Up、Down、Home、End は変わりません。focus:スタイルがフォーカスの位置を示します。Tabsの外にあるTabは、それ自体が独立した停止位置になります。
#プラットフォームに関する注記
- iOS、Android、Mac Catalyst: 押下は、ラベルを含むタブの上で終わるタップです。
- デスクトップ(macOS、Windows、Linux、GPUI)とウェブ: 押下はクリックです。macOS とウェブでは、Space と Return もキーボードフォーカスのあるタブを押下し、矢印キー、Home、End でフォーカスを移動します(キーボード)。
- どのプラットフォームでも、アプリがスタイルを指定したものが描画されます。タブ、選択中のタブのサーフェス、インジケーター、セクションのコンテンツです。インジケーターの移動は、どのプラットフォームでもネイティブのレイアウトアニメーションです。