PetalComponents.Tree (petal_components v4.15.4)

Copy Markdown View Source

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

keymeaning
:idrequired, unique within the tree; stringified for the DOM
:labelrequired, the visible text
:childrena list of node maps; a non-empty list makes the node a branch
:icona heroicon name overriding the default folder/document icon
:disabledrenders the node non-selectable (still focusable, per the APG)
:lazymarks 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))}
end

Clicking 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".

keydoes
Down / Upmove through visible nodes
Rightexpand a collapsed branch, or move to its first child
Leftcollapse an expanded branch, or move to the parent
Home / Endfirst / last visible node
Enter / Spaceselect 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

tree(assigns)

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: [...]}. :id and :label are required; :children makes a node a branch, :icon overrides the default icon, :disabled makes it non-selectable and :lazy marks a branch whose children load async. Extra keys pass through to the :item slot. 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 to nil.
  • selected (:string) - id of the currently selected node (single selection). Defaults to nil.
  • select_event (:string) - event name pushed when a node is chosen; the node id rides in phx-value-id. Defaults to nil.
  • 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 to nil.
  • 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 to nil.
  • default_expanded (:any) - ids of branches expanded at first render, or :all to 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 to true.
  • target (:any) - phx-target for the select and expand events, for trees inside a LiveComponent. Defaults to nil.
  • show_guides (:boolean) - render connecting indent guide lines down each expanded branch. Defaults to false.
  • class (:any) - extra classes for the tree container. Defaults to nil.
  • 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).