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.

el-toolbar.tsx
el-toolbar.tsxLive
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")),
        )
}

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

PropRustTypeDefaultDescription
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(..)booleanfalseEvery 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(..)stringnoneWhat screen readers call the toolbar (“Formatting”), for a page with more than one.
children.child(..)JSX.ElementnoneThe items, their groups and separators, and whatever else the app lays out among them (a title, a spacer, an input).
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe bar’s layout and look: alignment, gaps, padding, its surface.

#ToolbarGroup

PropRustTypeDefaultDescription
disabled.disabled(..)booleanfalseEvery item of the group is disabled, whatever its own disabled.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers call the group (“History”, “Alignment”).
children.child(..)JSX.ElementnoneThe group’s items, and whatever the app lays out among them.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe group’s layout and look. It runs along the toolbar’s axis.

#ToolbarSeparator

PropRustTypeDefaultDescription
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe 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

PartInsideStyled withStates
The barToolbar’s class, style
A groupthe barToolbarGroup’s class, style
A separatorthe bar, a groupToolbarSeparator’s class, style
An itemthe bar, a groupthe item’s own class, styleits 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

  • Toolbar has the role "toolbar", named by accessibilityLabel; its items stay elements of their own. On iOS the toolbar is a semantic group VoiceOver enters.
  • ToolbarGroup has the role "group", named by its accessibilityLabel, so a run of items reads as one unit. A ToggleGroup inside a toolbar is a group too.
  • ToolbarSeparator is no element for screen readers.
  • disabled is 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:

KeyWhat it does
Tab, Shift+TabEnters 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, EndMoves the focus to the first or the last item.
Space, ReturnPresses 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.