要素
Toolbar
ツールバーは、コントロールを並べたバーです。テキストエディタの書式設定、メディアプレーヤーの操作、縦に並ぶツールパレットなどがあたります。
Toolbar はそのキーボード操作とセマンティクスを担います。スクリーンリーダーはツールバーを(accessibilityLabel で名前を付けて)読み上げ、キーボードは 1 回の停止でツールバーに到達し、axis の矢印キーでアイテム間を移動します。アイテムは、ツールバーの内側にある Button、Toggle、Link で、階層の深さは問いません。ToolbarGroup は関連するアイテムのまとまり(元に戻すとやり直し、配置など)に名前を付けたもの、ToolbarSeparator はまとまりの間にアプリが描く線です。見た目はアプリ側で決めます。ツールバー自体は色、パディング、ギャップを持たず、各コントロールが自身の状態を保持します。
import { mount, signal, Text, View } from "nsh";
import { Button, Toggle, ToggleGroup, Toolbar, ToolbarGroup, ToolbarSeparator } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Split, ui } from "./palette";
type Mark = "bold" | "italic" | "underline";
type Align = "left" | "center" | "right";
type Tool = "select" | "pen" | "shape" | "text";
const marks: { id: Mark; label: string; glyph: string }[] = [
{ id: "bold", label: "Bold", glyph: "B" },
{ id: "italic", label: "Italic", glyph: "I" },
{ id: "underline", label: "Underline", glyph: "U" },
];
const aligns: { id: Align; label: string; glyph: string }[] = [
{ id: "left", label: "Align left", glyph: "⇤" },
{ id: "center", label: "Align center", glyph: "↔" },
{ id: "right", label: "Align right", glyph: "⇥" },
];
const tools: { id: Tool; label: string; glyph: string }[] = [
{ id: "select", label: "Select", glyph: "↖" },
{ id: "pen", label: "Pen", glyph: "✎" },
{ id: "shape", label: "Rectangle", glyph: "▢" },
{ id: "text", label: "Text", glyph: "T" },
];
const side = desktop ? 28 : 34;
function App() {
const on = signal<Mark[]>(["bold"]);
const align = signal<Align>("left");
const tool = signal<Tool>("select");
const history = signal<string[]>([]);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 3));
const edit = (what: string) => {
history.set([...history(), what]);
log(what);
};
const has = (m: Mark) => on().includes(m);
// A tool: bare while off, raised while pressed, a hover fill, the focus ring.
const look = (pressed: boolean) => ({
style: { minWidth: side, height: side, paddingHorizontal: 6, borderRadius: 7, alignItems: "center", justifyContent: "center" } as const,
class: "transition-colors active:opacity-70 disabled:opacity-35 motion-reduce:transition-none",
pressedStyle: ui().raised,
hoverStyle: pressed ? undefined : hoverFill(),
focusStyle: focusRing(),
});
const glyph = (text: string, active: boolean) => <Text style={{ fontSize: 14, fontWeight: "600", color: active ? ui().text : ui().muted }}>{text}</Text>;
const rule = (vertical = false) => (
<ToolbarSeparator style={vertical ? { height: 1, alignSelf: "stretch", marginVertical: 4, backgroundColor: ui().line } : { width: 1, height: 16, marginHorizontal: 4, backgroundColor: ui().line }} />
);
const bar = { alignItems: "center", gap: 2, padding: 3, borderRadius: 10, ...ui().inset } as const;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>toolbar</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Tab into a toolbar, then move with the arrow keys; Home and End jump to its ends" : "Tap the tools; a screen reader hears each bar as a toolbar"}
</Text>
<Split gap={14} inspector={<Log title={`${history().length} edits · ${on().join(", ") || "no marks"}, ${align()} · ${tool()}`} lines={events()} minHeight={76} />}>
{/* A formatting bar: history, a toggle group of marks (a group of the toolbar), the alignment. */}
<Toolbar accessibilityLabel="Formatting" style={{ ...bar, alignSelf: "flex-start" }}>
<ToolbarGroup accessibilityLabel="History">
<Button accessibilityLabel="Undo" disabled={history().length === 0} onPress={() => {
history.set(history().slice(0, -1));
log("Undo");
}} {...look(false)}>
{glyph("↶", true)}
</Button>
</ToolbarGroup>
{rule()}
<ToggleGroup style={{ flexDirection: "row", gap: 2 }}>
<Each of={marks}>
{(m) => (
<Toggle
pressed={has(m().id)}
accessibilityLabel={m().label}
onChange={({ pressed }) => {
on.set(pressed ? [...on(), m().id] : on().filter((x) => x !== m().id));
edit(`${m().label}: ${pressed}`);
}}
{...look(has(m().id))}
>
{glyph(m().glyph, has(m().id))}
</Toggle>
)}
</Each>
</ToggleGroup>
{rule()}
<ToolbarGroup accessibilityLabel="Alignment" style={{ gap: 2 }}>
<Each of={aligns}>
{(a) => (
<Toggle
pressed={align() === a().id}
accessibilityLabel={a().label}
onChange={({ pressed }) => {
// Exactly one: the pressed alignment stays pressed.
if (pressed) align.set(a().id);
edit(`${a().label}`);
}}
{...look(align() === a().id)}
>
{glyph(a().glyph, align() === a().id)}
</Toggle>
)}
</Each>
</ToolbarGroup>
</Toolbar>
<View style={{ flexDirection: "row", gap: 14, alignItems: "stretch" }}>
{/* A vertical palette: Up and Down move along it. */}
<Toolbar axis="vertical" accessibilityLabel="Tools" style={{ ...bar, alignSelf: "flex-start" }}>
<Each of={tools}>
{(t) => (
<Toggle
pressed={tool() === t().id}
accessibilityLabel={t().label}
onChange={({ pressed }) => {
if (!pressed) return;
tool.set(t().id);
log(`Tool: ${t().label}`);
}}
{...look(tool() === t().id)}
>
{glyph(t().glyph, tool() === t().id)}
</Toggle>
)}
</Each>
{rule(true)}
<Button accessibilityLabel="Clear canvas" onPress={() => log("Clear canvas")} {...look(false)}>
{glyph("⌫", false)}
</Button>
</Toolbar>
<View style={{ flex: 1, ...ui().card, borderRadius: 14, padding: 14, gap: 6 }}>
<Text
style={{
fontSize: 15,
lineHeight: 22,
color: ui().text,
fontWeight: has("bold") ? "700" : "400",
fontStyle: has("italic") ? "italic" : "normal",
textDecorationLine: has("underline") ? "underline" : "none",
textAlign: align(),
}}
>
One stop of Tab per toolbar; its arrows move between the tools.
</Text>
<Text style={{ fontSize: 13, color: ui().muted }}>{`Tool: ${tools.find((t) => t.id === tool())!.label}`}</Text>
</View>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{button, theme, toggle, toolbar, toolbar_group, toolbar_separator};
use nsh_core::prelude::*;
fn formatting() -> impl IntoElement {
let bold = signal(false);
toolbar()
.accessibility_label("Formatting")
.items_center()
.gap_1()
.child(
toolbar_group()
.accessibility_label("History")
.child(button().accessibility_label("Undo").size_8().child(text("↶")))
.child(button().accessibility_label("Redo").size_8().child(text("↷"))),
)
.child(toolbar_separator().w(px(1.0)).h_5().bg(move || theme().border))
.child(
toggle()
.pressed(bold)
.accessibility_label("Bold")
.on_change(move |pressed: &bool| bold.set(*pressed))
.size_8()
.items_center()
.justify_center()
.pressed_style(|s| s.bg(move || theme().accent))
.child(text("B")),
)
}import { mount, signal, Text } from "nsh";
import { Button, Toggle, Toolbar, ToolbarGroup, ToolbarSeparator } from "nsh/base";
function App() {
const bold = signal(false);
return (
<Toolbar accessibilityLabel="Formatting" class="items-center gap-1 self-start rounded-lg bg-muted p-1">
<ToolbarGroup accessibilityLabel="History">
<Button accessibilityLabel="Undo" class="size-8 items-center justify-center rounded-md hover:bg-accent">
<Text>↶</Text>
</Button>
<Button accessibilityLabel="Redo" class="size-8 items-center justify-center rounded-md hover:bg-accent">
<Text>↷</Text>
</Button>
</ToolbarGroup>
<ToolbarSeparator class="h-5 w-px bg-border" />
<Toggle
pressed={bold()}
accessibilityLabel="Bold"
onChange={({ pressed }) => bold.set(pressed)}
class="size-8 items-center justify-center rounded-md aria-pressed:bg-accent"
>
<Text>B</Text>
</Toggle>
</Toolbar>
);
}
mount(App);Toolbar、ToolbarGroup、ToolbarSeparator は nsh/base から提供されます。Rust では nsh-base クレートです。ツールバーは値を保持しません。内側のトグルは押された状態かどうかを渡されて onChange を報告し、ボタンは onPress を報告し、状態はアプリが保持します。Rust では toolbar()、toolbar_group()、toolbar_separator() でこれらを作成します。プロパティごとのセッター(.axis(..)、.disabled(..))、型付きのスタイルメソッド、内容を指定する .child(..) を備えています。
#プロパティ
#Toolbar
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
axis | .axis(..) | "horizontal" | "vertical" | "horizontal" | アイテムが並ぶ方向。移動に使う矢印キー(左右、または上下)と、子要素のレイアウト方向(行または列)を決めます。ツールバーの作成時に読み取られます。 |
disabled | .disabled(..) | boolean | false | 各アイテム自身の disabled に関係なく、ツールバーのすべてのアイテムを無効にします。押下を受け付けず、キーボードフォーカスの停止位置にならず、スクリーンリーダーには淡色表示として伝わります。 |
accessibilityLabel | .accessibility_label(..) | string | なし | スクリーンリーダーがツールバーを呼ぶ名前(「Formatting」など)。ページに複数のツールバーがある場合に使います。 |
children | .child(..) | JSX.Element | なし | アイテム、そのグループとセパレーター、およびアプリがその間に配置するもの(タイトル、スペーサー、入力欄など)。 |
class、style | スタイルメソッド | string、StyleProp<ViewStyle> | なし | バーのレイアウトと見た目: 配置、ギャップ、パディング、サーフェス。 |
#ToolbarGroup
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | 各アイテム自身の disabled に関係なく、グループのすべてのアイテムを無効にします。 |
accessibilityLabel | .accessibility_label(..) | string | なし | スクリーンリーダーがグループを呼ぶ名前(「History」、「Alignment」など)。 |
children | .child(..) | JSX.Element | なし | グループのアイテム、およびアプリがその間に配置するもの。 |
class、style | スタイルメソッド | string、StyleProp<ViewStyle> | なし | グループのレイアウトと見た目。ツールバーの軸に沿って並びます。 |
#ToolbarSeparator
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
class、style | スタイルメソッド | string、StyleProp<ViewStyle> | なし | 線の指定: 行を横切る細い線(h-5 w-px bg-border)、または列を横切る細い線(h-px self-stretch bg-border)。 |
#アイテム
ツールバーのアイテムは、その内側にある Button、Toggle、Link です。グループの中にあるものや、内側の ToggleGroup の中にあるものも含みます。Menu や Popover のトリガーもアイテムです。これは trigger スロット内のビューで、ポップアップが押下、フォーカス、expanded 状態を与えます。アプリが自前で描画するツールは、アプリのクラスを付けた Button です。Button 自体には見た目がないため、アプリがスタイルしたとおりに表示され、アイテムとして扱われます(onPress を指定した View は独立した停止位置のままで、矢印キーの対象外です)。ツールバー内の ToggleGroup はそのグループの 1 つになります。そのトグルはツールバーのアイテムとなり、スクリーンリーダーはグループとして読み上げ、その disabled は引き続き自身のトグルを無効にします。その他のコントロール(Input、Select、Slider)は独立した停止位置のままで、矢印キーも自身で使います。これらはバーの末尾に配置してください。アイテムが開くポップアップの内容(ポップオーバーのフィールドやボタン、メニューのアイテム、ホバーカードのリンク)は、ツールバーには含まれません。矢印キーは、各アイテムがどれほど深い階層にあっても、バーがレイアウトした順にアイテムをたどります。あとから表示されたアイテムは、その並びの中に収まります。
#パーツ
| パーツ | 内側 | スタイル指定 | 状態 |
|---|---|---|---|
| バー | Toolbar の class、style | ||
| グループ | バー | ToolbarGroup の class、style | |
| セパレーター | バー、グループ | ToolbarSeparator の class、style | |
| アイテム | バー、グループ | アイテム自身の class、style | 各アイテム自身のもの: aria-pressed:、disabled:、hover:、focus:、active: |
キーボードが置かれているアイテムは focus: スタイルで示し、押されたトグルはそのトグル自身のサーフェスで示します(参照: デザインガイド)。
#縦向き
ツールのパレットは列になります。axis="vertical" でアイテムを上から下へ配置し、上下キーでそれらの間を移動します。
<Toolbar axis="vertical" accessibilityLabel="Tools" class="gap-1 self-start rounded-lg bg-muted p-1">
<Each of={["select", "pen", "shape", "text"]}>
{(t) => (
<Toggle
pressed={tool() === t()}
accessibilityLabel={t()}
onChange={({ pressed }) => pressed && tool.set(t())}
class="size-8 items-center justify-center rounded-md aria-pressed:bg-accent"
>
<Text>{t()[0].toUpperCase()}</Text>
</Toggle>
)}
</Each>
</Toolbar>
#無効化
ツールバーに disabled を指定すると、バー内のすべてのアイテムが無効になります(アプリがバーをロックする場合です)。グループに指定すると、そのグループのアイテムが無効になります。無効なアイテムは押下を受け付けず、矢印キーはそれを飛ばし、スクリーンリーダーには淡色表示として伝わります。
<Toolbar disabled={readonly()} accessibilityLabel="Review" class="flex-row gap-1">
<Button onPress={approve} class="h-8 px-3 disabled:opacity-50">
<Text>Approve</Text>
</Button>
</Toolbar>
#アクセシビリティ
Toolbarはロール"toolbar"を持ち、accessibilityLabelで名前が付きます。アイテムは引き続き独立した要素です。iOS では、ツールバーは VoiceOver が入っていくセマンティックなグループです。ToolbarGroupはロール"group"を持ち、accessibilityLabelで名前が付くため、一連のアイテムが 1 つのまとまりとして読み上げられます。ツールバー内のToggleGroupもグループです。ToolbarSeparatorはスクリーンリーダーにとって要素ではありません。disabledは、各プラットフォームの流儀に従って読み上げられます(VoiceOver、TalkBack、UI Automation、ブラウザーのアクセシビリティツリー)。
ラベル、ロール、読み上げ全般については アクセシビリティ を参照してください。
#キーボード
Tab が押下を受け付けるビューに到達する macOS と Web では、Toolbar は WAI-ARIA toolbar pattern に従います。
| キー | 動作 |
|---|---|
| Tab、Shift+Tab | フォーカスが最後に離れたアイテム(最初は、無効でない最初のアイテム)でツールバーに入り、1 回の操作でツールバーから出ます。 |
右、左(axis="horizontal") | フォーカスを次または前のアイテムへ移動します。最後から最初へ、またその逆へも循環します。 |
下、上(axis="vertical") | フォーカスを次または前のアイテムへ移動します。最後から最初へ、またその逆へも循環します。 |
| Home、End | フォーカスを最初または最後のアイテムへ移動します。 |
| Space、Return | フォーカスされているアイテムを押します。 |
矢印キーは無効なアイテムを飛ばし、何も押しません。トグルが変化するのは、押されたときだけです。もう一方の組の矢印キーはアイテムに任されます(メニューのトリガーは下キーでメニューを開きます)。右から左へのレイアウトでは、横向きのツールバーは右から並ぶため、左キーが次のアイテム、右キーが前のアイテムへ移動します。縦向きのツールバーでは、下キーと上キーのままです。
#プラットフォームに関する注意
- iOS、Android、Mac Catalyst: 各アイテムはタップで押します。ツールバーはスクリーンリーダーが読み上げるグループです。
- デスクトップ(macOS、Windows、Linux、GPUI)と Web: 押下はクリックです。macOS と Web では、ツールバーは Tab の停止位置が 1 つで、矢印キー、Home、End でアイテム間を移動します(キーボード)。
- すべてのプラットフォームで、アプリがスタイルしたものが描画されます。バー、そのグループ、セパレーター、アイテムが対象です。