Toolbar

v0.0.10

v0.0.10StableMobStatelessApache-2.0
Installation mix mishka.ui.gen.mob toolbar
Source View Source

Mob Toolbar is a strip of related controls for iOS and Android, with clusters of items and separators between them. It comes from Mishka Chelekom and runs on Mob, which renders native Jetpack Compose and SwiftUI views from Elixir. It is the native port of the headless Toolbar.

Overview of the Mob Toolbar component for iOS and Android

On the web, a toolbar is partly a keyboard construct. It makes a row of controls one tab stop and moves focus with the arrow keys. A phone has neither, so that part does not travel. The rest does: four item kinds, clusters of items that share a group, disabled on one item or the whole bar, and separators that follow the orientation.

You write the items as tags inside the toolbar: a button, a link, an input and a separator. Each tag has a builder function that produces the identical node. The Item tags table below lists the four tags, their builders and what each takes.

Write the tags when you write the bar out. Call the builders when the items come from data. Anything that is not a toolbar item passes through untouched, so a Toggle, a Toggle group or an Action icon can sit beside the items.

The screen owns all state. One on_select on the bar serves every button and link, because each item reports its own id. No item tag carries an on_* prop of its own.

Consecutive items that share a group are drawn as one cluster. A separator turns to match the toolbar's axis. A separator that carries the same group as its neighbors sits inside the cluster.

Hold an item to read its label

On the web, an item's label is its aria-label, and a desktop browser shows it on hover. A phone has no hover, so a long press takes its place. An item with an icon draws the glyph. Holding it sends the item id through on_hold. Pass that id back as hint and the toolbar prints the item's label under the strip. A normal tap still does what the button does.

Buttons and links report holds. An input does not, because a text field uses its own long press to place a selection. A disabled item still answers a long press with its label, so you can find out what it is even though you cannot activate it. Set focusable_when_disabled to false to remove that.

How a link is underlined

A link is underlined so it does not read as a button. The rule is the color of a wrapper that shows through one pixel of bottom padding under the label. The label is painted in whatever the item sits on: the strip's background, or the group's group_background inside a labeled group. The rule is therefore exactly as wide as the label.

A rule of its own, a fill_width Box under the label, would be measured against everything the Row has left and take all of it. That would leave the separator and the input after the link zero wide. The cost is that a custom background has to be a color the label can match. A :transparent strip falls back to :background.

Overflow

A row does not wrap, and nothing measures itself, so a toolbar with more controls than fit is clipped. overflow decides what happens instead.

  • :none is the default. The toolbar is a plain row.
  • :scroll makes the strip scroll along its own axis. Every control stays reachable without a second surface.
  • :collapse keeps visible controls and ends with a ⋯ button that sends on_overflow. You decide where the rest go: a drawer, a menu or a second bar.

split/2 is the whole collapse policy. It counts controls and not children, so a separator rides along with the items it divides and does not use up a slot. A trailing separator is dropped, and at least one control always stays visible.

Input width

An input item takes the space the buttons leave over. Under overflow: :scroll and in a vertical bar it has a fixed width instead, 160 by default.

Generate the toolbar component

Run this from your Mob app's root to add the component to lib/<app>/components/:

bash
mix mishka.ui.gen.mob toolbar

The generator registers the tag, so you can use it in ~MOB markup. It also copies the shared Event module. The first example also uses Toggle and Action Icon, so generate those too if you follow it. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaToolbar>. Without a prefix, the tag is <Toolbar>.

A strip of your own controls

Anything that is not a toolbar item passes straight through, so a toggle or an action icon sits beside a <MishkaToolbarSeparator> without either noticing. The toggles here come from the toggle/1 function and the undo button from action_icon/1.

A strip of your own controls

A strip of your own controls card: a toolbar with a pressed black B toggle, an outlined I toggle, a thin separator and two small rotate arrow icons
elixir
<MishkaToolbar id="tb-own">
  {toggle(label: "B", pressed: @bold?, on_change: :bold, id: "tb-own-b")}
  {toggle(label: "I", pressed: @italic?, on_change: :italic, id: "tb-own-i")}
  <MishkaToolbarSeparator />
  {action_icon(icon: "↺", on_tap: :undo)}
</MishkaToolbar>

def handle_info({:tap, :bold}, socket) do
  {:noreply, Mob.Socket.assign(socket, :bold?, not socket.assigns.bold?)}
end

Four item kinds

A toolbar can hold a button, a link, a separator and a text input, one tag each. A single on_select clause serves every button and link. The input reports edits through on_input, and you echo the value back. Opening the link's URL is the screen's job, because a node tree describes the screen and does not perform effects.

Four item kinds

Four item kinds card: a white toolbar with a B button, a Docs link underlined as wide as the word, a thin separator and a Find box, above the gray text Nothing tapped, nothing typed
elixir
<MishkaToolbar id="tb-kinds" on_select={:act} on_input={:typed}>
  <MishkaToolbarButton id={:bold} label="Bold" icon="B" />
  <MishkaToolbarLink id={:docs} label="Docs" href="https://mishka.tools/chelekom" />
  <MishkaToolbarSeparator />
  <MishkaToolbarInput id={:find} placeholder="Find…" value={@query} />
</MishkaToolbar>

def handle_info({:tap, {:act, :docs}}, socket) do
  # A link reports its id like every other item, and opening the URL is the
  # screen's job — a node tree is a description, not an effect. Written as
  # tags the destination is right there in the markup; when the items come
  # from a list instead, href/2 turns the reported id back into it.
  Mob.Device.open_url("https://mishka.tools/chelekom")
  {:noreply, socket}
end

def handle_info({:change, {:typed, :find}, value}, socket) do
  {:noreply, Mob.Socket.assign(socket, :query, value)}
end

Toolbar groups

Consecutive items that share a group form one cluster, as the web wraps them in a <div role="group">. The label of a group is invisible on the web, and here it becomes the cluster's test tag. This toolbar has an Align cluster of three buttons and a Lists cluster of two.

Toolbar groups

Groups card: a toolbar of two gray clusters, left arrow, diamond and right arrow icons, then list and quote icons, above the gray text Nothing picked yet
elixir
<MishkaToolbar id="tb-grp" on_select={:align}>
  <MishkaToolbarButton id={:left} label="Left" icon="◀" group="Align" />
  <MishkaToolbarButton id={:center} label="Centre" icon="◆" group="Align" />
  <MishkaToolbarButton id={:right} label="Right" icon="▶" group="Align" />
  <MishkaToolbarButton id={:list} label="List" icon="☰" group="Lists" />
  <MishkaToolbarButton id={:quote} label="Quote" icon="❝" group="Lists" />
</MishkaToolbar>

def handle_info({:tap, {:align, id}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :align, id)}
end

Hold an icon to read its label

There is no hover on a phone, so hold the button. on_hold reports the item id, and passing it back as hint prints that item's label under the strip. The plain tap still does whatever the button does, so the hint costs you nothing.

Hold an icon to read its label

Hold an icon to read its label card: a toolbar with two rotate arrow icon buttons, a thin separator and a link icon, with no hint showing
A toolbar strip with undo, redo and link icon buttons and the label Insert link printed underneath.

Left: the example as it loads. Right: after a long press. Hold an icon button to read its label.

elixir
<MishkaToolbar id="tb-hint" on_select={:act} on_hold={:hold} hint={@hint}>
  <MishkaToolbarButton id={:undo} label="Undo" icon="↺" />
  <MishkaToolbarButton id={:redo} label="Redo" icon="↻" />
  <MishkaToolbarSeparator />
  <MishkaToolbarButton id={:link} label="Insert link" icon="🔗" />
</MishkaToolbar>

# A long press is the touch equivalent of a hover. The plain tap still
# does whatever the button does, so the hint costs the caller nothing.
def handle_info({:tap, {:hold, id}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :hint, id)}
end

Disabled items that stay discoverable

The first bar is disabled as a whole. The second has a single disabled item. Either way, a disabled item still answers a long press, which is how focusable_when_disabled works on a phone. Set it to false to take the long press away as well.

Disabled items that stay discoverable

Disabled, and still discoverable card: two toolbars with B and I buttons, the first fully muted and the second with a dark B and a muted I
elixir
<MishkaToolbar id="tb-off" disabled={true} on_hold={:hold} hint={@held}>
  <MishkaToolbarButton id={:bold} label="Bold" icon="B" />
  <MishkaToolbarButton id={:italic} label="Italic" icon="I" />
</MishkaToolbar>

<MishkaToolbar id="tb-mix" on_select={:act}>
  <MishkaToolbarButton id={:bold} label="Bold" icon="B" />
  <MishkaToolbarButton id={:italic} label="Italic" icon="I" disabled={true} />
</MishkaToolbar>

# focusable_when_disabled={false} takes the long press away as well.
def handle_info({:tap, {:hold, id}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :held, id)}
end

Overflow: scroll

A row does not wrap, so thirteen controls on a phone are clipped. overflow={:scroll} makes the strip scroll along its own axis instead. These items come from a list, so the example builds them with MishkaToolbar.button/3.

Overflow: scroll

Overflow: scroll card: a toolbar of seven icons, cut, copy, paste, two rotate arrows, a link and a picture, all fitting inside the screen width
elixir
# A tag and its builder produce the identical node, so pick by where the
# items come from. Written out, they are tags; built from DATA, a
# comprehension beats generated markup — and this is that case.
<MishkaToolbar id="tb-scr" overflow={:scroll} on_select={:act}>
  {Enum.map(@tools, fn {id, label, icon} ->
    MishkaToolbar.button(id, label, icon: icon)
  end)}
</MishkaToolbar>

def handle_info({:tap, {:act, id}}, socket), do: {:noreply, act(socket, id)}

Overflow: collapse

The other answer keeps visible controls and puts a ⋯ at the end. The ⋯ sends on_overflow, so the rest can go wherever you decide, such as a drawer, a menu or a second bar.

Overflow: collapse

Overflow: collapse card: a toolbar with cut, copy and paste icon buttons followed by a three dot button, above the gray text Tap the three dots for the rest
elixir
<MishkaToolbar
  id="tb-more"
  overflow={:collapse}
  visible={3}
  on_overflow={:show_rest}
  on_select={:act}
>
  {Enum.map(@tools, fn {id, label, icon} ->
    MishkaToolbar.button(id, label, icon: icon)
  end)}
</MishkaToolbar>

def handle_info({:tap, :show_rest}, socket) do
  {:noreply, Mob.Socket.assign(socket, :sheet_open?, true)}
end

Vertical toolbar

orientation={:vertical} stacks the items in a column. The separator turns to match the toolbar's axis.

Vertical toolbar

Vertical card: a vertical toolbar with pencil and copy icon buttons, a horizontal separator and a trash can icon button, in the MishkaMob showcase app on Android
elixir
<MishkaToolbar id="tb-vert" orientation={:vertical} on_select={:act}>
  <MishkaToolbarButton id={:edit} label="Edit" icon="✎" />
  <MishkaToolbarSeparator />
  <MishkaToolbarButton id={:delete} label="Delete" icon="🗑" />
</MishkaToolbar>

def handle_info({:tap, {:act, id}}, socket), do: {:noreply, act(socket, id)}

Builders and helpers

The generated MishkaToolbar module exports these functions.

FunctionDescription
button/3, link/3, input/2, separator/1The same four nodes as the tags, for when the items come from data.
href/2The href of the link item with a given id, or nil.
split/2Splits items into the shown and hidden lists for overflow: :collapse.
item_tag/3The test tag of an item, given the toolbar's id.

Props

PropTypeDefaultDescription
idstringnilTest tag. Items get <id>-<item_id>, with -disabled added when they are disabled.
orientation:horizontal or :vertical:horizontalLayout axis. Separators follow it.
disabledbooleanfalseDisables every item in the bar.
focusable_when_disabledbooleantrueA disabled item still answers a long press.
on_selectevent tagNone{:tap, {tag, item_id}} from a button or link.
on_holdevent tagNone{:tap, {tag, item_id}} from a long press, the replacement for hover.
on_inputevent tagNone{:change, {tag, item_id}, value} from an input item.
on_overflowevent tagNone{:tap, tag} from the ⋯ button.
hintitem idnilPrints that item's label under the strip.
overflow:none, :scroll or :collapse:noneWhat too many controls do. Nothing wraps.
visibleinteger3Controls kept when overflow is :collapse.
spacenumber8Gap between items.
heightnumbernilBounds the strip. A vertical :scroll needs it.
backgroundcolor token or ARGB integer:surface_raisedStrip fill. A link's rule is painted against it, and a :transparent strip falls back to :background.
corner_radiusradius token or number:radius_mdStrip corners.
paddingspacing token or number:space_smPadding inside the strip.
group_backgroundcolor token or ARGB integer:surfaceFill behind a labeled group. Links inside the group are painted against it.
item_colorcolor token or ARGB integer:on_surfaceButton ink.
link_colorcolor token or ARGB integer:primaryLink ink.

Item tags

TagBuilderDescription
<MishkaToolbarButton>button/3A command. Takes id (what on_select reports), label, icon, disabled and group.
<MishkaToolbarLink>link/3A destination. Takes the same props plus href. It is tinted with link_color and has a rule under it that is as wide as its label.
<MishkaToolbarInput>input/2A text field. Takes id, placeholder, value, width, label, disabled and group.
<MishkaToolbarSeparator>separator/1A divider across the toolbar's other axis. Takes group.

Events

Your screen receives these messages in handle_info/2.

PropMessage the screen receives
on_select{:tap, {tag, item_id}} from a button or link
on_hold{:tap, {tag, item_id}} from a long press
on_input{:change, {tag, item_id}, value} from an input item
on_overflow{:tap, tag} from the ⋯ button

In this example act/2 stands for your own function. Store the typed value and pass it back as the input's value, or the field keeps showing text the screen no longer has.

elixir
def handle_info({:tap, {:act, id}}, socket), do: {:noreply, act(socket, id)}

def handle_info({:change, {:typed, :find}, value}, socket) do
  {:noreply, Mob.Socket.assign(socket, :query, value)}
end

A link reports its id like every other item. To open it, call Mob.Device.open_url/1, or Mob.Socket.push_screen/3 for an internal destination. When the items come from a list, href/2 turns the reported id back into the destination.

Differences from the web component

  • loop is not ported. It moves focus with the arrow keys, and a phone has no arrow keys or focus ring to move.
  • class and group_class are not ported. There is no stylesheet, so every visual decision is a prop.
  • label is not ported as an announced name, because Mob exposes no accessible name. The long-press hint is the workaround.