Corex.TreeView (Corex v0.1.0-rc.1)

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

FunctionActionReturns
set_selected_value/2Set selection (client)%Phoenix.LiveView.JS{}
set_selected_value/3Set selection (server)socket
set_expanded_value/2Set expanded nodes (client)%Phoenix.LiveView.JS{}
set_expanded_value/3Set expanded nodes (server)socket
value/1Read selection (client)%Phoenix.LiveView.JS{}
value/3Read selection (server)socket
expanded_value/1Read expanded (client)%Phoenix.LiveView.JS{}
expanded_value/3Read 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

EventWhenPayload
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

EventWhenevent.detail
on_selection_change_client="tree-selection-changed"Selection changesid, selectedValue, added, removed
on_expanded_change_client="tree-expanded-changed"Expanded changesid, 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

ModifierClasses
Defaulttree-view
Accenttree-view tree-view--accent
Brandtree-view tree-view--brand
Alerttree-view tree-view--alert
Infotree-view tree-view--info
Successtree-view tree-view--success

Size

ModifierClasses
SMtree-view tree-view--sm
MDtree-view tree-view--md
LGtree-view tree-view--lg
XLtree-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

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) - 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 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_markup_branch(assigns)

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)

tree_view_markup_item(assigns)

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)

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_line(assigns)

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 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)

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)

Same as expanded_value/2 with default respond_to:.

expanded_value(tree_view_id, opts)

Read expanded paths from phx-click. Dispatches corex:tree-view:expanded-value.

ReplyPayload
Servertree_view_expanded_value_response%{"id" => id, "value" => expanded_paths}
Clienttree-view-expanded-valuesame 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"}])} />

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

Read expanded paths from handle_event (tree_view_expanded_value). Same replies as expanded_value/2.

ReplyPayload
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_value(tree_view_id, value)

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_value(socket, tree_view_id, value)

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_selected_value(tree_view_id, value)

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_selected_value(socket, tree_view_id, value)

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

value(tree_view_id)

Same as value/2 with default respond_to:.

value(tree_view_id, opts)

Read the selected paths from phx-click. Dispatches corex:tree-view:value. Optional respond_to: :server, :client, or :both.

ReplyPayload
Servertree_view_value_response%{"id" => id, "value" => selection}
Clienttree-view-value on the rootsame 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

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

Read selection from handle_event (tree_view_value). Same replies as value/2.

ReplyPayload
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