Accordion

v0.0.10

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

The Mishka Chelekom Mob Accordion is a stack of disclosure items for iOS and Android. Tapping an item header opens or closes its panel. It is the native port of the headless Accordion and runs on Mob, which draws it with Jetpack Compose on Android and SwiftUI on iOS.

Overview of the Mob Accordion component for iOS and Android

Accordion is a pure Elixir Mob composite. You write it as a <MishkaAccordion> tag, and at render time it expands into Mob's built-in Box, Row, Column and Text widgets. It ships no Swift or Kotlin code.

The items are the tag's children. Each one is a <MishkaAccordionItem> with an id and a title, and its own children are the panel body. An item without an id uses its 0-based position.

The screen owns the state. A composite is a pure function of its props, so you keep the open set in your assigns and pass it back in as open. When someone taps a header, a message arrives in handle_info/2. Call MishkaAccordion.toggle/3 to get the next open set. It applies the multiple and collapsible rules, so every screen behaves the same way.

Each header shows the title on the leading edge and a chevron on the trailing edge: ▸ when the item is closed and ▾ when it is open. The header is a tappable Box instead of a Button, because a Material Button on Android centers its label.

Generate the accordion component

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

bash
mix mishka.ui.gen.mob accordion

The generator registers the component's tag, so you can use it in ~MOB markup. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaAccordion>. Without a prefix the tag is <Accordion>.

Open one panel at a time

By default, opening a panel closes the others. The screen keeps the open set in its assigns, and toggle/3 returns the next set after each tap.

Open one panel at a time

Accordion card titled One at a time, with What is Mishka Chelekom? open and showing its answer above two closed items, Is it styled? and Is this real native UI?
An accordion with the item Is it styled? open and showing its text, while the other two items are collapsed.

Left: the example as it loads. Right: after a tap. Opening one panel closes the one that was open.

elixir
<MishkaAccordion open={@faq} on_toggle={:toggle_faq}>
  <MishkaAccordionItem id={:what} title="What is Mishka Chelekom?">
    {text("A component library for Phoenix — and now for Mob.")}
  </MishkaAccordionItem>
  <MishkaAccordionItem id={:styled} title="Is it styled?">
    ...
  </MishkaAccordionItem>
</MishkaAccordion>

# the screen owns the open set; toggle/3 applies the semantics
def handle_info({:tap, {:toggle_faq, id}}, socket) do
  {:noreply, assign(socket, :faq, MishkaAccordion.toggle(socket.assigns.faq, id))}
end

Open several panels at once

Set multiple={true} to let panels open independently. Pass multiple: true to toggle/3 so the open set follows the same rule.

Open several panels at once

Accordion card titled Multiple open, with The BEAM runs on the device and Widgets are native both open, and Code can be hot-pushed closed
elixir
<MishkaAccordion open={@open} multiple={true} on_toggle={:toggle_multi}>
  ...
</MishkaAccordion>

MishkaAccordion.toggle(open, id, multiple: true)

Always keep one panel open

Set collapsible={false} and the open panel cannot be closed by its own header. Opening another item moves the selection, because multiple is still false. Pass collapsible: false to toggle/3 as well.

Always keep one panel open

Accordion card titled Always one open (collapsible: false), with This one starts open showing its text above a closed item, Tap me instead, in the MishkaMob showcase app on Android
elixir
<MishkaAccordion open={@open} collapsible={false} on_toggle={:toggle_locked}>
  ...
</MishkaAccordion>

MishkaAccordion.toggle(open, id, collapsible: false)

Disabled accordion item

An item with disabled={true} renders muted and has no tap handler, so it cannot open. Set disabled on the accordion itself to disable every header.

Disabled accordion item

Accordion card titled Disabled item, with two closed rows: I open normally in dark text and I am disabled in muted gray, in the MishkaMob showcase app on Android
Two accordion items: the first expanded with a line of text, the second greyed out and collapsed.

Left: the example as it loads. Right: after a tap. A normal item opens while the disabled one stays inert.

heex
<MishkaAccordion open={@open} on_toggle={:toggle_disabled}>
  <MishkaAccordionItem id={:ok} title="I work" >...</MishkaAccordionItem>
  <MishkaAccordionItem id={:nope} title="I am disabled" disabled={true}>
    ...
  </MishkaAccordionItem>
</MishkaAccordion>

Know which way the accordion moved

on_toggle tells you which header was tapped. on_open_change adds the state after the tap, and on_value_change hands you the whole open set, already resolved. A header sends one message, so pick one of the three. If you set more than one, on_value_change wins over on_open_change, which wins over on_toggle.

Know which way the accordion moved

Accordion card titled Knowing which way it went, with two closed rows, Shipping and Returns, above a muted note saying nothing has been opened or closed yet
An accordion with Shipping expanded and a small log line below reading Last: shipping opened.

Left: the example as it loads. Right: after a tap. The callback reports which way the item went.

elixir
# on_toggle — which trigger. The screen works the rest out itself.
<MishkaAccordion open={@open} on_toggle={:toggled} />
def handle_info({:tap, {:toggled, id}}, socket) do
  {:noreply, assign(socket, :open, MishkaAccordion.toggle(socket.assigns.open, id))}
end

# on_open_change — …and which WAY. Use it to load a panel body lazily,
# or to fire analytics only on close.
<MishkaAccordion open={@open} on_open_change={:changed} />
def handle_info({:tap, {:changed, id, true}}, socket), do: {:noreply, load(socket, id)}
def handle_info({:tap, {:changed, _id, false}}, socket), do: {:noreply, socket}

# on_value_change — the least work. The payload IS the next open set,
# computed with the same toggle/3, so the handler is a bare assign and
# cannot drift from the component's multiple / collapsible semantics.
<MishkaAccordion open={@open} multiple={true} on_value_change={:opened} />
def handle_info({:tap, {:opened, next}}, socket) do
  {:noreply, assign(socket, :open, next)}
end

Props

Props of the <MishkaAccordion> tag.

PropTypeDefaultDescription
openlist of item ids[]The open set. It lives in the screen, because composites are stateless.
multiplebooleanfalseLets several panels be open at once. When false, opening one closes the rest.
collapsiblebooleantrueLets the open item be closed by its own header.
disabledbooleanfalseDisables every header. It cascades to all items.
on_toggleevent tagNoneSent as {:tap, {tag, item_id}}, so one handler serves every item.
chevronbooleantrueShows the ▸ or ▾ state indicator on each header.
backgroundcolor / ARGB:surface_raisedItem background.
corner_radiusradius / number:radius_mdRounds each item.
paddingspacing / number:space_mdPadding inside the header and the panel.
spacenumber8Gap between items. Use 0 to join them into one block.
colorcolor / ARGB:on_surfaceColor of the item title and chevron. Set it whenever you set background.
on_open_changeevent tagNoneSent as {:tap, {tag, item_id, open?}}, where open? is the state after the tap.
on_value_changeevent tagNoneSent as {:tap, {tag, next_open_set}}, already resolved. Only one of the three event props fires.

Item props

Props of each <MishkaAccordionItem> child.

PropTypeDefaultDescription
idtermPosition in the list (0-based)Identifies the item in the open set and in event messages.
titlestringNoneThe header label.
disabledbooleanfalseMakes this item inert. It renders muted and wires no tap.

Events

A tap carries no payload, so the event tag is the payload and a header can send only one message. The three event props are alternatives. Your screen receives each one as {:tap, message} in handle_info/2.

PropMessageUse it to
on_toggle{:tap, {tag, item_id}}Learn which header was tapped.
on_open_change{:tap, {tag, item_id, open?}}Learn which header and which way. Load a panel body lazily, or send analytics only on close.
on_value_change{:tap, {tag, next_open_set}}Receive the next open set, computed by the same toggle/3, so the handler is a bare assign.

A handler for on_toggle that keeps the open set in the screen:

elixir
def handle_info({:tap, {:toggle_faq, id}}, socket) do
  open = MishkaAccordion.toggle(socket.assigns.faq_open, id, multiple: false)
  {:noreply, Mob.Socket.assign(socket, :faq_open, open)}
end

Differences from the web component

The web engine emits two events for each click: on_open_change on the item and on_value_change on the root. A phone tap sends one message, so the Mob props are alternatives. A phone also has no DOM, no CSS cascade and no roving tab focus, so these web features are not ported:

  • orientation and loop, which move focus with the arrow keys.
  • heading_level, which sets <h1> to <h6> semantics.
  • hidden_until_found, which lets a browser's find-in-page open a panel.
  • Every *_class attribute.