Corex.TreeView
(Corex v0.1.0-beta.3)
View Source
Phoenix implementation of the Zag.js Tree View.
## Features
- Declarative — render from a list of items with
Corex.Tree.new/1 - Custom slots — override label, branch, branch indicator, item, and item indicator with
:let={item} - Compound — take full structural control with dedicated sub-components
- Animated — instant, JS (Web Animations API), or fully custom branch expand/collapse
- Navigation — per-item redirect modes (
:href/:patch/:navigate/false) andnew_tab - Client/Server control — uncontrolled by default; opt in to make LiveView the source of truth
- API control — set selected/expanded from JavaScript, a Phoenix binding, or a LiveView event
- API events — subscribe to selection/expanded changes from the client, the server, or both
- Async-ready — pairs with
assign_async/3; includes a skeleton for loading states - Accessible — WAI-ARIA compliant, full keyboard navigation and typeahead via Zag.js
- Unstyled — target
data-partattributes directly or use Corex Design tokens - Localizable — automatic LTR/RTL from the document
## Declarative
Pass a list to Corex.Tree.new/1 to build the tree items. Each item supports:
| Field | Required | Description |
|---|---|---|
:id | yes | Unique identifier used as the node value |
:label | yes | Label shown in the row |
:children | no | Nested list of items to make a branch |
:to | no | Destination URL when using redirect |
:redirect | no | :href (default) | :patch | :navigate | false |
:new_tab | no | Open destination in a new tab |
:disabled | no | Prevents interaction |
### Basic
<.tree_view
id="my-tree"
class="tree-view"
items={
Corex.Tree.new([
%{label: "Components", id: "components", children: [
%{label: "Accordion", id: "accordion"},
%{label: "Checkbox", id: "checkbox"},
%{label: "Tree view", id: "tree-view"}
]},
%{label: "Form", id: "form"},
%{label: "Tree", id: "tree", children: [%{label: "Tree.Item", id: "tree-item"}]}
])
}
/>### With Label
<.tree_view
id="docs-tree"
class="tree-view"
items={
Corex.Tree.new([
%{label: "Guides", id: "guides"},
%{label: "Reference", id: "reference"}
])
}
>
<:label>My Documents</:label>
</.tree_view>### With Indicator
<.tree_view
id="tree-indicator"
class="tree-view"
items={
Corex.Tree.new([
%{label: "src", id: "src", children: [
%{label: "components", id: "components"},
%{label: "index.ts", id: "index.ts"}
]},
%{label: "README.md", id: "readme"}
])
}
>
<:branch_indicator :let={item}>
<.heroicon :if={item.children && item.children != []} name="hero-chevron-right" />
</:branch_indicator>
<:item_indicator>
<.heroicon name="hero-check" />
</:item_indicator>
</.tree_view>## Custom Slots
Customize the rendering of each row with the :label, :branch, :branch_indicator, :item, and :item_indicator slots. Each of the per-row slots receives a :let={item} of type Corex.Tree.Item.
<.tree_view
id="custom-tree"
class="tree-view"
items={
Corex.Tree.new([
%{label: "src", id: "src", children: [
%{label: "components", id: "components", children: [%{label: "tree-view.tsx", id: "tree-view.tsx"}]},
%{label: "main.ts", id: "main.ts"}
]},
%{label: "README.md", id: "readme"}
])
}
>
<:label>Project</:label>
<:branch :let={item}>
<.heroicon name="hero-folder" /> {item.label}
</:branch>
<:item :let={item}>
<.heroicon name="hero-document" /> {item.label}
</:item>
</.tree_view>## Compound
Take full structural control with the tree_view_root, tree_view_branch, tree_view_branch_trigger, tree_view_branch_content, and tree_view_item sub-components. Branches and items resolve their path from ctx.items; iterate recursively or statically.
<.tree_view :let={ctx} compound id="compound-tree" class="tree-view" items={@items}>
<.tree_view_root ctx={ctx}>
<:label>Project</:label>
<.tree_view_branch :let={branch} :for={item <- ctx.items} ctx={ctx} item={item}>
<.tree_view_branch_trigger branch={branch}>
{item.label}
<:indicator>
<.heroicon name="hero-chevron-right" />
</:indicator>
</.tree_view_branch_trigger>
<.tree_view_branch_content branch={branch}>
<.tree_view_item :for={child <- item.children || []} ctx={ctx} item={child}>
{child.label}
</.tree_view_item>
</.tree_view_branch_content>
</.tree_view_branch>
</.tree_view_root>
</.tree_view> ctx is a map with :id, :dir, :animation, :items, :expanded_value, :value, and an internal :index_paths map. Items referenced from tree_view_branch / tree_view_item must be present in ctx.items (they are resolved to their path).
## Patterns
### Initial expanded/selected
expanded_value and value are lists of ids matching items in the tree.
<.tree_view
class="tree-view"
expanded_value={["src", "components"]}
value={["tree-view.tsx"]}
items={
Corex.Tree.new([
%{label: "src", id: "src", children: [
%{label: "components", id: "components", children: [%{label: "tree-view.tsx", id: "tree-view.tsx"}]},
%{label: "main.ts", id: "main.ts"}
]}
])
}
/>### Controlled (LiveView)
def handle_event("tree_expanded", %{"expandedValue" => expanded}, socket) do
{:noreply, assign(socket, :expanded, expanded)}
end
def handle_event("tree_selected", %{"selectedValue" => selected}, socket) do
{:noreply, assign(socket, :selected, selected)}
end
def render(assigns) do
~H"""
<.tree_view
id="controlled-tree"
controlled
class="tree-view"
value={@selected}
expanded_value={@expanded}
on_selection_change="tree_selected"
on_expanded_change="tree_expanded"
items={@items}
/>
"""
end ### Async (assign_async)
def mount(_params, _session, socket) do
socket =
assign_async(socket, :tree, fn ->
{:ok, %{tree: Corex.Tree.new([%{label: "Docs", id: "docs"}])}}
end)
{:ok, socket}
end
def render(assigns) do
~H"""
<.async_result :let={tree} assign={@tree}>
<:loading><.tree_view_skeleton count={3} class="tree-view" /></:loading>
<:failed>Could not load the tree.</:failed>
<.tree_view id="async-tree" class="tree-view" items={tree} />
</.async_result>
"""
end### Navigation (redirect)
Set redirect on the component so selection navigates. Per item, the navigation kind comes from :redirect:
:href(default) — full page redirect viawindow.location(safe everywhere):patch— LiveViewjs().patch(url)(caller asserts: same LV mount + matching live route):navigate— LiveViewjs().navigate(url)(caller asserts: another LV in the samelive_session)false— disable redirect for this item
Set :new_tab on an item to open its destination via window.open.
<.tree_view id="nav" class="tree-view" redirect items={
Corex.Tree.new([
%{label: "Home", id: "home", to: "/", redirect: :patch},
%{label: "External", id: "ext", to: "https://example.com", new_tab: true}
])
} />## Animation
Set animation on the outer tree_view. The hook reads data-animation and data-animation-* on the root.
### instant
Zag toggles the native hidden attribute; no height animation on branch content.
<.tree_view class="tree-view" animation="instant" items={@items} /> ### js (default)
Built-in height and opacity via the Web Animations API. Tune timing with animation_options using Corex.Animation.Height.
<.tree_view
class="tree-view"
animation="js"
animation_options={%Corex.Animation.Height{duration: 0.3, easing: "ease-out"}}
items={@items}
/> ### custom
The hook removes hidden and dispatches a browser CustomEvent whose type is on_expanded_change_client. The event detail is enriched with deltas:
// event.detail (TreeViewExpandedChangedDetail)
{ id, expandedValue, previousExpandedValue, added, removed, focusedValue } Animate branch content yourself, using added/removed to drive the transition without diffing on the client side. The example below also seeds initial closed-state styling on mount and after LiveView navigations.
<.tree_view class="tree-view" animation="custom" on_expanded_change_client="my-tree-expanded" items={@items} /> import { animate } from "motion"
import {
findTreeBranch,
animateHeightOpen,
animateHeightClose,
} from "corex"
const reducedMotion = () =>
window.matchMedia("(prefers-reduced-motion: reduce)").matches
document.addEventListener("my-tree-expanded", (e) => {
const root = document.getElementById(e.detail.id)
if (!root) return
e.detail.added.forEach((v) => {
const el = findTreeBranch(root, v)
if (!el) return
animateHeightOpen(el, { animator: animate, duration: 0.5, easing: [0.16, 1, 0.3, 1] })
if (!reducedMotion()) {
animate(
el,
{ filter: ["blur(8px)", "blur(0px)"], y: [-10, 0] },
{ duration: 0.55, easing: [0.16, 1, 0.3, 1] },
)
}
})
e.detail.removed.forEach((v) => {
const el = findTreeBranch(root, v)
if (!el) return
animateHeightClose(el, { animator: animate, duration: 0.28, easing: "ease-in" })
if (!reducedMotion()) {
animate(
el,
{ filter: ["blur(0px)", "blur(8px)"], y: [0, -8] },
{ duration: 0.26, easing: "ease-in" },
)
}
})
})## API
The API targets one specific tree view via its DOM id.
set_selected_value/2andset_selected_value/3set_expanded_value/2andset_expanded_value/3value/1,value/2, andvalue/3expanded_value/1,expanded_value/2, andexpanded_value/3
For value and expanded_value, use respond_to: :server | :client | :both to control whether the response is pushed to LiveView, dispatched as a DOM event, or both.
<.action phx-click={Corex.TreeView.set_selected_value("my-tree", ["lorem"])}>Select Lorem</.action>
<.action phx-click={Corex.TreeView.set_expanded_value("my-tree", ["lorem"])}>Expand Lorem</.action>
<.action phx-click={Corex.TreeView.value("my-tree")}>Value</.action>
<.action phx-click={Corex.TreeView.expanded_value("my-tree")}>Expanded</.action>## Events
User interaction and imperative API use different channels. See also the on_* attributes on tree_view/1.
### User interaction
When phx-hook="TreeView" is active, Zag invokes callbacks that map to:
on_selection_change—pushEvent/3to LiveView with the name you set. Params:%{"id" => tree_dom_id, "selectedValue" => [...], "previousSelectedValue" => [...], "added" => [...], "removed" => [...], "focusedValue" => focused_or_nil, "isItem" => bool}(TS:TreeViewSelectionChangedDetail).on_selection_change_client— browserCustomEventwhose type is the string you set;event.detailmirrors the push payload (bubbles).on_expanded_change—pushEvent/3with%{"id" => dom_id, "expandedValue" => [...], "previousExpandedValue" => [...], "added" => [...], "removed" => [...], "focusedValue" => focused_or_nil}(TS:TreeViewExpandedChangedDetail).on_expanded_change_client—CustomEventwith the samedetailshape (bubbles). Required foranimation="custom".
### Imperative API (LiveView helpers and client DOM)
From LiveView, see the API list above. All push to the hook; optional respond_to controls where the answer goes.
From the client, dispatch CustomEvents on the tree view root (the same element as id, e.g. #my-tree):
| Dispatch (type) | detail |
|---|---|
corex:tree-view:set-selected-value | value — list of selected ids |
corex:tree-view:set-expanded-value | value — list of expanded ids |
corex:tree-view:value | optional respond_to: "server", "client", or "both" |
corex:tree-view:expanded-value | optional respond_to |
Responses to LiveView (push_event from the hook; handle in handle_event/3):
tree_view_value_response—%{"id" => ..., "value" => [...]}tree_view_expanded_value_response—%{"id" => ..., "value" => [...]}
Responses to the DOM (listen on the tree view root element):
tree-view-value—detail: { id, value }tree-view-expanded-value—detail: { id, value }
## Styling
Zag exposes data-scope="tree-view" and data-part on each element:
[data-scope="tree-view"][data-part="root"] {}
[data-scope="tree-view"][data-part="label"] {}
[data-scope="tree-view"][data-part="tree"] {}
[data-scope="tree-view"][data-part="branch"] {}
[data-scope="tree-view"][data-part="branch-control"] {}
[data-scope="tree-view"][data-part="branch-content"] {}
[data-scope="tree-view"][data-part="branch-indicator"] {}
[data-scope="tree-view"][data-part="branch-text"] {}
[data-scope="tree-view"][data-part="branch-indent-guide"] {}
[data-scope="tree-view"][data-part="item"] {}
[data-scope="tree-view"][data-part="item-text"] {}
[data-scope="tree-view"][data-part="item-indicator"] {} With Corex Design, import tokens and the tree-view stylesheet, then add the tree-view class and modifiers:
@import "../corex/main.css";
@import "../corex/tokens/themes/neo/light.css";
@import "../corex/components/tree-view.css"; <.tree_view class="tree-view tree-view--accent tree-view--lg tree-view--rounded-md tree-view--text-md" items={@items}>
<:label>Project</:label>
</.tree_view>
Summary
Components
Renders a tree branch (node with children). For custom server-rendered branches or testing.
Renders a tree item (leaf). For custom server-rendered items or testing.
Renders a tree view. Pass items as Corex.Tree.new/1. Component id = tree root id; names capitalized from labels.
API
Requests the tree's current expanded values from the browser. Returns a Phoenix.LiveView.JS command.
Requests the tree's current expanded values from the client. Pushes a LiveView event handled by the hook.
Sets the tree expanded value from client-side.
Sets the tree expanded value from server-side.
Sets the tree selected value from client-side.
Sets the tree selected value from server-side.
Requests the tree's current selected values from the browser. Returns a Phoenix.LiveView.JS command.
Requests the tree's current selected values from the client. Pushes a LiveView event handled by the hook.
Components
Renders a tree branch (node with children). For custom server-rendered branches or testing.
Attributes
row(:map) (required)animation(:string) - Defaults to"js".
Slots
inner_block(required)branch(required) - Accepts attributes:class(:string)
branch_indicator(required) - Accepts attributes:class(:string)
Renders a tree item (leaf). For custom server-rendered items or testing.
Attributes
item(:map) (required)
Slots
inner_block(required)
Renders a tree view. Pass items as Corex.Tree.new/1. Component id = tree root id; names capitalized from labels.
Attributes
id(:string) - The id of the tree view, useful for API to identify the component.items(:list) (required) - The tree items: list of Corex.Tree.Item structs (use Corex.Tree.new/1).compound(:boolean) - Enable compound mode. Use with :let={ctx}, tree_view_root, and tree_view_branch / tree_view_item. Defaults tofalse.redirect(:boolean) - When true, selecting an item triggers redirect-on-select using the item value (or:to) as the destination. Each item picks the navigation kind via its:redirectfield (:href(default) |:patch|:navigate|false); set:new_tabto open in a new tab.Defaults to
false.value(:list) - Selected node value(s). Use with controlled. Defaults to[].expanded_value(:list) - Expanded node value(s). Use with controlled. Defaults to[].controlled(:boolean) - Whether the tree is controlled (value and expanded_value from server). Defaults tofalse.selection_mode(:string) - Selection mode: single or multiple. Defaults to"single". Must be one of"single", or"multiple".typeahead(:boolean) - When true, type characters to move focus among nodes. Defaults totrue.dir(:string) - The direction of the tree. Defaults tonil. Must be one ofnil,"ltr", or"rtl".on_selection_change(:string) - Server event name when selection changes. Payload:%{id, selectedValue, previousSelectedValue, added, removed, focusedValue, isItem}. Defaults tonil.on_selection_change_client(:string) - DOM event name dispatched on selection change.event.detailmatchesTreeViewSelectionChangedDetail. Defaults tonil.on_expanded_change(:string) - Server event name when expanded state changes. Payload:%{id, expandedValue, previousExpandedValue, added, removed, focusedValue}. Defaults tonil.on_expanded_change_client(:string) - DOM event name dispatched on expanded change.event.detailmatchesTreeViewExpandedChangedDetail. Required foranimation="custom". Defaults tonil.animation(:string) - Branch open/close: instant, built-in js, or custom via on_expanded_change_client. Defaults to"js". Must be one of"instant","js", or"custom".animation_options(Corex.Animation.Height) - Wired to the host whenanimationisjsonly. Custom transitions ignore this assign. SeeCorex.Animation.Height(opacity, height,block_interaction). Defaults to%Corex.Animation.Height{duration: 0.3, easing: "ease", opacity_start: 0.0, opacity_end: 1.0, block_interaction: false}.Global attributes are accepted.
Slots
inner_block- Compound mode inner content. Use with thecompoundattribute and:let={ctx}.ctxis a map with keys:id,dir,animation,items,expanded_value,value.label- Optional label slot. Accepts attributes:class(:string)
branch- Optional label for each branch row. Use :let={item} (Corex.Tree.Item). Accepts attributes:class(:string)
branch_indicator- Optional indicator for each branch row. Use :let={item}. Accepts attributes:class(:string)
item- Optional label for each leaf row. Use :let={item}. Accepts attributes:class(:string)
item_indicator- Optional indicator for each leaf row. Use :let={item}. Accepts attributes:class(:string)
Attributes
count(:integer) - Defaults to3.- Global attributes are accepted.
Compounds
Attributes
ctx(:map) (required)item(:any) (required)
Slots
inner_block(required)
Attributes
branch(:map) (required)
Slots
inner_block(required)
Attributes
branch(:map) (required)
Slots
inner_block(required)
Attributes
branch(:map) (required)
Slots
inner_block(required)indicator- Accepts attributes:class(:string)
Attributes
ctx(:map) (required)item(:any) (required)
Slots
inner_blockitem_indicator- Accepts attributes:class(:string)
Attributes
item(:map) (required)
Slots
inner_block(required)
Attributes
ctx(:map) (required)- Global attributes are accepted.
Slots
inner_block(required)label- Accepts attributes:class(:string)
API
Requests the tree's current expanded values from the browser. Returns a Phoenix.LiveView.JS command.
Options:
:respond_to—:server(default, LiveViewtree_view_expanded_value_responseonly),:both(also dispatchestree-view-expanded-value), or:client(DOMtree-view-expanded-valueonly).
Examples
From Client Binding
<.action phx-click={Corex.TreeView.expanded_value("my-tree")} class="button button--sm">
Expanded
</.action>
```javascript
const el = document.getElementById("my-tree");
el?.addEventListener("tree-view-expanded-value", (e) => console.log(e.detail));
```JS.dispatch
<.action
phx-click={JS.dispatch("corex:tree-view:expanded-value",
to: "#my-tree",
detail: %{respond_to: "client"},
bubbles: false
)}
class="button button--sm"
>
Expanded (JS.dispatch, client only)
</.action>
Requests the tree's current expanded values from the client. Pushes a LiveView event handled by the hook.
See expanded_value/2 for :respond_to. The hook pushes tree_view_expanded_value_response and/or dispatches
tree-view-expanded-value accordingly.
Examples
def handle_event("tree_view_expanded_value_response", %{"id" => id, "value" => value}, socket) do
{:noreply, assign(socket, :tree_view_expanded_value, {id, value})}
end
Sets the tree expanded value from client-side.
Sets the tree expanded value from server-side.
Sets the tree selected value from client-side.
Sets the tree selected value from server-side.
Requests the tree's current selected values from the browser. Returns a Phoenix.LiveView.JS command.
Options:
:respond_to—:server(default, LiveViewtree_view_value_responseonly),:both(also dispatchestree-view-value), or:client(DOMtree-view-valueonly). When:serverand the LiveView is not connected, nothing is pushed.
The hook pushes tree_view_value_response when :respond_to is :both or :server, and dispatches
tree-view-value on the element when :respond_to is :both or :client.
Examples
From Client Binding
<.action phx-click={Corex.TreeView.value("my-tree")} class="button button--sm">
Value
</.action>
```javascript
const el = document.getElementById("my-tree");
el?.addEventListener("tree-view-value", (e) => console.log(e.detail));
```JS.dispatch
<.action
phx-click={JS.dispatch("corex:tree-view:value",
to: "#my-tree",
detail: %{respond_to: "client"},
bubbles: false
)}
class="button button--sm"
>
Value (JS.dispatch, client only)
</.action>
Requests the tree's current selected values from the client. Pushes a LiveView event handled by the hook.
See value/2 for :respond_to. The hook pushes tree_view_value_response and/or dispatches tree-view-value
accordingly.
Examples
def handle_event("tree_view_value_response", %{"id" => id, "value" => value}, socket) do
{:noreply, assign(socket, :tree_view_value, {id, value})}
end
Functions
Attributes
id(:string) (required)dir(:string) (required)animation(:string) (required)tree_item(:any) (required)index_path(:list) (required)expanded_value(:list) - Defaults to[].value(:list) - Defaults to[].use_branch_slot(:boolean) - Defaults tofalse.use_branch_indicator_slot(:boolean) - Defaults tofalse.use_item_slot(:boolean) - Defaults tofalse.use_item_indicator_slot(:boolean) - Defaults tofalse.
Slots
branch- Accepts attributes:class(:string)
branch_indicator- Accepts attributes:class(:string)
item- Accepts attributes:class(:string)
item_indicator- Accepts attributes:class(:string)