defmodule Corex.TreeView do @moduledoc ~S''' Phoenix implementation of the [Zag.js Tree View](https://zagjs.com/components/react/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: | 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 ```heex <.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 ```heex <.tree_view id="docs-tree" class="tree-view" items={ Corex.Tree.new([ %{label: "Guides", id: "guides"}, %{label: "Reference", id: "reference"} ]) } > <:label>My Documents ``` ### With Indicator ```heex <.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" /> <:item_indicator> <.heroicon name="hero-check" /> ``` ## 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`. ```heex <.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 <:branch :let={item}> <.heroicon name="hero-folder" /> {item.label} <:item :let={item}> <.heroicon name="hero-document" /> {item.label} ``` ## 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. ```heex <.tree_view :let={ctx} compound id="compound-tree" class="tree-view" items={@items}> <.tree_view_root ctx={ctx}> <:label>Project <.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" /> <.tree_view_branch_content branch={branch}> <.tree_view_item :for={child <- item.children || []} ctx={ctx} item={child}> {child.label} ``` `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. ```heex <.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) ```elixir 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`) ```elixir 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" /> <:failed>Could not load the tree. <.tree_view id="async-tree" class="tree-view" items={tree} /> """ 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`. ```heex <.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. ```heex <.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`. ```heex <.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. ```heex <.tree_view class="tree-view" animation="custom" on_expanded_change_client="my-tree-expanded" items={@items} /> ``` ```javascript 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/2` and `set_selected_value/3` - `set_expanded_value/2` and `set_expanded_value/3` - `value/1`, `value/2`, and `value/3` - `expanded_value/1`, `expanded_value/2`, and `expanded_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. ```heex <.action phx-click={Corex.TreeView.set_selected_value("my-tree", ["lorem"])}>Select Lorem <.action phx-click={Corex.TreeView.set_expanded_value("my-tree", ["lorem"])}>Expand Lorem <.action phx-click={Corex.TreeView.value("my-tree")}>Value <.action phx-click={Corex.TreeView.expanded_value("my-tree")}>Expanded ``` ## 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/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_change`** — `pushEvent/3` with `%{"id" => dom_id, "expandedValue" => [...], "previousExpandedValue" => [...], "added" => [...], "removed" => [...], "focusedValue" => focused_or_nil}` (TS: `TreeViewExpandedChangedDetail`). - **`on_expanded_change_client`** — `CustomEvent` 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 `CustomEvent`s 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: ```css [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: ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/tree-view.css"; ``` ```heex <.tree_view class="tree-view tree-view--accent tree-view--lg tree-view--rounded-md tree-view--text-md" items={@items}> <:label>Project ``` ''' @doc type: :component use Phoenix.Component alias Corex.TreeView.Anatomy.{Branch, Item, Label, Props, Root} alias Corex.TreeView.Connect alias Phoenix.LiveView alias Phoenix.LiveView.JS import Corex.Helpers, only: [validate_value!: 1, respond_to_fields: 1] @doc """ Renders a tree view. Pass `items` as `Corex.Tree.new/1`. Component id = tree root id; names capitalized from labels. """ attr(:id, :string, required: false, doc: "The id of the tree view, useful for API to identify the component" ) attr(:items, :list, required: true, doc: "The tree items: list of Corex.Tree.Item structs (use Corex.Tree.new/1)" ) attr(:compound, :boolean, default: false, doc: "Enable compound mode. Use with :let={ctx}, tree_view_root, and tree_view_branch / tree_view_item." ) attr(:redirect, :boolean, default: false, doc: """ 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. """ ) attr(:value, :list, default: [], doc: "Selected node value(s). Use with controlled." ) attr(:expanded_value, :list, default: [], doc: "Expanded node value(s). Use with controlled." ) attr(:controlled, :boolean, default: false, doc: "Whether the tree is controlled (value and expanded_value from server)." ) attr(:selection_mode, :string, default: "single", values: ["single", "multiple"], doc: "Selection mode: single or multiple" ) attr(:typeahead, :boolean, default: true, doc: "When true, type characters to move focus among nodes" ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "The direction of the tree." ) attr(:on_selection_change, :string, default: nil, doc: "Server event name when selection changes. Payload: `%{id, selectedValue, previousSelectedValue, added, removed, focusedValue, isItem}`." ) attr(:on_selection_change_client, :string, default: nil, doc: "DOM event name dispatched on selection change. `event.detail` matches `TreeViewSelectionChangedDetail`." ) attr(:on_expanded_change, :string, default: nil, doc: "Server event name when expanded state changes. Payload: `%{id, expandedValue, previousExpandedValue, added, removed, focusedValue}`." ) attr(:on_expanded_change_client, :string, default: nil, doc: "DOM event name dispatched on expanded change. `event.detail` matches `TreeViewExpandedChangedDetail`. Required for `animation=\"custom\"`." ) attr(:animation, :string, default: "js", values: ["instant", "js", "custom"], doc: "Branch open/close: instant, built-in js, or custom via on_expanded_change_client" ) attr(:animation_options, Corex.Animation.Height, default: %Corex.Animation.Height{}, doc: "Wired to the host when `animation` is `js` only. Custom transitions ignore this assign. See `Corex.Animation.Height` (opacity, height, `block_interaction`)." ) attr(:rest, :global) slot(:inner_block, required: false, doc: """ 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`. """ ) slot :label, doc: "Optional label slot" do attr(:class, :string, required: false) end slot :branch, doc: "Optional label for each branch row. Use :let={item} (Corex.Tree.Item)." do attr(:class, :string, required: false) end slot :branch_indicator, doc: "Optional indicator for each branch row. Use :let={item}." do attr(:class, :string, required: false) end slot :item, doc: "Optional label for each leaf row. Use :let={item}." do attr(:class, :string, required: false) end slot :item_indicator, doc: "Optional indicator for each leaf row. Use :let={item}." do attr(:class, :string, required: false) end def tree_view(assigns) do assigns = assigns |> assign_new(:id, fn -> "tree-view-#{System.unique_integer([:positive])}" end) |> validate_items() |> put_tree() index_paths = index_paths_for_items(assigns.items) ctx = %{ id: assigns.id, dir: assigns.dir, animation: assigns.animation, items: assigns.items, index_paths: index_paths, expanded_value: assigns.expanded_value, value: assigns.value } assigns = assign(assigns, :ctx, ctx) ~H"""