Tree

v0.0.10

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

The Mishka Chelekom Mob Tree shows hierarchical data on iOS and Android screens built with Mob as rows that expand, select and check. It is the native port of the headless Tree.

Overview of the Mob Tree component for iOS and Android

A tree takes a list of node maps and draws the rows that are on screen. A row can have a disclosure arrow, a checkbox, an icon, a label and a trailing note. The tree is controlled: your screen owns expanded, selected, checked and loading, and the component reports taps back to it.

Nodes

A node is a map with label and value. It can also have children, icon, meta (a trailing note, such as a size, a count or a date), disabled, selectable and has_children. Nesting is arbitrary.

elixir
[
  %{label: "lib", value: "lib", children: [
    %{label: "mishka_mob", value: "lib/mishka_mob", children: [
      %{label: "app.ex", value: "lib/mishka_mob/app.ex"}
    ]}
  ]},
  %{label: "mix.exs", value: "mix.exs"}
]

Three of those keys describe a kind of node:

  • disabled: true wires nothing: no tap, no hold and no checkbox.
  • selectable: false makes a category header. It can never be selected, and tapping it toggles its branch, even with expand_on_click: false. This lets a heading be a row that is not a destination.
  • has_children: true with no children is a branch whose contents are not fetched yet. It draws a disclosure like any other parent, and the first tap reports on_load_children instead of on_expand, so the screen knows to fetch them.

Nodes as markup

When the hierarchy is known in advance, write it as <MishkaTreeNode> children. The tag takes label and value, plus any of icon, meta, disabled, selectable, has_children and children. The tags inside it are its children.

heex
<MishkaTree expanded={@open} on_expand={:open} on_select={:pick}>
  <MishkaTreeNode label="lib" value="lib" icon="📁">
    <MishkaTreeNode label="app.ex" value="lib/app.ex" icon="📄" meta="12 KB" />
  </MishkaTreeNode>
  <MishkaTreeNode label="mix.exs" value="mix.exs" icon="📄" disabled={true} />
</MishkaTree>

node/3 builds the same node map by hand, so the two forms mix. Written nodes come first, and the nodes prop follows. Use the nodes prop when the hierarchy is data, such as a directory listing or a category table. toggle_check/4 and select_range/4 take the node list too, so a tree with checkboxes or ranges needs it in the screen anyway.

A tap does one thing

On the web, a click can expand and select. A Mob node has a single on_tap, so a row picks one. A branch that may expand expands (expand_on_click, and always for a selectable: false header), and anything else selects (select_on_click). The disclosure arrow keeps its own handler either way, so a tree with expand_on_click: false is still fully navigable.

select_on_click defaults to true here, where the web defaults to false. A pointer has a second button and a modifier key to spare, and a finger has neither. A row that reports nothing when tapped would feel broken on a phone.

Long press selects a range

Range selection is Shift and click on the web. A touch screen has no Shift key, so a long press reports on_range_select. select_range/4 turns the anchor and the held row into the values between them. The range follows the rows you can see, and rows that cannot be selected, such as disabled rows and selectable: false headers, drop out of the middle.

Checkbox state

With with_checkboxes, a parent reflects its descendants. It is checked when they all are and indeterminate when only some are. check_state/4 computes the state, and toggle_check/4 applies a tap the way the web engine does, cascading to descendants unless check_strictly is set.

A flat layout

The web component nests lists inside list items. Nested columns would add a layout node for every level whether or not it is expanded, so the Mob tree does not use them. visible/2 flattens the hierarchy into exactly the rows on screen, each with its depth, and the component draws one flat Column of indented rows. A collapsed subtree costs nothing, and the row list is easy to assert on.

Generate the tree component

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

bash
mix mishka.ui.gen.mob tree

The generator registers the tag, so you can use it in ~MOB markup. The tree draws checkboxes, so the generator also creates the Checkbox component (MishkaCheckbox) with the same prefix, and copies the shared Event support module. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the module is <App>.Components.MishkaTree and the tag is <MishkaTree>. Without a prefix, the tag is <Tree>.

Expand and select tree nodes

Tapping a folder opens it, and tapping a file selects it. One tap does one thing, and the branch wins where there is one. with_lines draws guide lines at each level.

Expand and select tree nodes

Expand and select card: a file tree with guide lines, an open lib folder holding a closed mishka_mob folder and mishka_mob.ex, then a closed test folder and a dimmed mix.exs
A file tree with lib and mishka_mob expanded, app.ex highlighted, and the line Picked: lib/mishka_mob/app.ex below.

Left: the example as it loads. Right: after a tap. Tap a folder to open it and a file to pick it.

elixir
<MishkaTree
  nodes={@nodes}
  expanded={@expanded}
  selected={@selected}
  with_lines={true}
  expand_on_click={true}
  select_on_click={true}
  on_expand={:open}
  on_collapse={:close}
  on_select={:pick}
/>

def handle_info({:tap, {:open, value}}, socket) do
  {:noreply,
   Mob.Socket.assign(
     socket,
     :expanded,
     MishkaTree.toggle_expand(value, socket.assigns.expanded)
   )}
end

def handle_info({:tap, {:pick, value}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :selected, MishkaTree.toggle_select(value, socket.assigns.selected))}
end

Tree with cascading checkboxes

With with_checkboxes, every row gets a checkbox. Checking a directory checks what is inside it, as the web component does, and a parent is indeterminate when only some of its descendants are checked.

select_on_click={false} turns off row selection in this tree.

Tree with cascading checkboxes

Checkboxes cascade card: a file tree with an unchecked checkbox on every row, folders lib and test and a dimmed mix.exs, above the gray text Checked: nothing
A file tree with checkboxes where lib and all the files inside it are checked, and a Checked line listing them.

Left: the example as it loads. Right: after a tap. Checking a parent cascades to its children.

elixir
<MishkaTree
  nodes={@nodes}
  checked={@ticked}
  with_checkboxes={true}
  select_on_click={false}
  on_check={:check}
/>

# Checking a directory checks what is inside it, which is what the web does.
def handle_info({:tap, {:check, value}}, socket) do
  {:noreply,
   Mob.Socket.assign(socket, :ticked, MishkaTree.toggle_check(@nodes, value, socket.assigns.ticked))}
end

Multiple selection and range selection

A touch screen has no Shift key, so a long press is the range. Tap one name, hold another, and everything selectable between them joins the selection. The headers and the disabled row drop out of the middle.

A tap also sets the anchor, so the next long press measures its range from the last tapped row. toggle_select/3 with multiple: true adds and removes values. Without it, a tap replaces the selection.

Multiple selection and range selection

Multiple, and hold for a range card: headers DESIGN with Ada, Bo and Cy and ENGINEERING with Dee, a dimmed Eve and Fen, and a Clear button below
A grouped list with DESIGN and ENGINEERING headings where Ada, Bo and Cy are selected, with a Team line and a Clear button.

Left: the example as it loads. Right: after a long press. Tap one name and hold another to select the range between.

elixir
<MishkaTree
  id="multi"
  nodes={@people}
  expanded={:all}
  selected={@picked}
  with_expand_icon={false}
  expand_on_click={false}
  allow_range_selection={true}
  on_select={:pick}
  on_range_select={:range}
/>

# multiple: true adds and removes; without it a tap replaces.
def handle_info({:tap, {:pick, value}}, socket) do
  {:noreply,
   socket
   |> Mob.Socket.assign(:picked, MishkaTree.toggle_select(value, socket.assigns.picked, multiple: true))
   |> Mob.Socket.assign(:anchor, value)}
end

# The hold is Shift+click: the run goes from the last tap to here.
def handle_info({:tap, {:range, value}}, socket) do
  run = MishkaTree.select_range(@people, :all, socket.assigns.anchor, value)
  {:noreply, Mob.Socket.assign(socket, :picked, Enum.uniq(socket.assigns.picked ++ run))}
end

Headers and children loaded on demand

PROJECT has selectable: false, so it is a header that opens its branch instead of pretending to be a destination. The assets node has has_children: true and no children, so its first tap reports on_load_children, and its row shows the loader until the children arrive.

Pass the value of the row that is fetching in loading, and set loader_icon to change the glyph the row draws while it waits.

Headers and children loaded on demand

Headers, and children on demand card: an open PROJECT header with README.md and a 2 KB note and a closed assets folder, above Fetched: nothing yet and a Forget button
A tree with PROJECT open, README.md and an expanded assets folder holding logo.png and app.css, and the line Fetched: 2 files.

Left: the example as it loads. Right: after a tap. Children are fetched the first time a branch opens.

elixir
<MishkaTree
  id="async"
  nodes={remote_nodes(@loaded?)}
  expanded={@open}
  selected={@picked}
  loading={@loading}
  on_expand={:open_node}
  on_collapse={:close_node}
  loader_icon="⟳"
  on_load_children={:fetch}
  on_select={:pick}
/>

# has_children: true with no :children — the first tap is a request, not
# a state change, so it arrives as its own event.
def handle_info({:tap, {:fetch, value}}, socket) do
  Process.send_after(self(), {:tap, {:arrived, value}}, 1_000)
  {:noreply, Mob.Socket.assign(socket, :loading, [value])}
end

def handle_info({:tap, {:arrived, value}}, socket) do
  {:noreply,
   socket
   |> Mob.Socket.assign(:loaded?, true)
   |> Mob.Socket.assign(:loading, [])
   |> Mob.Socket.assign(:open, [value | socket.assigns.open])}
end

Strict checks with every branch open

expanded={:all} opens every branch. check_strictly keeps a tick on the row it was made on, so a parent no longer cascades to its children. expand_icon and collapse_icon set the disclosure glyphs, and level_offset sets the indent per level.

:all is a shorthand for rendering, not a state. Pass the nodes to toggle_expand/3 so it can turn the shorthand into a list before it closes a branch.

Strict checks with every branch open

Strict checks, everything open card: a fully open file tree with an unchecked checkbox on every row, minus signs as disclosure glyphs and a wide indent, above Strictly: nothing
elixir
<MishkaTree
  id="strict"
  nodes={@nodes}
  expanded={:all}
  checked={@ticked}
  with_checkboxes={true}
  check_strictly={true}
  expand_icon="+"
  collapse_icon="−"
  level_offset={28}
  on_expand={:open}
  on_collapse={:close}
  on_check={:tick}
/>

# strictly: true — the tick stays on the row it was made on.
def handle_info({:tap, {:tick, value}}, socket) do
  next = MishkaTree.toggle_check(@nodes, value, socket.assigns.ticked, strictly: true)
  {:noreply, Mob.Socket.assign(socket, :ticked, next)}
end

# :all is a shorthand, not a state — pass the nodes so it can be
# materialised before the branch is closed.
def handle_info({:tap, {:close, value}}, socket) do
  next = MishkaTree.toggle_expand(value, socket.assigns.open, @nodes)
  {:noreply, Mob.Socket.assign(socket, :open, next)}
end

Tree select

A tree select is a trigger that shows the current selection, with the tree in a panel beneath it. Picking a value closes the panel, which is the point of a select. The tree select component has its own page.

Tree select

Tree select card: a closed tree select trigger showing the muted placeholder Choose a file with a small down caret, in the MishkaMob showcase app on Android
A tree select trigger with an open panel showing a file tree of lib, test and mix.exs.

Left: the example as it loads. Right: after a tap. A tree in a panel under a select style trigger.

elixir
<MishkaTreeSelect label={@picked} open={@open} on_toggle={:ts_toggle}>
  <MishkaTree id="ts" nodes={@nodes} on_select={:ts_pick} />
</MishkaTreeSelect>

# Picking closes the panel, which is the whole point of a select.
def handle_info({:tap, {:ts_pick, value}}, socket) do
  {:noreply,
   socket
   |> Mob.Socket.assign(:picked, value)
   |> Mob.Socket.assign(:open, false)}
end

Helper functions

FunctionWhat it does
visible(nodes, expanded)Returns the rows on screen as {node, depth, expandable?, expanded?}.
toggle_expand(value, expanded, nodes \\ [])Opens or closes value in an expanded list. Pass the nodes when expanded is :all.
toggle_select(value, selected, opts \\ [])Applies a selection tap. Single selection replaces, and multiple: true adds and removes.
select_range(nodes, expanded, anchor, value)Returns the selectable values between anchor and value on screen.
check_state(nodes, value, checked, opts \\ [])Returns :checked, :unchecked or :indeterminate. strictly: true reads only the node's own membership.
toggle_check(nodes, value, checked, opts \\ [])Applies a checkbox tap, cascading to descendants unless strictly: true.
expanded_values(nodes)Returns every branch value, which is what :all stands for.
subtree_values(nodes, value)Returns value and every value under it.
node(label, value, opts \\ [])Builds a node map, the function form of <MishkaTreeNode>.
row_tag/2, toggle_tag/2, check_tag/2, state_tag/3Build the test tags, so a device suite never hardcodes their shape.

Test tags

The tree shows expansion with a glyph, selection with a color and weight, and a check with a drawn mark. A device test reads none of those, so id prefixes a tag on every part and the tags that describe state carry it. Rows, disclosures and checkboxes are tappable, so they merge their children's semantics. A device test reaches the state tags with useUnmergedTree = true.

TagWhere
<id>-row-<value>The whole row, the tap and long press target
<id>-toggle-<value>The disclosure
<id>-check-<value>The checkbox
<id>-<value>-open, -closedThe arrow glyph, by which way it points
<id>-<value>-selected, -idleThe label
<id>-<value>-checked, -mixed, -emptyThe indicator, added by MishkaCheckbox
<id>-<value>-loadingThe loader, while children are on their way

Props

PropTypeDefaultDescription
nodeslist of node maps[]The hierarchy. A node has label, value, children, icon, meta, disabled, selectable and has_children.
idstring"tree"Prefix for every test tag: row, toggle, check and the state tags.
expandedlist of values, or :all[]Which nodes are open. :all opens every branch, and expanded_values/1 turns it into a list.
selectedlist of values[]Which nodes are selected. The screen owns the state.
checkedlist of values[]Which nodes are checked.
loadinglist of values[]Which nodes are fetching their children.
with_checkboxesbooleanfalseDraws a checkbox on each node, with indeterminate parents.
check_strictlybooleanfalseDoes not cascade a check to descendants.
with_expand_iconbooleantrueDraws the disclosure.
expand_iconstring"▸"Disclosure glyph for a closed branch.
collapse_iconstring"▾"Disclosure glyph for an open branch.
loader_iconstring"…"Drawn on a row that is in loading. It replaces the loader slot of the web component.
with_linesbooleanfalseDraws guide lines at each level.
level_offsetnumber18Indent per depth.
expand_on_clickbooleantrueA tap on a branch toggles it. The branch wins where there is one.
select_on_clickbooleantrueA tap on anything else selects it.
allow_range_selectionbooleantrueA long press reports on_range_select, the touch equivalent of Shift and click.
on_expand, on_collapseevent tag (atom)NoneSends {:tap, {tag, value}}.
on_select, on_checkevent tag (atom)NoneSends {:tap, {tag, value}}.
on_load_childrenevent tag (atom)NoneThe first tap on a has_children branch.
on_range_selectevent tag (atom)NoneA long press on a row, when range selection is on.

The tree has no multiple prop, because your screen owns selected. Pass multiple: true to toggle_select/3 instead.

Events

Every on_* prop sends {:tap, {tag, value}} to your screen's handle_info/2, where value is the value of the node. A disabled node sends nothing. The helper functions above compute the next state, so each clause is a single call.

elixir
def handle_info({:tap, {:open, value}}, socket) do
  {:noreply,
   Mob.Socket.assign(
     socket,
     :expanded,
     MishkaTree.toggle_expand(value, socket.assigns.expanded)
   )}
end

def handle_info({:tap, {:pick, value}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :selected, MishkaTree.toggle_select(value, socket.assigns.selected))}
end

Differences from the web component

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

  • draggable, with_drag_handle, allow_drop, on_drag_drop and the <:drag_icon> slot. Mob's only drag gesture belongs to a static :canvas.
  • expand_on_space, check_on_space and the rest of the keyboard key map.
  • keep_mounted. A collapsed branch is never built, so there is nothing to keep.
  • clear_selection_on_outside_click. There is no outside on a phone.
  • aria_label and drag_handle_label. Mob exposes no accessibility labels.
  • name and on_target, which are form and LiveView plumbing.
  • The *_class attributes.
  • The <:node> slot, which renders a custom label. A node's icon and meta keys are the parts of a custom label that a row has room for.
  • The <:expand_icon> and <:loader> slots. They became the glyph props expand_icon, collapse_icon and loader_icon, because a native row can only vary the character.