Elements
Toolbar
A toolbar is a bar of controls: a text editor’s formatting, a media player’s transport, a vertical palette of tools.
Toolbar owns the keyboard and the semantics of one: screen readers announce a toolbar (named by its
accessibilityLabel), and the keyboard reaches it in one stop and moves between its items with the arrow keys of its
axis. Its items are the Buttons, Toggles and Links inside it, at any depth. A ToolbarGroup is a named run of
related items (undo and redo, the alignment), and a ToolbarSeparator a line the app draws between runs. The look is
the app’s: the toolbar has no colors, padding or gaps of its own, and each control keeps its own state.
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 and ToolbarSeparator come from nsh/base, the nsh-base crate
in Rust. The toolbar holds no value: a toggle inside it is told whether it is pressed and reports onChange, a
button reports onPress, and the app keeps the state. In Rust, toolbar(), toolbar_group() and
toolbar_separator() make them, with a setter per prop (.axis(..), .disabled(..)), the typed style methods and
.child(..) for their content.
#Props
#Toolbar
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
axis | .axis(..) | "horizontal" | "vertical" | "horizontal" | The direction its items run: the arrow keys that move along it (Left and Right, or Up and Down) and the direction it lays its children out in (a row, or a column). Read when the toolbar is made. |
disabled | .disabled(..) | boolean | false | Every item of the toolbar is disabled, whatever its own disabled: no presses, no stop of the keyboard focus, dimmed for screen readers. |
accessibilityLabel | .accessibility_label(..) | string | none | What screen readers call the toolbar (“Formatting”), for a page with more than one. |
children | .child(..) | JSX.Element | none | The items, their groups and separators, and whatever else the app lays out among them (a title, a spacer, an input). |
class, style | style methods | string, StyleProp<ViewStyle> | none | The bar’s layout and look: alignment, gaps, padding, its surface. |
#ToolbarGroup
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | Every item of the group is disabled, whatever its own disabled. |
accessibilityLabel | .accessibility_label(..) | string | none | What screen readers call the group (“History”, “Alignment”). |
children | .child(..) | JSX.Element | none | The group’s items, and whatever the app lays out among them. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The group’s layout and look. It runs along the toolbar’s axis. |
#ToolbarSeparator
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
class, style | style methods | string, StyleProp<ViewStyle> | none | The line: a hairline across a row (h-5 w-px bg-border) or a column (h-px self-stretch bg-border). |
#Items
The items of a toolbar are the Buttons, Toggles and Links inside it, in its groups and in a
ToggleGroup inside it too. A Menu’s or a Popover’s trigger is an
item as well: the view in its trigger slot, which the popup gives its presses, its focus and its expanded state. A
ToggleGroup inside a toolbar is one of its groups: its toggles are the toolbar’s items, screen readers hear a group,
and its disabled still disables its own toggles. Other controls (an Input, a Select, a Slider) stay stops of
their own and keep their arrow keys; place them at the end of the bar. What a popup an item opens shows (a popover’s
fields and buttons, a menu’s items) is no part of the toolbar. The arrow keys go through the items in the order the
bar lays them out, however deep each one sits, and an item the app shows later takes its place among them.
#Parts
| Part | Inside | Styled with | States |
|---|---|---|---|
| The bar | Toolbar’s class, style | ||
| A group | the bar | ToolbarGroup’s class, style | |
| A separator | the bar, a group | ToolbarSeparator’s class, style | |
| An item | the bar, a group | the item’s own class, style | its own: aria-pressed:, disabled:, hover:, focus:, active: |
Show the item the keyboard is on with focus: styles, and a pressed toggle through its own surface (see
Design guides).
#Vertical
A palette of tools is a column: axis="vertical" lays the items out top to bottom, and Up and Down move along them.
<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
disabled on a toolbar disables every item in it, a bar the app locks; on a group, the group’s items. A disabled item
takes no presses, the arrow keys pass it by, and screen readers announce it as dimmed:
<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>
#Accessibility
Toolbarhas the role"toolbar", named byaccessibilityLabel; its items stay elements of their own. On iOS the toolbar is a semantic group VoiceOver enters.ToolbarGrouphas the role"group", named by itsaccessibilityLabel, so a run of items reads as one unit. AToggleGroupinside a toolbar is a group too.ToolbarSeparatoris no element for screen readers.disabledis announced as the platform announces it (VoiceOver, TalkBack, UI Automation, the browser’s accessibility tree).
See Accessibility for labels, roles and announcements in general.
#Keyboard
On macOS and the web, where Tab reaches the views that take presses, a Toolbar follows the
WAI-ARIA toolbar pattern:
| Key | What it does |
|---|---|
| Tab, Shift+Tab | Enters the toolbar at the item the focus last left (at first, the first item that is not disabled), and leaves it in one step. |
Right, Left (axis="horizontal") | Moves the focus to the next or the previous item, from the last to the first and back. |
Down, Up (axis="vertical") | Moves the focus to the next or the previous item, from the last to the first and back. |
| Home, End | Moves the focus to the first or the last item. |
| Space, Return | Presses the focused item. |
The arrow keys skip disabled items and press nothing: a toggle changes only when it is pressed. The other pair of arrows is left to the items (a menu’s trigger opens its menu with Down).
#Platform notes
- iOS, Android and Mac Catalyst: each item is pressed with a tap; the toolbar is a group screen readers announce.
- Desktops (macOS, Windows, Linux, GPUI) and the web: a press is a click. On macOS and the web the toolbar is one stop of Tab, and the arrow keys, Home and End move between its items (Keyboard).
- Every platform draws what the app styles: the bar, its groups, its separators and its items.