Tree
v0.0.10
mix mishka.ui.gen.headless tree
Overview of the Phoenix and LiveView Headless Tree Component
The Mishka Chelekom Headless Tree renders hierarchical data as an accessible tree view in Phoenix and LiveView. The server draws the whole hierarchy as nested <ul role="tree"> and <li role="treeitem"> elements from nodes, a list of plain maps that can come straight from a database or the file system. The Tree hook then handles expanding and collapsing, single and multiple selection, Shift range selection, cascading checkboxes with an indeterminate state, keyboard navigation, drag and drop, and loading children on demand.
The component ships no colors, spacing or sizes. The root takes class, and the parts take node_class, label_class, subtree_class, checkbox_class, expand_icon_class, drag_handle_class, label_text_class and loader_class. Icons come from slots, because the component ships no icon set.
Base UI Tree example - project file explorer (Phoenix headless)
Base UI has no tree, so this one is styled the way its other components are: a bordered explorer, a caret that turns a quarter turn on open folders, and rows that fill in when selected. Click a row to select it, click a folder to open or close it, or focus the tree and use the arrow keys.
Base UI Tree example - project file explorer (Phoenix headless)
-
lib
-
my_app
-
accounts.ex
-
repo.ex
-
-
my_app_web
-
endpoint.ex
-
router.ex
-
-
-
mix.exs
-
README.md
<.tree
id="baseui-tree-hero"
aria_label="Project files"
select_on_click
expanded={["lib", "lib/my_app_web"]}
selected={["lib/my_app_web/router.ex"]}
nodes={[
%{
label: "lib",
value: "lib",
children: [
%{
label: "my_app",
value: "lib/my_app",
children: [
%{label: "accounts.ex", value: "lib/my_app/accounts.ex"},
%{label: "repo.ex", value: "lib/my_app/repo.ex"}
]
},
%{
label: "my_app_web",
value: "lib/my_app_web",
children: [
%{label: "endpoint.ex", value: "lib/my_app_web/endpoint.ex"},
%{label: "router.ex", value: "lib/my_app_web/router.ex"}
]
}
]
},
%{label: "mix.exs", value: "mix.exs"},
%{label: "README.md", value: "README.md"}
]}
class="w-64 max-w-full border border-neutral-950 bg-white py-1 text-neutral-950 select-none dark:border-white dark:bg-neutral-950 dark:text-white"
node_class="outline-none [&:focus-visible>[data-part=label]]:outline-2 [&:focus-visible>[data-part=label]]:-outline-offset-1 [&:focus-visible>[data-part=label]]:outline-neutral-950 dark:[&:focus-visible>[data-part=label]]:outline-white"
label_class="flex h-8 cursor-default items-center gap-1.5 pr-3 pl-[calc(var(--label-offset)+0.5rem)] text-sm leading-none whitespace-nowrap hover:not-data-[selected]:bg-neutral-100 data-[selected]:bg-neutral-950 data-[selected]:text-white dark:hover:not-data-[selected]:bg-neutral-800 dark:data-[selected]:bg-white dark:data-[selected]:text-neutral-950"
expand_icon_class="grid size-4 shrink-0 place-items-center transition-transform duration-100 ease-[ease-out] [[data-expanded=true]>[data-part=label]>&]:rotate-90"
label_text_class="truncate"
>
<:expand_icon><.icon name="hero-chevron-right-mini" class="size-4" /></:expand_icon>
</.tree>Tree example - checkboxes that cascade (Phoenix headless)
With with_checkboxes every node gets a checkbox. Checking a group checks its children, and a group with only some of them checked is indeterminate. "Delete posts" is disabled, so the cascade skips it.
Tree example - checkboxes that cascade (Phoenix headless)
-
Content
-
Read posts
-
Write posts
-
Delete posts
-
-
Users
-
Invite users
-
Remove users
-
<.tree
id="permissions-tree"
aria_label="Permissions"
with_checkboxes
expanded={["content", "users"]}
checked={["content:read"]}
nodes={[
%{
label: "Content",
value: "content",
children: [
%{label: "Read posts", value: "content:read"},
%{label: "Write posts", value: "content:write"},
%{label: "Delete posts", value: "content:delete", disabled: true}
]
},
%{
label: "Users",
value: "users",
children: [
%{label: "Invite users", value: "users:invite"},
%{label: "Remove users", value: "users:remove"}
]
}
]}
class="w-64 max-w-full border border-neutral-950 bg-white py-1 text-neutral-950 select-none dark:border-white dark:bg-neutral-950 dark:text-white"
node_class="outline-none [&:focus-visible>[data-part=label]]:outline-2 [&:focus-visible>[data-part=label]]:-outline-offset-1 [&:focus-visible>[data-part=label]]:outline-neutral-950 dark:[&:focus-visible>[data-part=label]]:outline-white data-[disabled]:opacity-50"
label_class="flex h-8 cursor-default items-center gap-2 pr-3 pl-[calc(var(--label-offset)+0.5rem)] text-sm leading-none whitespace-nowrap hover:bg-neutral-100 dark:hover:bg-neutral-800"
checkbox_class="size-4 shrink-0 accent-neutral-950 dark:accent-white"
expand_icon_class="grid size-4 shrink-0 place-items-center transition-transform duration-100 ease-[ease-out] [[data-expanded=true]>[data-part=label]>&]:rotate-90"
label_text_class="truncate"
>
<:expand_icon><.icon name="hero-chevron-right-mini" class="size-4" /></:expand_icon>
</.tree>Nodes and the node slot
Every node is a map with :label and :value. The value names the node in every event and in expanded, selected and checked. The other keys are optional: :children, :has_children for children that load later, :icon, :disabled, and :selectable, which defaults to true. A node with selectable: false stays expandable but is never selected, and a click or Enter on it toggles the branch instead.
nodes = [
%{
label: "Content",
value: "content",
children: [
%{label: "Read posts", value: "content:read", icon: "hero-eye"},
%{label: "Delete posts", value: "content:delete", disabled: true}
]
},
%{label: "Lazy folder", value: "lazy", has_children: true},
%{label: "Reports", value: "reports", selectable: false, children: [%{label: "Q1", value: "reports/q1"}]}
]The node slot draws the label. It receives the node map as node, its icon, and the derived state: level, expanded, selected, checked, indeterminate, has_children and is_root. Without the slot the tree prints :label. The expand_icon slot fills the expand icon of every node that has children, and the loader slot fills the loader part, a busy indicator for children that are loading.
<:node :let={n}>
<.icon :if={n.icon} name={n.icon} class="size-4" />
{n.node.label}
</:node>Expanded, selected and checked
expanded takes a list of values, or :all to open every node that has children. selected and checked take lists of values. The server renders all three as the first state only. After mount the hook owns them and keeps them across patches, and the server takes control back by changing the assign, for example a button that sets expanded to :all.
Selection is off by default. select_on_click selects a node on click, multiple allows more than one, allow_range_selection (on by default) adds Shift+click and Shift+Arrow ranges, and clear_selection_on_outside_click clears the selection when the visitor clicks elsewhere. expand_on_click and expand_on_space are on by default. with_checkboxes adds a checkbox to every node. A parent is checked when all its leaves are, and partly checked parents are indeterminate. check_strictly turns the cascade off, and check_on_space checks a node with the Space key.
Events
The tree reports what the visitor does through event names you pass: on_expand and on_collapse send %{"value" => value}, on_select and on_check send %{"values" => values}, on_load_children sends %{"value" => value}, and on_drag_drop sends %{"dragged" => value, "target" => value, "position" => position} where the position is before, after or inside. on_target is a selector for the LiveComponent that should receive the events. Without it the events go to the LiveView or component that renders the tree.
<.tree
id="project-files"
nodes={@nodes}
expanded={@expanded}
select_on_click
on_expand="expanded"
on_collapse="collapsed"
on_select="selected"
aria_label="Project files"
/>def handle_event("expanded", %{"value" => value}, socket), do: ...
def handle_event("collapsed", %{"value" => value}, socket), do: ...
def handle_event("selected", %{"values" => values}, socket), do: ...Checkboxes in a form
Pass name together with with_checkboxes and every checkbox posts under that name with the node's value, so a plain phx-submit receives the checked values with no JavaScript glue. Checking a group cascades to its children. A disabled node is skipped by the cascade and never posted, and a partly checked group posts nothing for itself. When nothing is checked the browser sends nothing, so default to an empty list.
<form id="permissions-form" phx-submit="save">
<.tree
id="permissions-tree"
nodes={@permissions}
expanded={:all}
with_checkboxes
name="permissions[]"
checked={["content:read"]}
aria_label="Permissions"
>
<:expand_icon><.icon name="hero-chevron-right-mini" class="size-4" /></:expand_icon>
</.tree>
<button type="submit">Save permissions</button>
</form>def handle_event("save", params, socket) do
permissions = params["permissions"] || []
{:noreply, assign(socket, :saved, permissions)}
endLoading children on demand
A node with has_children: true and no children asks the server the first time it opens. The tree sets data-loading on the node and sends on_load_children. Style the loader to show only while that attribute is present, as loader_class does below. Put the loaded children into nodes, then push the event tree:<id>:children with the node's value, where <id> is the id of the tree. That clears the loading state.
<.tree
id="project-files"
nodes={@nodes}
aria_label="Project files"
on_load_children="load_children"
loader_class="hidden [[data-loading]>[data-part=label]>&]:block"
>
<:loader><.icon name="hero-arrow-path" class="size-4 animate-spin" /></:loader>
</.tree>def handle_event("load_children", %{"value" => value}, socket) do
children = MyApp.Files.list_children(value)
{:noreply,
socket
|> assign(:nodes, put_children(socket.assigns.nodes, value, children))
|> push_event("tree:project-files:children", %{value: value})}
endHere put_children/3 and MyApp.Files.list_children/1 are your own functions.
Drag and drop
draggable lets visitors drag nodes, and with_drag_handle restricts dragging to a handle drawn by the drag_icon slot. allow_drop accepts any (default), inside-only or reorder-only. While a node is dragged over a row, the row carries data-drag-over with the position that a drop would use. The tree does not move any node. The server owns the data, so the on_drag_drop handler changes nodes and the tree renders the result.
<.tree
id="project-files"
nodes={@nodes}
aria_label="Project files"
draggable
with_drag_handle
allow_drop="reorder-only"
on_drag_drop="dropped"
>
<:drag_icon><.icon name="hero-bars-3" class="size-4" /></:drag_icon>
</.tree>def handle_event("dropped", %{"dragged" => dragged, "target" => target, "position" => position}, socket) do
{:noreply, assign(socket, :nodes, MyApp.Files.move(socket.assigns.nodes, dragged, target, position))}
endLayout options
level_offset is the indentation of each level as any CSS length, 1rem by default. with_lines sets data-with-lines on the root, and you draw the parent and child lines in CSS. with_expand_icon (on by default) renders the expand icon part. keep_mounted (on by default) keeps collapsed subtrees in the DOM as hidden lists. Set it to false to leave them out, and then update expanded from on_expand and on_collapse so the server can render a subtree when it opens. aria_label is the accessible name of the tree and defaults to the English Tree, so set it for each use.
Parts & data attributes
Each part carries a stable data-part and a structural chelekom-tree__* class you can target, plus the state attributes below.
| Part | data-part | State / ARIA | Purpose |
|---|---|---|---|
| Root | - | role="tree", aria-label, aria-multiselectable, data-tree-root, data-with-lines, data-with-checkboxes | The ul that carries the Tree hook. It reads every option and event name from its data attributes and sets --level-offset. |
| Node | - | role="treeitem", aria-level, aria-setsize, aria-posinset, aria-expanded, aria-selected, aria-checked, aria-disabled, data-value, data-level, data-expanded, data-has-children, data-selected, data-checked, data-indeterminate, data-disabled, data-selectable, data-loading | One li for each node. It sets --label-offset. aria-selected and aria-checked never appear together: a tree with checkboxes reports aria-checked. |
| Label | label | data-selected, data-dragging, data-drag-over | The clickable row of a node. data-drag-over is before, after or inside while a node is dragged over it. |
| Subtree | subtree | role="group", data-level, hidden | The nested ul of a node's children. It is hidden while collapsed. |
| Checkbox | checkbox | aria-checked, aria-hidden | A native checkbox with tabindex -1, so the node keeps the tab stop. It posts under name. |
| Expand icon | expand-icon | aria-hidden, data-has-children | Holds the expand_icon slot, for nodes with children only. |
| Drag handle | drag-handle | aria-label | Rendered with draggable and with_drag_handle. Holds the drag_icon slot. |
| Label text | label-text | - | Holds the node slot, or the node's label. |
| Loader | loader | - | Holds the loader slot. Reveal it on the node's data-loading. |
Accessibility
Follows the WAI-ARIA Tree View pattern with a roving tab stop, so one node is in the tab order. Arrow Down and Arrow Up move through the visible nodes. Arrow Right expands a collapsed node, and otherwise moves to its first child. Arrow Left collapses an expanded node, and otherwise moves to its parent. Home and End go to the first and last visible node. Enter selects the focused node. Space expands or collapses it, or checks it when check_on_space is set. Shift+click and Shift+Arrow select a range. The APG asks for an accessible name on the tree, which is what aria_label sets.
Attributes
| Name | Type | Default | Values | Required | Description |
|---|---|---|---|---|---|
id
|
string
| — | — | Yes | Unique id (the engine keys its server events on it) |
nodes
|
list
| — | — | Yes | Nested list of node maps: label, value, children, has_children, icon, disabled, selectable |
level_offset
|
string
|
1rem
| — | No | Horizontal indentation of each subtree level; any CSS length (emitted as --level-offset) |
expanded
|
any
|
[]
| — | No | Values expanded on first render, or `:all` |
selected
|
list
|
[]
| — | No | Values selected on first render |
checked
|
list
|
[]
| — | No | Values checked on first render |
expand_on_click
|
boolean
|
true
| — | No | Expand a node with children on click |
expand_on_space
|
boolean
|
true
| — | No | Expand a node on space key press |
check_on_space
|
boolean
|
false
| — | No | Check a node on space key press |
select_on_click
|
boolean
|
false
| — | No | Select a node on click |
multiple
|
boolean
|
false
| — | No | Allow more than one node to be selected |
allow_range_selection
|
boolean
|
true
| — | No | Select a range of nodes with Shift+click or Shift+arrow |
clear_selection_on_outside_click
|
boolean
|
false
| — | No | Clear the selection when clicking outside of the tree |
with_checkboxes
|
boolean
|
false
| — | No | Render a checkbox next to every node |
name
|
string
| — | — | No | Form field name for the checkboxes (e.g. `permissions[]`). With it the checked values post natively — no JS glue |
check_strictly
|
boolean
|
false
| — | No | Checking a node does not cascade to its parent or children |
with_lines
|
boolean
|
false
| — | No | Emit `data-with-lines` on the root; the consumer draws the parent/child lines |
with_expand_icon
|
boolean
|
true
| — | No | Render the expand-icon part (filled by `<:expand_icon>` for nodes with children) |
keep_mounted
|
boolean
|
true
| — | No | Render collapsed subtrees in the DOM (hidden). Set false to omit them — that needs `on_expand`/`on_collapse` so the server knows to render a subtree once it opens |
draggable
|
boolean
|
false
| — | No | Enable drag and drop reordering |
with_drag_handle
|
boolean
|
false
| — | No | Drag only from an explicit handle |
allow_drop
|
string
|
any
|
anyinside-onlyreorder-only
| No | Restrict which drop positions are accepted |
on_expand
|
string
| — | — | No | Event pushed when a node is expanded |
on_collapse
|
string
| — | — | No | Event pushed when a node is collapsed |
on_select
|
string
| — | — | No | Event pushed when the selection changes |
on_check
|
string
| — | — | No | Event pushed when the checked state changes |
on_drag_drop
|
string
| — | — | No | Event pushed when a node is dropped |
on_load_children
|
string
| — | — | No | Event pushed the first time a node with `has_children: true` is expanded |
on_target
|
string
| — | — | No | Live component target for the pushed events |
aria_label
|
string
|
Tree
| — | No | Accessible name for the tree. The APG requires one; override it per usage |
drag_handle_label
|
string
|
Drag handle
| — | No | Accessible name for each drag handle |
class
|
any
| — | — | No | Extra classes for the root |
node_class
|
any
| — | — | No | Extra classes for each node |
label_class
|
any
| — | — | No | Extra classes for each node's label row |
subtree_class
|
any
| — | — | No | Extra classes for each subtree |
checkbox_class
|
any
| — | — | No | Extra classes for each checkbox |
expand_icon_class
|
any
| — | — | No | Extra classes for each expand-icon part |
drag_handle_class
|
any
| — | — | No | Extra classes for each drag handle |
label_text_class
|
any
| — | — | No | Extra classes for each label text |
loader_class
|
any
| — | — | No | Extra classes for each loader |
rest
|
global
| — | — | No | — |
Slots
| Name | Required | Description |
|---|---|---|
expand_icon
| No | Shared icon rendered inside every parent node's expand-icon (e.g. a chevron) |
drag_icon
| No | Shared icon rendered inside every drag handle |
loader
| No | Shared busy indicator, revealed on a node's `data-loading` |
node
| No | Renders a node label. Receives the node map plus its derived state |
Options
Each option takes one or more of its values, separated by commas.
| Option | Values |
|---|---|
--type
|
tree
|
--module
| A module name of your own |
What it generates
mix mishka.ui.gen.headless tree