Tree

v0.0.10

v0.0.10StableHeadlessStatelessAccessibleJSApache-2.0
Installation mix mishka.ui.gen.headless tree
Source View Source

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_web
      • endpoint.ex
      • router.ex
  • mix.exs
  • README.md
heex
<.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
heex
<.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.

elixir
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.

heex
<: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.

heex
<.tree
  id="project-files"
  nodes={@nodes}
  expanded={@expanded}
  select_on_click
  on_expand="expanded"
  on_collapse="collapsed"
  on_select="selected"
  aria_label="Project files"
/>
elixir
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.

heex
<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>
elixir
def handle_event("save", params, socket) do
  permissions = params["permissions"] || []
  {:noreply, assign(socket, :saved, permissions)}
end

Loading 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.

heex
<.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>
elixir
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})}
end

Here 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.

heex
<.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>
elixir
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))}
end

Layout 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.

Partdata-partState / ARIAPurpose
Root-role="tree", aria-label, aria-multiselectable, data-tree-root, data-with-lines, data-with-checkboxesThe 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-loadingOne li for each node. It sets --label-offset. aria-selected and aria-checked never appear together: a tree with checkboxes reports aria-checked.
Labellabeldata-selected, data-dragging, data-drag-overThe clickable row of a node. data-drag-over is before, after or inside while a node is dragged over it.
Subtreesubtreerole="group", data-level, hiddenThe nested ul of a node's children. It is hidden while collapsed.
Checkboxcheckboxaria-checked, aria-hiddenA native checkbox with tabindex -1, so the node keeps the tab stop. It posts under name.
Expand iconexpand-iconaria-hidden, data-has-childrenHolds the expand_icon slot, for nodes with children only.
Drag handledrag-handlearia-labelRendered with draggable and with_drag_handle. Holds the drag_icon slot.
Label textlabel-text-Holds the node slot, or the node's label.
Loaderloader-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.