元素
Toolbar
工具列是一列控制項:文字編輯器的格式設定、媒體播放器的播放控制,或是垂直的工具面板。
Toolbar 負責工具列的鍵盤操作與語意:螢幕閱讀器會朗讀工具列(以其
accessibilityLabel 命名),鍵盤只需一次停駐即可進入,並以其
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 crate。工具列不持有任何值:其中的切換鈕會被告知是否已按下並回報 onChange,按鈕回報 onPress,狀態由應用程式保管。在 Rust 中,toolbar()、toolbar_group() 和 toolbar_separator() 用來建立它們,每個 prop 有對應的 setter(.axis(..)、.disabled(..)),另有具型別的樣式方法,以及用於內容的 .child(..)。
#Props
#Toolbar
| 屬性 | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
axis |
.axis(..) |
"horizontal" | "vertical" |
"horizontal" |
項目延伸的方向:沿該方向移動的方向鍵(左右,或上下),以及子項目的排列方向(橫列或直欄)。在建立工具列時讀取。 |
disabled |
.disabled(..) |
boolean |
false |
工具列的每個項目都會停用,不論其自身的 disabled:無法按下、不成為鍵盤焦點的停駐點,並對螢幕閱讀器顯示為變暗。 |
accessibilityLabel |
.accessibility_label(..) |
string |
無 | 螢幕閱讀器對工具列的稱呼(「格式設定」),適用於一個頁面有多個工具列的情況。 |
children |
.child(..) |
JSX.Element |
無 | 項目、其群組和分隔線,以及應用程式在其間排版的其他內容(標題、間隔元件、輸入框)。 |
class、style |
樣式方法 | string、StyleProp<ViewStyle> |
無 | 工具列的版面與外觀:對齊、間距、內距及其表面。 |
#ToolbarGroup
| 屬性 | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
disabled |
.disabled(..) |
boolean |
false |
群組的每個項目都會停用,不論其自身的 disabled。 |
accessibilityLabel |
.accessibility_label(..) |
string |
無 | 螢幕閱讀器對群組的稱呼(「歷史記錄」、「對齊方式」)。 |
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 slot 中的視圖,彈出層會為它提供按下、焦點和 expanded 狀態。應用程式自行繪製的工具是套用應用程式 class 的 Button:Button 本身沒有外觀,因此會依應用程式的樣式顯示,並且是項目(View 若帶有 onPress,則仍是獨立的停駐點,不在方向鍵的範圍內)。工具列內的 ToggleGroup 是其群組之一:其切換鈕是工具列的項目,螢幕閱讀器會讀出為一個群組,而其 disabled 仍會停用其自身的切換鈕。其他控制項(Input、Select、Slider)仍是獨立的停駐點,並保留各自的方向鍵;請把它們放在工具列的末端。項目開啟的彈出層所顯示的內容(popover 的欄位和按鈕、menu 的項目、hover card 的連結)不屬於工具列。方向鍵依工具列排版的順序走過各個項目,不論項目位於多深的層級,應用程式稍後才顯示的項目也會在其中佔有位置。
#組成部分
| 部分 | 位於 | 樣式設定 | 狀態 |
|---|---|---|---|
| 工具列本體 | 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命名,因此一連串項目會被讀為一個單位。工具列內的ToggleGroup同樣是群組。ToolbarSeparator不是螢幕閱讀器的元素。disabled依各平台的方式朗讀(VoiceOver、TalkBack、UI Automation、瀏覽器的無障礙樹)。
標籤、角色和朗讀的一般說明,請見無障礙。
#鍵盤
在 macOS 和網頁上,Tab 可到達接受按下操作的視圖,Toolbar 遵循 WAI-ARIA toolbar pattern:
| 按鍵 | 作用 |
|---|---|
| Tab、Shift+Tab | 進入工具列時,焦點落在上次離開的項目(初次為第一個未停用的項目),並一步離開。 |
右、左(axis="horizontal") |
把焦點移到下一個或上一個項目,由最後一個循環到第一個,反之亦然。 |
下、上(axis="vertical") |
把焦點移到下一個或上一個項目,由最後一個循環到第一個,反之亦然。 |
| Home、End | 把焦點移到第一個或最後一個項目。 |
| Space、Return | 按下取得焦點的項目。 |
方向鍵會略過已停用的項目,且不會按下任何東西:切換鈕只有在被按下時才會改變。另一對方向鍵留給項目使用(menu 的觸發元件以下鍵開啟其 menu)。在由右至左的版面中,橫向工具列從右方開始排列,因此左鍵移到下一個項目,右鍵移到上一個;直向工具列則仍以下鍵和上鍵移動。
#平台說明
- iOS、Android 和 Mac Catalyst:每個項目以輕觸按下;工具列是螢幕閱讀器會朗讀的群組。
- 桌面平台(macOS、Windows、Linux、GPUI)和網頁:按下即是點按。在 macOS 和網頁上,工具列是 Tab 的一個停駐點,方向鍵、Home 和 End 在其項目之間移動(鍵盤)。
- 每個平台都會繪製應用程式所設定的樣式:工具列本體、群組、分隔線和項目。