Tabs

v0.0.10

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

The Mishka Chelekom Mob Tabs component shows a strip of tab labels above one visible panel on iOS and Android screens built with Mob. The strip scrolls sideways when the tabs do not fit. It is the native port of the headless Tabs.

Overview of the Mob Tabs component for iOS and Android

Each tab declares its own panel, and only the panel of the active tab renders. Your screen owns the active tab and passes it as active. A stale or missing id falls back to the first tab, so the panel is never empty. A tab without an id uses its index.

The active tab shows its label in the color prop, in a heavier weight, with a 2dp underline exactly as wide as the label. on_change is wired for you, so you pass a bare atom, and it sends the tab's own id, which lets one clause in handle_info/2 serve every tab.

Tabs as slot tags

Write the tabs as <MishkaTab> children. A tab and its panel are declared together, so you never match them up by position. The tabs component consumes each <MishkaTab> tag, so the tag never reaches the renderer. tab/4 builds the same node by hand, so use the tag when you write tabs out and the function when the tabs come from a list.

The underline and the backdrop

Mob has no way to ask for a rule as wide as the text above it. A Box that fills its parent gets zero width inside the horizontal Scroll, so no underline shows. Inside a plain Row it takes everything the Row has left, so the first tab swallows the strip.

The component draws the underline the other way round. It wraps the label in a Box of color with 2dp of bottom padding and paints the label itself opaque, so exactly 2dp of color shows beneath it. Every tab keeps the same height whether or not it is active, so choosing a tab never moves the strip.

The label has to be painted in the color behind the strip, or the active tab shows as a slightly different rectangle. backdrop is that color. It defaults to :surface, which is what a card or a sheet is made of. Set it to :background for tabs on the bare screen, or to the strip's own fill. Nothing else reads it, and it is never drawn when indicator is false.

A strip that scrolls

A Row cannot wrap, so a long set of tabs would run past the screen edge. The strip is a horizontal Scroll instead: drag it left and right with a finger. Set scrollable={false} for a fixed row when the tabs fit.

Tabs and Mob's TabBar

Mob ships a TabBar node, and it is a different component. On Android it is a Material NavigationBar pinned to the bottom of the screen, and on iOS it is a SwiftUI TabView. That is app-level navigation, and neither version is an inline strip or scrolls sideways. Use TabBar when you want bottom navigation, and this component for a strip above a panel inside a screen or card.

Generate the tabs component

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

bash
mix mishka.ui.gen.mob tabs

The generator registers the tag, so you can use it in ~MOB markup. It also copies the shared Event support module into the same folder. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the module is <App>.Components.MishkaTabs and the tag is <MishkaTabs>. Without a prefix, the tag is <Tabs>.

Tab strip with one panel per tab

Each tab declares its own panel, and only the active one renders. on_change sends the id of the tapped tab, so the single handle_info/2 clause below serves all three.

Tab strip with one panel per tab

A tab strip card: the tabs Overview, Specs and Support in a row, with Overview bold and underlined, above the gray panel text about a native tab strip
A tab strip with Specs bold and underlined and the panel text Content-sized tabs, so it works on both platforms.

Left: the example as it loads. Right: after a tap. Only the active tab's panel renders.

elixir
<MishkaTabs active={@tab} on_change={:pick} id="main">
  <MishkaTab id={:overview} label="Overview">
    <Text text="A native tab strip." />
  </MishkaTab>
  <MishkaTab id={:specs} label="Specs">
    <Text text="Each tab's children are its panel." />
  </MishkaTab>
  <MishkaTab id={:support} label="Support">
    <Text text="Only the active one renders." />
  </MishkaTab>
</MishkaTabs>

# One clause serves every tab: the message carries the tab's own id.
def handle_info({:tap, {:pick, id}}, socket) do
  {:noreply, assign(socket, :tab, id)}
end

Scrollable strip with nine tabs

Nine tabs do not fit on a phone. Drag the strip left and right with your finger: every tab stays tappable, and its panel still shows.

The tabs here come from data, so the code calls the function form, tab/4, with three arguments instead of writing <MishkaTab> tags. Both forms build the same node, %{type: :mishka_tab, props: %{id:, label:}, children: panel}. Import the function from the generated module with import <App>.Components.MishkaTabs, only: [tab: 3].

Scrollable strip with nine tabs

Too many to fit card: a tab strip with Inbox, Starred, Snoozed, Sent and a cut off Draft running past the right edge, above a gray note about nine tabs
elixir
# The strip is a horizontal Scroll, so it drags. A Row cannot wrap, and
# before this the fifth tab was drawn past the screen edge for good.
#
# Written as a LIST here rather than as <MishkaTab> slot tags, because
# the tabs come from data. tab/3 builds exactly the same node the tag
# does — %{type: :mishka_tab, props: %{id:, label:}, children: panel} —
# so the two forms are interchangeable. Use the tag when you are writing
# tabs out by hand, the function when you are mapping over a list.
<MishkaTabs active={@tab} on_change={:pick} id="many">
  {Enum.map(@folders, fn {id, label, body} -> tab(id, label, body) end)}
</MishkaTabs>

Tabs without the indicator

indicator={false} removes the underline. The color and weight of the active label still mark the active tab.

Tabs without the indicator

Without the indicator card: the tabs First, Second and Third in a row with no underline, above the gray panel text No underline here
heex
<MishkaTabs active={@tab} indicator={false} on_change={:pick}>{tabs_list}</MishkaTabs>

Disabled tab

A disabled tab renders muted and has no handler, so tapping it does nothing and on_change never fires for it. Set disabled on the <MishkaTab>.

Disabled tab

Disabled tab card: the tabs Overview, Locked and Specs with Locked dimmed, above the gray text saying Locked will not respond, in the MishkaMob showcase app on Android
heex
<MishkaTab id={:locked} label="Locked" disabled={true}>…</MishkaTab>

Tab color and spacing

color tints the active label and its underline, and space sets the gap between tabs. This strip uses a violet ARGB value and a gap of 26.

Tab color and spacing

Colour and spacing card: the tabs Violet, Spaced and Wide, with Violet in a violet label and a violet underline, above the gray text A brand-tinted strip
heex
<MishkaTabs
  active={@tab}
  color={0xFF7C3AED}
  space={26}
  on_change={:pick}
>{tabs_list}</MishkaTabs>

Test tags

A tab says it is active with a color, a heavier weight and an underline, and none of them is in the semantics tree, so a device test cannot tell the selected tab from any other. With id set, the state is part of the tag.

TagNode
<id>-stripThe scrolling strip
<id>-tab-<tab_id>-active, -idle, -disabledOne tab's trigger, with its state in the name
<id>-panelThe panel below the strip

Helper functions

FunctionReturns
tab(id, label, panel \\ [], opts \\ [])A tab node, the function form of <MishkaTab>. opts takes disabled.
active(props, items)The active prop when it names a real tab, otherwise the first tab's id.
strip_id(id)The strip tag, for example "plans-strip".
tab_id(id, tab, state)A trigger tag, for example "plans-tab-pro-active".
panel_id(id)The panel tag, for example "plans-panel".

Props

PropTypeDefaultDescription
activetab idthe first tabWhich tab is selected. A stale id falls back to the first tab. The value lives in your screen.
on_changeevent tag (atom)NoneSends {:tap, {tag, tab_id}}, so one handler serves every tab.
indicatorbooleantrueUnderlines the active tab.
colorcolor token or ARGB integer:primaryColor of the active label and the indicator.
backdropcolor token or ARGB integer:surfaceThe color behind the strip. The underline is drawn against it. See The underline and the backdrop.
spacenumber18Gap between tabs.
scrollablebooleantrueThe strip drags sideways when the tabs overflow. A Row cannot wrap.
idstringnilBase of the test tags. Without an id there are no tags.

Tab props

Each <MishkaTab> takes these props. Its children are that tab's panel.

PropTypeDefaultDescription
idtermthe tab's indexIdentifies the tab in active and in the on_change message.
labelstringNoneThe text in the strip.
disabledbooleanfalseMutes the tab and removes its handler.

Events

When you tap a tab that is not disabled, on_change sends {:tap, {tag, tab_id}}, and your screen receives it in handle_info/2. Store the id and pass it back as active.

elixir
def handle_info({:tap, {:pick, id}}, socket) do
  {:noreply, assign(socket, :tab, id)}
end

Differences from the web component

The Mob Tabs component does not port these parts of the headless component:

  • orientation: "vertical".
  • activate_on_focus. There is no focus ring to follow.
  • default_value. Your screen holds the state, so an uncontrolled tab strip has no meaning.
  • The *_class attributes.