gpuikit 0.8.0

A UI toolkit for GPUI applications
Documentation
# Toolbar: one tab stop over grouped controls, not window chrome

## What it is

A horizontal (or vertical) strip that groups related controls — icon buttons, toggles, a select, a separator between clusters — into a single composite with one Tab stop, arrow-key movement between items, and `Role::Toolbar` announced over the lot. It is an in-window element a view composes wherever it wants a row of actions: above an editor, atop a panel, along the edge of a canvas.

It is deliberately *less* than SwiftUI's `.toolbar`. SwiftUI's placements split into two kinds: the in-window kind (leading, trailing, principal-as-center, a primary action) and the OS-window kind — title-bar unification on macOS, where `principal` items merge into the window's own chrome next to the traffic lights, and `automatic` lets the system move items between bars. The second kind belongs to the platform's window server and to whatever gpui someday exposes for titlebar integration; a toolkit element that pretended to own it would be lying about where the pixels come from. What this element owns is the first kind: three placement groups (`leading`, `center`, `trailing`) laid out in one row, which is also exactly the split Zed's own `ToolbarItemLocation` uses (`PrimaryLeft`, `PrimaryRight`, `Secondary`) minus the pane-item plumbing.

Most of a toolbar's visible content already exists in this crate: `src/elements/icon_button.rs`, `src/elements/button_group.rs`, `src/elements/toggle_group.rs`, `src/elements/separator.rs`, `src/elements/select.rs`. What none of them can provide, and what the toolbar element itself owns, is: the role, the single tab stop with roving arrow-key focus across *heterogeneous* children, the placement groups, and one rung of the control size scale applied to the whole strip.

## Why it survives triage

**It is not the rejected Menubar.** `docs/component-triage.md` rejects Menubar as "an application shell concern, not an element": the strip of application menus is the system menu bar on macOS and a window-chrome decision elsewhere, with a named revisit trigger of gpui growing a cross-platform application-menu API. That rejection is about *menus in chrome*. A toolbar is neither of those things: it holds controls, not menus; it lives inside a view's layout, not in the window frame; and every one of its hard parts (roving focus, the role, the rung) is toolkit work with no platform dependency. The same boundary is drawn here on the SwiftUI side: title-bar unification is the Menubar rejection's territory and stays rejected; the in-window strip is not.

**It has a consumer shape the crate already documents.** `src/theme/control.rs` describes `ControlSize::Small` as "Dense toolbars, inline chrome" — the scale was designed with this element in mind before it existed. And `src/elements/control_size_tests.rs` already contains a private struct named `Toolbar`: a test fixture that puts one of every row-capable control on one row to prove the scale works. That fixture is this component's skeleton, built to verify a promise ("controls can share a row") that no public element yet cashes in.

**It is the cheapest place to buy the roving-focus convention.** `src/a11y.rs`'s `role_requires_keyboard_focus` explicitly declines the composite-item roles (`MenuItem`, `Tab`, `TreeItem`, `ListBoxOption`, `RadioButton`-in-group) because "no per-item rule can be right" until "this crate has a roving-focus convention, which is a separate decision — `Tabs`, `List`, `ContextMenu` and `Select`'s popup all want it and none of them has it." Six entries in `ELEMENTS_WITHOUT_A_ROLE` (`context_menu`, `list`, `radio_group`, `tabs`, `breadcrumb`, and transitively `accordion`) are blocked on that same missing convention. The WAI-ARIA authoring practices treat toolbar as the *introductory* composite: its items are ordinary buttons rather than special item roles, so the focus machinery can be built and tested without also deciding what a `MenuItem` announces. Building Toolbar first turns "a separate decision" into a worked example the other six inherit.

**The curated rosters ship it.** Radix ships Toolbar as a primitive (Root, Button, Link, Separator, ToggleGroup) — a *composition surface*, which is precisely this crate's situation: the children exist, the composite does not. Primer ships it as ActionBar. Headless UI does not ship one, and the reason cuts the other way from its Form omission: Headless UI has no icon-button or toggle-group either, so it has nothing to compose; this crate does.

## Prior art

- **WAI-ARIA APG, Toolbar pattern.** The contract to implement, verbatim: `role="toolbar"` on the container; a label via `aria-label` (accesskit: a required name — see Accessibility); one tab stop, "if focus is moving into the toolbar for the first time, focus is set on the first control that is not disabled"; Left/Right arrows move between controls (Down/Up when `aria-orientation="vertical"`; horizontal is the default); Home/End optional, to first and last; wrapping optional. On disabled items: "disabled elements are not focusable when navigating with a keyboard," with the stated exception that where discoverability matters they may be focusable — this crate has already taken the skip side of that fork (`Button` declines focus when disabled, forced by gpui having no `aria_disabled`), so the toolbar skips disabled items and does not revisit. Also the scope threshold: "use toolbar as a grouping element only if the group contains 3 or more controls" — two buttons in a row are an `h_stack`, not this element. Re-open the pattern before implementing.
- **Radix, Toolbar.** The API shape to steal: a Root taking heterogeneous children, orientation with arrow keys mapped per orientation, a `loop` prop for wrap (default on), Home/End, and — worth noticing — *no overflow handling at all*. A primitive library looked at overflow and declined; see Non-goals. Re-open it.
- **SwiftUI, `.toolbar` / `ToolbarItemPlacement`.** Take the placement vocabulary, not the mechanism: `navigation`/leading, `principal`/center, `primaryAction`/trailing translate to three groups in one flex row. Leave behind everything that is OS-window integration — `principal`'s title-bar unification, `automatic`'s system-decided placement, `bottomBar`, `keyboard` — and the semantic-role placements (`confirmationAction`, `cancellationAction`, `destructiveAction`), which are `Dialog`'s territory in this crate (`docs/issues/confirmation-dialog.md`), not the toolbar's. Re-open the docs.
- **Zed, `workspace::Toolbar` (crates/workspace/src/toolbar.rs).** One paragraph on what is reusable as a technique: Zed's toolbar is a `Vec` of `ToolbarItemView` entities that each *answer* `set_active_pane_item` with a `ToolbarItemLocation` (`Hidden`, `PrimaryLeft`, `PrimaryRight`, `Secondary`), and the render pass is a vertical flex whose primary row lays left items `flex_auto`/`justify_start` with `overflow_x_hidden` and right items `flex_row_reverse`/`justify_end`, with secondary items on rows below. The reusable parts are the location enum (it is the placement API, proven on gpui) and the `flex_row_reverse` trick for the trailing group. The non-reusable parts are instructive too: items reposition themselves in response to workspace events, which is application wiring this element must not take on, and the whole thing has no keyboard navigation and no role — Zed's toolbar is operated by mouse and by global keybindings, which is exactly the gap a toolkit element exists to close. Re-open the file.
- **Primer, ActionBar.** The overflow evidence: when buttons don't fit, a trailing kebab `IconButton` opens a menu holding the items that didn't fit, groups move as units, dividers are preserved — and the implementation measures with a `ResizeObserver`. That last fact is why overflow is a non-goal here (below). Re-open it if overflow is ever picked up.

## What it has to close in this crate

- **The roving-focus mechanics, which do not exist and are almost expressible.** The pieces are all named in `src/a11y.rs` section 4: gpui mints a `FocusHandle` per focusable element, `TabStopMap::next` "walks straight past a node that is not a stop," and both of `announce`'s focus paths hard-code `.tab_stop(true)`. Roving focus is exactly the missing combination: every item focusable, only the *active* item a tab stop, arrow keys moving both real focus and the stop. That needs a way for `A11y` to say "focusable, not a tab stop" — a new field or variant in `src/a11y.rs`, applied by `Announce::announce`. This is a named prerequisite, not a footnote: it is the "roving-focus convention" that `role_requires_keyboard_focus`'s doc comment defers, and it should land as its own change to `src/a11y.rs` with the toolbar as its first consumer. The alternative — `active_descendant`, which `A11y` already carries — is the container-holds-focus model; `src/a11y.rs` documents that field as "the odd one," unreadable by any test, and its guard's limits are argued at the field. Take the roving model, which the splitter's precedent (real focus, real handle, arrow keys on the focused element) already matches.
- **Arrow-key handling.** The worked example is `src/elements/context_menu.rs`: actions declared with `actions!`, bound in an init function the way `src/a11y.rs`'s `bind_focus_keys` and `src/input/bindings.rs`'s `bind_input_keys` are, dispatched to the focused element, with its own tests. The toolbar's bindings must carry a key context (the `KeyBindingContextPredicate` trap in a11y.rs section 4 notwithstanding — the toolbar container *can* set a key context, unlike the app root) so that Left/Right inside a toolbar-hosted `TextField` still edit text: binding order alone, which is what protects Tab today, is per-`init`-call ordering and should not be the only wall. Whether a hosted text input keeps the arrows or the toolbar takes them is the one genuinely open keyboard question; ARIA's answer (the pattern's own examples put text inputs in toolbars and give arrows to the input at the edges) should be recorded in the module docs.
- **Heterogeneous children vs. an item model.** `ButtonGroup` takes `Vec<AnyElement>` and can do nothing to a child after the fact; `ToggleGroup` takes a typed `Vec<ToggleOption>` and owns everything. The toolbar needs to know its items to rove focus across them and skip disabled ones, so opaque `AnyElement` children are not enough — but a closed item enum would forbid hosting a `Select`. The likely answer is a `ToolbarItem` wrapper that carries the per-item facts the container needs (id, disabled, group) around an arbitrary child, the way `ContextMenu`'s entry list works; the issue leaves the exact shape to the implementation but names the constraint: **the container must be able to enumerate its focusable items.**
- **Placement groups.** `leading`/`center`/`trailing`, rendered as one `h_stack` with the Zed layout technique. Groups are separated visually by `separator().vertical()` (`src/elements/separator.rs`), which inside a toolbar is announced as nothing — a decorative divider, matching `ELEMENTS_WITHOUT_A_ROLE`'s existing note that `separator` and `splitter` roles "need reconciling before either moves."
- **Cascading size, honestly.** A toolbar hosts controls on one rung, and today that means threading `.control_size(size)` onto every child by hand — the same shape as the cascading-disabled problem `docs/issues/form.md` names: "an ambient value read by the controls, not a prop threaded by hand. Get this wrong and every control needs a `disabled` argument at every call site, which is what happens today." No ambient mechanism exists in the crate. This issue does *not* make the toolbar invent one: the toolbar sets its own paddings and gaps from its rung, states in its module docs that children must be put on the same rung by the caller, and the ambient-value mechanism — if it comes — comes once, shared with Form, as its own decision. If Form lands first with a cascade, the toolbar adopts it.
- **Where the body lives.** `docs/issues/` files are checked by `triage_coverage` in `src/elements.rs`: every file there must be reachable from `docs/component-triage.md`, whose verdict table is one-row-per-#59-entry — and Toolbar was never a #59 entry. Landing this body as `docs/issues/toolbar.md` therefore also means a prose link from the triage document (not a table row), or the build fails. That is a feature: the triage document gets a sentence saying the Menubar rejection does not cover this, in the place where the rejection lives.

## Accessibility

`Role::Toolbar` is real and reachable: gpui re-exports `accesskit::Role` wholesale (`pub use accesskit::{Orientation, Role, Toggled}` in gpui's root), and accesskit 0.24.1 — the version in this crate's tree — has `Role::Toolbar`. The element implements `traits::accessible::Accessible` (`src/traits/accessible.rs`) and applies its `A11y` with one `.announce(a11y)`, per the `src/a11y.rs` convention; the source scan `no_element_calls_gpuis_a11y_builders_directly` forbids anything else. The name is a required constructor argument, the `Splitter`/`IconButton` precedent: a toolbar has no visible text of its own to borrow, and `role_requires_a_name` should grow a `Role::Toolbar` arm (with its pinning assertion in `the_focus_rule_covers_the_roles_a_keyboard_operates`'s sibling test) in the same change. Orientation is reported through the `A11y::orientation` field that already exists, fed from `traits::orientable::Orientable`, which the element implements — note `Orientation::default()` in this crate is `Vertical`, so the toolbar must construct itself `Horizontal` explicitly to match ARIA's default.

The focus story, stated against the mechanics: **roving tabindex is not expressible today.** `Announce::announce` offers exactly three focus answers — `focusable()` (mints a handle, forces `.tab_stop(true)`), `focus_handle(…)` (same stop), `not_focusable(reason)` — and none of them can make an element focusable *without* making it a Tab stop, which is the entire trick. The prerequisite, named: extend `src/a11y.rs` with the roving-focus convention its own docs defer — a fourth answer ("focusable, stop controlled by the container"), plus whatever container-side helper moves the stop when arrows move focus. `Role::Toolbar` itself does not join `role_requires_keyboard_focus` (the container is entered, not operated — same reasoning as the declined `Group` arm), but the convention unblocks the composite-item roles that list is waiting to grow. Disabled items are skipped during arrow traversal — consistent with the crate's existing disabled answer (out of the order, since gpui cannot announce `aria_disabled`) — and first-focus lands on the first non-disabled item, per the pattern. Tab from anywhere inside exits the toolbar entirely: that falls out of only one item being a stop, and `focus_survives_a_redraw`-style tests in the element's module should pin both facts.

## Sizing

The toolbar implements `ControlSized` (`src/traits/control_sized.rs`) and resolves its rung through `Themeable::control` into `ControlMetrics`: item gap from `metrics.gap`, strip padding from the rung, separator inset from the rung's `ink`. It names no dimension of its own — the "What belongs here" note atop `src/theme/control.rs` governs. The default is `Medium` like every control; `ControlSize::Small`'s own doc comment ("Dense toolbars, inline chrome") makes the dense variant a one-call opt-in. The strip's height is the rung's height plus the strip's padding, and `src/elements/control_size_tests.rs::every_sized_control_on_a_row_is_the_same_height` is the proof the children line up — that test's fixture should be replaced by, or rebuilt on, the real element when it lands, and the three off-scale controls that test names as toolbar-relevant (`Tabs`, `ToggleGroup`, `Slider`, tracked as #152) become more urgent the day a public toolbar exists to put them on.

## Showcase

`src/elements.rs`'s `showcase_coverage` test makes a showcase page a build requirement: a `pub mod toolbar` does not compile into the crate without a row in `examples/showcase.rs`'s `ELEMENT_COVERAGE` and a match arm that renders it. The page shows: a horizontal toolbar with three placement groups (leading icon buttons, a center `ToggleGroup`, a trailing `Select`), separators between groups, one disabled item to demonstrate the focus skip, the same toolbar on `Small`, and a vertical toolbar to prove `Orientable`. The keyboard behavior is the point of the page — arrow across, Tab out — so the page copy should say the keys, the way the splitter's page does.

## Non-goals

- **Overflow.** Collapsing items that don't fit into a trailing menu — Primer ActionBar's kebab — requires measuring items against available width and re-rendering with a different child set, which in this crate is the same wall `docs/component-triage.md` records for `Table`'s content-sized columns: a measurement pass "which flex cannot do and which gpui's grid … cannot express either. It wants a hand-written `Element`." Primer does it with a `ResizeObserver`; gpui's equivalent is prepaint-time bounds, one frame late. Radix, the closest analog to this crate, ships no overflow at all. Named non-goal, with a revisit trigger: if the crate ever builds the measuring-Element machinery for `ColumnWidth`'s missing arm, the toolbar's overflow is its second consumer, and Primer ActionBar (groups move as units, dividers preserved, overflowed items prepend to the menu) is the behavioral spec. Until then the strip clips, and `overflow_x_hidden` — Zed's own answer — is stated in the module docs as the deliberate one.
- **Window chrome.** No titlebar drawing, no traffic-light spacing, no unified-toolbar mode, no `bottomBar`. The Menubar rejection's revisit trigger (gpui growing a platform chrome API) is this non-goal's revisit trigger too.
- **Application wiring.** No `ToolbarItemView`-style registration, no active-pane-item protocol, no items that reposition themselves on events. Zed's toolbar shows where that ends: in the workspace crate, not the toolkit.
- **Menus.** A toolbar may *contain* a button that opens a `ContextMenu`; it does not become one. The menu keyboard model (Escape, typeahead, submenus) stays in `context_menu.rs`.
- **Form semantics.** No label association, no field grouping — a toolbar of inputs is a `Form` question, and `docs/issues/form.md` owns it.