A hierarchical tree view: the file-explorer staple.
A tree is for data you EXPLORE, not pages you navigate to. It is a
composite widget - one tab stop, arrow keys traverse it, screen readers
announce it as a tree - which is exactly wrong for site navigation
(the APG says so too): links belong in PetalComponents.Menu.vertical_menu/1,
where Tab and aria-current do what a reader expects. Both drop
straight into a PetalComponents.Sidebar.sidebar_nav/1 - the shell holds
either; they never hold each other. If it's a page, it's a menu item;
if it's a thing, it's a tree node.
tree/1 takes a nested list of maps and renders arbitrary depth with
expand/collapse, single selection, optional connecting indent guides, and the
WAI-ARIA TreeView
keyboard map. Nodes need nothing but an :id and a :label; :children
makes a node a branch.
<.tree
id="files"
label="Project files"
show_guides
default_expanded={["lib"]}
items={[
%{id: "lib", label: "lib", children: [%{id: "app.ex", label: "app.ex"}]},
%{id: "mix.exs", label: "mix.exs"}
]}
/>Node maps
| key | meaning |
|---|---|
:id | required, unique within the tree; stringified for the DOM |
:label | required, the visible text |
:children | a list of node maps; a non-empty list makes the node a branch |
:icon | a heroicon name overriding the default folder/document icon |
:disabled | renders the node non-selectable (still focusable, per the APG) |
:lazy | marks a branch whose children arrive later; shows the :loading row while :children is empty |
Any other key rides along untouched and is handed to the :item slot, so
custom rows can read whatever they need off the node.
The two expansion models
Pick one. Both render identical markup and identical ARIA, so a tree can move from one to the other without a visual change.
Client-side (the default). Leave :expanded unset and seed the open
branches with :default_expanded. The chevron toggles data-expanded and
aria-expanded with Phoenix.LiveView.JS, and CSS animates the height. No
round-trip, no assigns to keep, nothing to handle. The catch: the open/closed
state lives only in the DOM, so a LiveView patch that re-renders the tree
resets it to :default_expanded. Right for static trees (docs navigation, a
settings outline, anything that renders once).
Server-controlled. Pass :expanded (a list or MapSet of branch ids) and
an :on_expand event name. The chevron pushes that event with the node id in
phx-value-id and your handle_event/3 decides what opens. Required for
:lazy branches, whose children only exist after the server has been asked
for them, and for any tree that has to survive phx-update patches.
def handle_event("toggle", %{"id" => id}, socket) do
{:noreply, update(socket, :expanded, &toggle(&1, id))}
endClicking a row
Either model, clicking anywhere on a branch row toggles that branch, and
when selection is wired the same click also selects it - one click, both
outcomes, the way a folder behaves in VS Code or Finder. That is what
:expand_on_click turns on and it defaults to true, because a row-sized
hit target is what people expect from a file tree and hunting a 16px chevron
is the worse default. Set expand_on_click={false} for rows whose click
already has a job - a link tree that must navigate, a row with its own
controls - and expansion goes back to the chevron alone. The chevron toggles
and never selects in both modes, leaves are untouched by either, and the
keyboard map does not move: Enter/Space still selects without expanding.
Selection
Selection is single-select and always server-owned: pass the chosen id as
:selected and give :select_event a name to hear about clicks (the node id
arrives as phx-value-id). The component also flips aria-selected and the
selected class client-side on click, so the highlight lands immediately rather
than a round-trip later. :on_select composes your own JS commands onto that.
Selection only exists when you wire it: with none of :selected,
:select_event or :on_select set, the tree is purely navigational -
nodes carry no aria-selected at all (per the APG, a tree that does not
support selection must not announce it) and label clicks do nothing.
Keyboard
The PetalTree hook implements the APG map over a roving tabindex: the tree
is a single tab stop and exactly one node carries tabindex="0".
| key | does |
|---|---|
Down / Up | move through visible nodes |
Right | expand a collapsed branch, or move to its first child |
Left | collapse an expanded branch, or move to the parent |
Home / End | first / last visible node |
Enter / Space | select the focused node |
* | expand every sibling branch of the focused node |
Expansion and selection are JS commands and server events, not hook internals, so a tree still expands and selects with the pointer if the hook never runs.
Summary
Functions
Renders a tree.
Functions
Renders a tree.
Examples
<.tree id="files" label="Files" items={@files} />
<.tree
id="explorer"
label="Explorer"
show_guides
default_expanded={:all}
selected={@current}
select_event="pick_file"
items={@files}
/>
<.tree id="org" items={@people}>
<:item :let={person}>
<span class="font-medium">{person.label}</span>
<span class="text-xs text-gray-500">{person.title}</span>
</:item>
<:empty>Nobody reports into this team yet.</:empty>
</.tree>Attributes
id(:string) (required) - unique id; the PetalTree hook mounts here for roving focus.items(:list) - nested node maps, e.g.%{id: "lib", label: "lib", children: [...]}.:idand:labelare required;:childrenmakes a node a branch,:iconoverrides the default icon,:disabledmakes it non-selectable and:lazymarks a branch whose children load async. Extra keys pass through to the:itemslot. Defaults to[].label(:string) - accessible name for the tree, rendered as aria-label. Skip it if you label the tree with aria-labelledby via rest. Defaults tonil.selected(:string) - id of the currently selected node (single selection). Defaults tonil.select_event(:string) - event name pushed when a node is chosen; the node id rides in phx-value-id. Defaults tonil.on_select(Phoenix.LiveView.JS) - extra JS commands composed onto the built-in selection behaviour when a node is chosen. Defaults to%Phoenix.LiveView.JS{ops: []}.expanded(:any) - ids of the currently expanded branches (list or MapSet), or:all. Setting this switches the tree to the server-controlled expansion model; leave it nil for the client-side default. Defaults tonil.on_expand(:string) - event name pushed by the chevron in the server-controlled model, with the node id in phx-value-id. Ignored when :expanded is nil. Defaults tonil.default_expanded(:any) - ids of branches expanded at first render, or:allto expand everything. Client-side model only. Defaults to[].expand_on_click(:boolean) - whether clicking anywhere on a branch row toggles it, selecting it too when selection is wired. Set false to leave expansion to the chevron alone, for rows whose click has another job. Leaves and the keyboard map behave the same either way. Defaults totrue.target(:any) - phx-target for the select and expand events, for trees inside a LiveComponent. Defaults tonil.show_guides(:boolean) - render connecting indent guide lines down each expanded branch. Defaults tofalse.class(:any) - extra classes for the tree container. Defaults tonil.- Global attributes are accepted.
Slots
item- custom node rendering; receives the node map via :let. The chevron, indent and ARIA wiring stay owned by the component - the slot replaces the icon and label row content only.empty- rendered when :items is empty.loading- rendered inside a :lazy branch while its children are pending (default: a small spinner row).