Corex. TreeView
(Corex v0.1.0)
View Source
Phoenix implementation of the Zag.js Tree View.
Anatomy
Render from a list with Corex.Tree.new/1.
Basic
Each item supports :value, :label, optional :children, :to, :redirect, :new_tab, :disabled.
<.tree_view
class="tree-view"
items={
Corex.Tree.new([
%{label: "Components", value: "components", children: [
%{label: "Accordion", value: "accordion"},
%{label: "Checkbox", value: "checkbox"},
%{label: "Tree view", value: "tree-view"}
]},
%{label: "Form", value: "form"},
%{label: "Tree", value: "tree", children: [%{label: "Tree.Item", value: "tree-item"}]}
])
}
/>With Label
<.tree_view
class="tree-view"
items={
Corex.Tree.new([
%{label: "Guides", value: "guides"},
%{label: "Reference", value: "reference"}
])
}
>
<:label>My Documents</:label>
</.tree_view>With Indicator
<.tree_view
class="tree-view"
items={
Corex.Tree.new([
%{label: "src", value: "src", children: [
%{label: "components", value: "components"},
%{label: "index.ts", value: "index.ts"}
]},
%{label: "README.md", value: "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
<.tree_view
class="tree-view"
items={
Corex.Tree.new([
%{label: "src", value: "src", children: [
%{label: "components", value: "components", children: [%{label: "tree-view.tsx", value: "tree-view.tsx"}]},
%{label: "main.ts", value: "main.ts"}
]},
%{label: "README.md", value: "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 tree_view_root, tree_view_branch, and tree_view_item. Branches and items resolve their path from ctx.items.
<.tree_view
:let={ctx}
compound
class="tree-view"
items={
Corex.Tree.new([
%{label: "Components", value: "components", children: [
%{label: "Accordion", value: "accordion"},
%{label: "Checkbox", value: "checkbox"}
]},
%{label: "Form", value: "form"}
])
}
>
<.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 item values matching the tree.
<.tree_view
class="tree-view"
expanded_value={["src", "components"]}
value={["tree-view.tsx"]}
items={
Corex.Tree.new([
%{label: "src", value: "src", children: [
%{label: "components", value: "components", children: [%{label: "tree-view.tsx", value: "tree-view.tsx"}]},
%{label: "main.ts", value: "main.ts"}
]}
])
}
/>Async (assign_async)
def mount(_params, _session, socket) do
socket =
assign_async(socket, :tree, fn ->
{:ok, %{tree: Corex.Tree.new([%{label: "Docs", value: "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>
"""
endNavigation (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 class="tree-view" redirect items={
Corex.Tree.new([
%{label: "Home", value: "home", to: "/", redirect: :patch},
%{label: "External", value: "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={
Corex.Tree.new([
%{label: "Components", value: "components", children: [%{label: "Accordion", value: "accordion"}]},
%{label: "Form", value: "form"}
])
}
/>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={
Corex.Tree.new([
%{label: "Components", value: "components", children: [%{label: "Accordion", value: "accordion"}]},
%{label: "Form", value: "form"}
])
}
/>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={
Corex.Tree.new([
%{label: "Components", value: "components", children: [%{label: "Accordion", value: "accordion"}]},
%{label: "Form", value: "form"}
])
}
/>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
Requires a stable id on <.tree_view>.
| Function | Action | Returns |
|---|---|---|
set_selected_value/2 | Set selection (client) | %Phoenix.LiveView.JS{} |
set_selected_value/3 | Set selection (server) | socket |
set_expanded_value/2 | Set expanded nodes (client) | %Phoenix.LiveView.JS{} |
set_expanded_value/3 | Set expanded nodes (server) | socket |
value/1 | Read selection (client) | %Phoenix.LiveView.JS{} |
value/3 | Read selection (server) | socket |
expanded_value/1 | Read expanded (client) | %Phoenix.LiveView.JS{} |
expanded_value/3 | Read expanded (server) | socket |
For value and expanded_value, use respond_to: :server | :client | :both.
Events
Pick an event name and pass it to on_* on <.tree_view>.
Server events
| Event | When | Payload |
|---|---|---|
on_selection_change="tree_selected" | Selection changes | %{"id" => id, "selectedValue" => values, ...} |
on_expanded_change="tree_expanded" | Expanded nodes change | %{"id" => id, "expandedValue" => values, ...} |
Client events
| Event | When | event.detail |
|---|---|---|
on_selection_change_client="tree-selection-changed" | Selection changes | id, selectedValue, added, removed |
on_expanded_change_client="tree-expanded-changed" | Expanded changes | id, expandedValue, added, removed |
Style
Target parts with data-scope and data-part, or use Corex Design: import tokens and tree-view.css, then set class="tree-view" on <.tree_view>.
[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-content"] {}
[data-scope="tree-view"][data-part="item"] {}@import "../corex/main.css";
@import "../corex/tokens/themes/neo/light.css";
@import "../corex/components/tree-view.css";Stack modifiers on the host (class on <.tree_view>).
Color
| Modifier | Classes |
|---|---|
| Default | tree-view |
| Accent | tree-view tree-view--accent |
| Brand | tree-view tree-view--brand |
| Alert | tree-view tree-view--alert |
| Info | tree-view tree-view--info |
| Success | tree-view tree-view--success |
Size
| Modifier | Classes |
|---|---|
| SM | tree-view tree-view--sm |
| MD | tree-view tree-view--md |
| LG | tree-view tree-view--lg |
| XL | tree-view tree-view--xl |
Summary
Components
Renders a tree view. Pass items as Corex.Tree.new/1. Component id = tree root id; names capitalized from labels.
Renders a tree branch row for custom server markup or tests. Prefer compound tree_view_branch when possible.
Renders a tree leaf row for custom server markup or tests. Prefer compound tree_view_item when possible.
API
Same as expanded_value/2 with default respond_to:.
Read expanded paths from phx-click. Dispatches corex:tree-view:expanded-value.
Read expanded paths from handle_event (tree_view_expanded_value). Same replies as expanded_value/2.
Set expanded branches from phx-click. Dispatches corex:tree-view:set-expanded-value; value must be an expanded-path list accepted by Zag.
Set expanded branches from handle_event (tree_view_set_expanded_value). Payload uses tree_view_id matching the DOM id.
Set the selection from phx-click. Dispatches corex:tree-view:set-selected-value with detail.value.
Set the selection from handle_event (tree_view_set_selected_value).
Same as value/2 with default respond_to:.
Read the selected paths from phx-click. Dispatches corex:tree-view:value. Optional respond_to: :server, :client, or :both.
Read selection from handle_event (tree_view_value). Same replies as value/2.
Components
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) - Initial selected node value(s). Defaults to[].expanded_value(:list) - Initial expanded node value(s). Defaults to[].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)
Renders a tree branch row for custom server markup or tests. Prefer compound tree_view_branch when possible.
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 leaf row for custom server markup or tests. Prefer compound tree_view_item when possible.
Attributes
item(:map) (required)
Slots
inner_block(required)
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
id(:string)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)
Attributes
ctx(:map) (required)- Global attributes are accepted.
Slots
inner_block(required)label- Accepts attributes:class(:string)
API
Same as expanded_value/2 with default respond_to:.
Read expanded paths from phx-click. Dispatches corex:tree-view:expanded-value.
| Reply | Payload | |
|---|---|---|
| Server | tree_view_expanded_value_response | %{"id" => id, "value" => expanded_paths} |
| Client | tree-view-expanded-value | same fields in detail |
<.action phx-click={Corex.TreeView.expanded_value("my-tree")}>Expanded</.action>
<.tree_view id="my-tree" class="tree-view" items={Corex.Tree.new([%{label: "A", value: "a"}])} />
Read expanded paths from handle_event (tree_view_expanded_value). Same replies as expanded_value/2.
| Reply | Payload |
|---|---|
tree_view_expanded_value_response | %{"id" => id, "value" => expanded_paths} |
def handle_event("read_expanded", _, socket) do
{:noreply, Corex.TreeView.expanded_value(socket, "my-tree", respond_to: :server)}
end
Set expanded branches from phx-click. Dispatches corex:tree-view:set-expanded-value; value must be an expanded-path list accepted by Zag.
<.action phx-click={Corex.TreeView.set_expanded_value("my-tree", ["src"])}>Open src</.action>
<.tree_view id="my-tree" class="tree-view" items={Corex.Tree.new([%{label: "src", value: "src", children: [%{label: "a.ts", value: "a"}]}])} />document.getElementById("my-tree")?.dispatchEvent(
new CustomEvent("corex:tree-view:set-expanded-value", {
bubbles: false,
detail: { value: ["src"] },
})
);
Set expanded branches from handle_event (tree_view_set_expanded_value). Payload uses tree_view_id matching the DOM id.
def handle_event("expand", _, socket) do
{:noreply, Corex.TreeView.set_expanded_value(socket, "my-tree", ["src"])}
end
Set the selection from phx-click. Dispatches corex:tree-view:set-selected-value with detail.value.
<.action phx-click={Corex.TreeView.set_selected_value("my-tree", ["readme"])}>Select readme</.action>
<.tree_view id="my-tree" class="tree-view" items={Corex.Tree.new([%{label: "README.md", value: "readme"}])} />
Set the selection from handle_event (tree_view_set_selected_value).
def handle_event("select_item", _, socket) do
{:noreply, Corex.TreeView.set_selected_value(socket, "my-tree", ["a"])}
end
Same as value/2 with default respond_to:.
Read the selected paths from phx-click. Dispatches corex:tree-view:value. Optional respond_to: :server, :client, or :both.
| Reply | Payload | |
|---|---|---|
| Server | tree_view_value_response | %{"id" => id, "value" => selection} |
| Client | tree-view-value on the root | same fields in detail |
<.action phx-click={Corex.TreeView.value("my-tree")}>Read selection</.action>
<.tree_view id="my-tree" class="tree-view" items={Corex.Tree.new([%{label: "Guide", value: "g"}])} />def handle_event("tree_view_value_response", %{"id" => _, "value" => v}, socket) do
{:noreply, assign(socket, :sel, v)}
end
Read selection from handle_event (tree_view_value). Same replies as value/2.
| Reply | Payload |
|---|---|
tree_view_value_response | %{"id" => id, "value" => selection} |
def handle_event("read_tree", _, socket) do
{:noreply, Corex.TreeView.value(socket, "my-tree", respond_to: :server)}
end