Corex.TreeView (Corex v0.1.0-beta.4)

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) and new_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-part attributes 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:

FieldRequiredDescription
:idyesUnique identifier used as the node value
:labelyesLabel shown in the row
:childrennoNested list of items to make a branch
:tonoDestination URL when using redirect
:redirectno:href (default) | :patch | :navigate | false
:new_tabnoOpen destination in a new tab
:disablednoPrevents 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 via window.location (safe everywhere)
  • :patch — LiveView js().patch(url) (caller asserts: same LV mount + matching live route)
  • :navigate — LiveView js().navigate(url) (caller asserts: another LV in the same live_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.

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_changepushEvent/3 to 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 — browser CustomEvent whose type is the string you set; event.detail mirrors the push payload (bubbles).
  • on_expanded_changepushEvent/3 with %{"id" => dom_id, "expandedValue" => [...], "previousExpandedValue" => [...], "added" => [...], "removed" => [...], "focusedValue" => focused_or_nil} (TS: TreeViewExpandedChangedDetail).
  • on_expanded_change_clientCustomEvent with the same detail shape (bubbles). Required for animation="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-valuevalue — list of selected ids
corex:tree-view:set-expanded-valuevalue — list of expanded ids
corex:tree-view:valueoptional respond_to: "server", "client", or "both"
corex:tree-view:expanded-valueoptional 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-valuedetail: { id, value }
  • tree-view-expanded-valuedetail: { 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

tree_branch(assigns)

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)

tree_item(assigns)

Renders a tree item (leaf). For custom server-rendered items or testing.

Attributes

  • item (:map) (required)

Slots

  • inner_block (required)

tree_view(assigns)

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 to false.

  • 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 :redirect field (:href (default) | :patch | :navigate | false); set :new_tab to 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 to false.

  • 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 to true.

  • dir (:string) - The direction of the tree. Defaults to nil. Must be one of nil, "ltr", or "rtl".

  • on_selection_change (:string) - Server event name when selection changes. Payload: %{id, selectedValue, previousSelectedValue, added, removed, focusedValue, isItem}. Defaults to nil.

  • on_selection_change_client (:string) - DOM event name dispatched on selection change. event.detail matches TreeViewSelectionChangedDetail. Defaults to nil.

  • on_expanded_change (:string) - Server event name when expanded state changes. Payload: %{id, expandedValue, previousExpandedValue, added, removed, focusedValue}. Defaults to nil.

  • on_expanded_change_client (:string) - DOM event name dispatched on expanded change. event.detail matches TreeViewExpandedChangedDetail. Required for animation="custom". Defaults to nil.

  • 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 when animation is js only. Custom transitions ignore this assign. See Corex.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 the compound attribute and :let={ctx}. ctx is 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)

tree_view_skeleton(assigns)

Attributes

  • count (:integer) - Defaults to 3.
  • Global attributes are accepted.

Compounds

tree_view_branch(assigns)

Attributes

  • ctx (:map) (required)
  • item (:any) (required)

Slots

  • inner_block (required)

tree_view_branch_content(assigns)

Attributes

  • branch (:map) (required)

Slots

  • inner_block (required)

tree_view_branch_indicator(assigns)

Attributes

  • branch (:map) (required)

Slots

  • inner_block (required)

tree_view_branch_trigger(assigns)

Attributes

  • branch (:map) (required)

Slots

  • inner_block (required)
  • indicator - Accepts attributes:
    • class (:string)

tree_view_item(assigns)

Attributes

  • ctx (:map) (required)
  • item (:any) (required)

Slots

  • inner_block
  • item_indicator - Accepts attributes:
    • class (:string)

tree_view_item_indicator(assigns)

Attributes

  • item (:map) (required)

Slots

  • inner_block (required)

tree_view_root(assigns)

Attributes

  • ctx (:map) (required)
  • Global attributes are accepted.

Slots

  • inner_block (required)
  • label - Accepts attributes:
    • class (:string)

API

expanded_value(tree_view_id)

Requests the tree's current expanded values from the browser. Returns a Phoenix.LiveView.JS command.

Options:

  • :respond_to:server (default, LiveView tree_view_expanded_value_response only), :both (also dispatches tree-view-expanded-value), or :client (DOM tree-view-expanded-value only).

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>

expanded_value(socket, tree_view_id, opts \\ [])

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

set_expanded_value(tree_view_id, value)

Sets the tree expanded value from client-side.

set_expanded_value(socket, tree_view_id, value)

Sets the tree expanded value from server-side.

set_selected_value(tree_view_id, value)

Sets the tree selected value from client-side.

set_selected_value(socket, tree_view_id, value)

Sets the tree selected value from server-side.

value(tree_view_id)

Requests the tree's current selected values from the browser. Returns a Phoenix.LiveView.JS command.

Options:

  • :respond_to:server (default, LiveView tree_view_value_response only), :both (also dispatches tree-view-value), or :client (DOM tree-view-value only). When :server and 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>

value(socket, tree_view_id, opts \\ [])

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

expanded_value(tree_view_id, opts)

tree_view_line(assigns)

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 to false.
  • use_branch_indicator_slot (:boolean) - Defaults to false.
  • use_item_slot (:boolean) - Defaults to false.
  • use_item_indicator_slot (:boolean) - Defaults to false.

Slots

  • branch - Accepts attributes:
    • class (:string)
  • branch_indicator - Accepts attributes:
    • class (:string)
  • item - Accepts attributes:
    • class (:string)
  • item_indicator - Accepts attributes:
    • class (:string)

value(tree_view_id, opts)