Packages
phoenix_kit
2.41.2
2.41.4
2.41.3
2.41.2
2.41.1
2.41.0
2.40.1
2.40.0
2.39.0
2.38.1
2.38.0
2.37.5
2.37.4
2.37.3
2.37.2
2.37.1
2.37.0
2.36.1
2.36.0
2.35.0
2.34.0
2.33.0
2.32.1
2.32.0
2.31.1
2.31.0
2.30.0
2.29.1
2.29.0
2.28.2
2.28.1
2.28.0
2.27.2
2.27.1
2.27.0
2.26.1
2.26.0
2.25.0
2.24.0
2.23.3
2.23.2
2.23.1
2.23.0
2.22.24
2.22.23
2.22.22
2.22.21
2.22.20
2.22.19
2.22.18
2.22.17
2.22.16
2.22.15
2.22.14
2.22.13
2.22.12
2.22.11
2.22.10
2.22.9
2.22.8
2.22.7
2.22.6
2.22.5
2.22.4
2.22.3
2.22.2
2.22.1
2.22.0
2.21.5
2.21.4
2.21.3
2.21.2
2.21.1
2.21.0
2.20.0
2.19.0
2.18.1
2.18.0
2.17.0
2.16.0
2.15.1
2.15.0
2.14.2
2.14.1
2.14.0
2.13.19
2.13.18
2.13.17
2.13.16
2.13.15
2.13.13
2.13.12
2.13.11
2.13.10
2.13.9
2.13.8
2.13.7
2.13.6
2.13.5
2.13.4
2.13.3
2.13.2
2.13.1
2.13.0
2.12.1
2.12.0
2.11.0
2.10.0
2.9.0
2.8.1
2.8.0
2.7.0
2.6.0
2.5.0
2.4.0
2.3.0
2.2.0
2.1.0
2.0.1
2.0.0
1.7.236
1.7.235
1.7.234
1.7.233
1.7.232
1.7.231
1.7.230
1.7.229
1.7.228
1.7.227
1.7.226
1.7.225
1.7.224
1.7.223
1.7.222
1.7.221
1.7.220
1.7.219
1.7.218
1.7.217
1.7.216
1.7.215
1.7.214
1.7.213
1.7.212
1.7.211
1.7.210
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_web/components/tree_picker.ex
defmodule PhoenixKitWeb.Components.TreePicker do
@moduledoc """
A searchable, collapsible tree for picking one record — or several —
out of a nested set (a parent category, a folder, a page), instead of an
indented flat `<select>`. Build the tree with `PhoenixKit.Utils.Tree`.
**Controlled.** The parent owns what is picked: it passes `value` and
handles `{PhoenixKitWeb.Components.TreePicker, id, value}`, sent whenever
the admin picks (a single id, or the new list with `multiple`). The
component owns only the search text and which rows are open. The parent
builds the `tree`, so what to leave out — a record's own subtree, the
row being moved — is its call, and it must check a received id against
live data before acting on it: the tree may be minutes old.
<.live_component
module={PhoenixKitWeb.Components.TreePicker}
id="parent-picker"
tree={[Tree.root(gettext("Top level"), @parent_tree)]}
value={@parent_pick}
field
name="page[parent_uuid]"
/>
def handle_info({TreePicker, "parent-picker", id}, socket),
do: {:noreply, assign(socket, :parent_pick, id)}
## Attributes
* `id` (required)
* `tree` (required) — `PhoenixKit.Utils.Tree` nodes
* `value` — the picked id (`nil` for none), or a list with `multiple`
* `pickable` — the node `:type`s that can be picked, or `:all`
(default); other rows only open and close
* `multiple` — check any number of rows; a row that cannot be picked
then carries a box that checks or clears every pickable row under it
* `current` — the id to badge "Current" (where the record is now)
* `field` — `true` shows the picked row's path and a Change button,
the tree only while changing (for forms); `false` (default) shows the
tree at once (for dialogs). Single pickers only
* `path_skip` — node `:type`s left out of the shown path
* `placeholder` — the path text while nothing is picked
* `search_placeholder` — the search box's hint (default "Search...")
* `disabled` — field mode only: shows the path, offers no Change and
posts nothing, like a disabled `<select>`
* `name` — renders hidden inputs so a surrounding form posts the value.
With `multiple`, give it a list name (`"item[tags][]"`); an empty
pick posts nothing, so read a missing key as `[]`
* `post` — what a hidden input posts for an id: `:id` (default; the
`"root"` row posts `""`, "no parent") or a function of the id
**Inside a form.** The search box has no `name`, and its hook
(`TreePickerSearch`) keeps its `input`/`change` events and Enter from
reaching the surrounding form — typing here must not run the form's
`phx-change` or submit it. Every other control is a `type="button"`.
"""
use PhoenixKitWeb, :live_component
alias PhoenixKit.Utils.Tree
@impl true
def mount(socket) do
{:ok, assign(socket, query: "", open: MapSet.new(), search_open: MapSet.new(), panel?: false)}
end
@impl true
def update(assigns, socket) do
before = Map.take(socket.assigns, [:tree, :value, :current])
socket =
socket
|> assign(assigns)
|> assign_new(:value, fn -> nil end)
|> assign_new(:pickable, fn -> :all end)
|> assign_new(:multiple, fn -> false end)
|> assign_new(:current, fn -> nil end)
|> assign_new(:field, fn -> false end)
|> assign_new(:path_skip, fn -> [] end)
|> assign_new(:placeholder, fn -> "—" end)
|> assign_new(:search_placeholder, fn -> gettext("Search...") end)
|> assign_new(:name, fn -> nil end)
|> assign_new(:post, fn -> :id end)
|> assign_new(:disabled, fn -> false end)
{:ok, socket |> reopen(before) |> assign_under() |> refilter()}
end
# The rows that show what is picked open on the first render, and again
# whenever the parent hands in another value, current row or a refreshed
# tree — a row picked elsewhere, or a tree that arrived late, must not sit
# under a closed parent. The echo of this picker's own pick is not such a
# change (picking a whole branch must not pop it open), and rows the admin
# closed stay closed otherwise.
defp reopen(socket, before) do
now = Map.take(socket.assigns, [:tree, :value, :current])
sent = socket.assigns[:sent]
# The echo arrives once: forget the pick then, so the same value handed
# in again later (after another one) counts as handed in.
socket =
if before != %{} and before.value != now.value and now.value == sent,
do: assign(socket, :sent, :none),
else: socket
cond do
before == %{} ->
assign(socket, :open, opened_at(socket.assigns))
handed_in?(before, now, sent) ->
update(socket, :open, &MapSet.union(&1, opened_at(socket.assigns)))
true ->
socket
end
end
defp handed_in?(before, now, sent) do
before.tree != now.tree or before.current != now.current or
(before.value != now.value and now.value != sent)
end
# Multiple mode: every branch's pickable rows, worked out once per tree
# rather than walked again for each row on every render.
defp assign_under(%{assigns: %{multiple: true}} = socket),
do: assign(socket, :under, Tree.pickable_under(socket.assigns.tree, socket.assigns.pickable))
defp assign_under(socket), do: assign(socket, :under, %{})
# The rows above what is picked (or where the record is now) start open,
# so the admin sees it in place; so does the current row itself, whose
# children are the likeliest destination, and a root row, which only
# holds the rest of the tree.
defp opened_at(assigns) do
ids = List.wrap(assigns.value) ++ List.wrap(assigns.current)
roots = for %{id: id} = node <- assigns.tree, Map.get(node, :type) == :root, do: id
MapSet.new(
roots ++
List.wrap(assigns.current) ++
Enum.flat_map(ids, &Tree.ancestor_ids(assigns.tree, &1))
)
end
defp refilter(socket) do
{shown, open} = Tree.filter(socket.assigns.tree, socket.assigns.query)
assign(socket, shown: shown, search_open: MapSet.new(open))
end
@impl true
def handle_event("search", %{"value" => value}, socket) when is_binary(value) do
{:noreply, socket |> assign(:query, value) |> refilter()}
end
def handle_event("toggle", %{"id" => id}, socket) when is_binary(id) do
key = if searching?(socket.assigns), do: :search_open, else: :open
set = Map.fetch!(socket.assigns, key)
set = if MapSet.member?(set, id), do: MapSet.delete(set, id), else: MapSet.put(set, id)
{:noreply, assign(socket, key, set)}
end
# A disabled picker shows a path and posts nothing; a crafted event must
# not move it either.
def handle_event(event, _params, %{assigns: %{disabled: true}} = socket)
when event in ~w(pick pick_all),
do: {:noreply, socket}
# Only a row the tree offers, of a pickable type, is taken.
def handle_event("pick", %{"id" => id}, socket) when is_binary(id) do
%{tree: tree, pickable: pickable} = socket.assigns
if Tree.member?(tree, id, pickable) do
value =
if socket.assigns.multiple, do: toggle(List.wrap(socket.assigns.value), id), else: id
send(self(), {__MODULE__, socket.assigns.id, value})
{:noreply, assign(socket, panel?: false, sent: value)}
else
{:noreply, socket}
end
end
# A branch's box: every pickable row under it, checked when any is not,
# else cleared. The whole branch counts — rows a search hides too.
def handle_event("pick_all", %{"id" => id}, %{assigns: %{multiple: true}} = socket)
when is_binary(id) do
%{tree: tree, pickable: pickable} = socket.assigns
case Tree.find(tree, id) do
%{children: children} ->
under = Tree.ids_of(children, pickable)
value = List.wrap(socket.assigns.value)
picked = MapSet.new(value)
value =
if Enum.all?(under, &MapSet.member?(picked, &1)) do
drop = MapSet.new(under)
Enum.reject(value, &MapSet.member?(drop, &1))
else
value ++ Enum.reject(under, &MapSet.member?(picked, &1))
end
send(self(), {__MODULE__, socket.assigns.id, value})
{:noreply, assign(socket, :sent, value)}
nil ->
{:noreply, socket}
end
end
def handle_event("open_panel", _params, %{assigns: %{disabled: true}} = socket),
do: {:noreply, socket}
def handle_event("open_panel", _params, socket), do: {:noreply, assign(socket, :panel?, true)}
def handle_event("close_panel", _params, socket), do: {:noreply, assign(socket, :panel?, false)}
def handle_event(_event, _params, socket), do: {:noreply, socket}
defp toggle(value, id), do: if(id in value, do: List.delete(value, id), else: value ++ [id])
defp searching?(assigns), do: String.trim(assigns.query) != ""
@impl true
def render(assigns) do
assigns =
assign(assigns,
shown_open: if(searching?(assigns), do: assigns.search_open, else: assigns.open),
tree_visible?: not assigns.field or (assigns.panel? and not assigns.disabled),
path: path(assigns),
picked: MapSet.new(List.wrap(assigns.value))
)
~H"""
<div id={@id} class="flex flex-col gap-2 min-w-0" data-tree-picker>
<input
:for={value <- posted(@value, @post)}
:if={@name && not @disabled}
type="hidden"
name={@name}
value={value}
/>
<div :if={@field} class="flex flex-wrap items-center gap-2">
<.path_line id={"#{@id}-path"} names={@path} placeholder={@placeholder} />
<button
:if={not @panel? and not @disabled}
type="button"
id={"#{@id}-change"}
phx-click="open_panel"
phx-target={@myself}
class="btn btn-outline btn-sm ml-auto"
>
<.icon name="hero-folder-open" class="w-4 h-4" />
{gettext("Change")}
</button>
<button
:if={@panel?}
type="button"
phx-click="close_panel"
phx-target={@myself}
class="btn btn-ghost btn-sm ml-auto"
>
{gettext("Cancel")}
</button>
</div>
<div
:if={@tree_visible?}
class={["flex flex-col gap-2", @field && "rounded-box border border-base-300 p-2"]}
>
<label class="input input-sm w-full">
<.icon name="hero-magnifying-glass" class="w-4 h-4 opacity-50" />
<input
id={"#{@id}-search"}
type="text"
value={@query}
phx-hook="TreePickerSearch"
phx-target={@myself}
placeholder={@search_placeholder}
aria-label={@search_placeholder}
autocomplete="off"
class="grow"
/>
</label>
<div class="max-h-72 overflow-y-auto">
<p :if={@shown == []} class="px-2 py-3 text-sm text-base-content/50">
{gettext("No matches.")}
</p>
<ul :if={@shown != []} id={"#{@id}-tree"} role="tree" class="text-sm">
<.tree_row
:for={node <- @shown}
node={node}
open={@shown_open}
picked={@picked}
current={@current}
pickable={@pickable}
multiple={@multiple}
under={@under}
myself={@myself}
/>
</ul>
</div>
</div>
</div>
"""
end
defp path(%{value: value} = assigns) when is_binary(value),
do: Tree.path(assigns.tree, value, assigns.path_skip)
defp path(_assigns), do: []
# A single picker always posts its field (blank while nothing is picked);
# a multiple one posts one per picked row.
defp posted(value, post) when is_list(value), do: Enum.map(value, &post_value(&1, post))
defp posted(nil, _post), do: [""]
defp posted(value, post), do: [post_value(value, post)]
defp post_value(id, :id), do: if(id == Tree.root_id(), do: "", else: id)
defp post_value(id, fun) when is_function(fun, 1), do: fun.(id) || ""
attr :id, :string, required: true
attr :names, :list, required: true
attr :placeholder, :string, required: true
defp path_line(assigns) do
~H"""
<nav id={@id} class="min-w-0">
<span :if={@names == []} class="text-sm text-base-content/50">{@placeholder}</span>
<ol :if={@names != []} class="flex flex-wrap items-center gap-1 text-sm">
<li :for={{name, index} <- Enum.with_index(@names)} class="flex items-center gap-1">
<.icon :if={index > 0} name="hero-chevron-right-mini" class="w-4 h-4 text-base-content/30" />
<span class={
if index == length(@names) - 1, do: "font-medium", else: "text-base-content/70"
}>
{name}
</span>
</li>
</ol>
</nav>
"""
end
attr :node, :map, required: true
attr :open, :any, required: true
attr :picked, :any, required: true
attr :current, :string, default: nil
attr :pickable, :any, required: true
attr :multiple, :boolean, required: true
attr :under, :map, required: true
attr :myself, :any, required: true
defp tree_row(assigns) do
node = assigns.node
assigns =
assign(assigns,
expanded?: MapSet.member?(assigns.open, node.id),
branch?: node.children != [],
pickable?: Tree.of_type?(node, assigns.pickable),
selected?: MapSet.member?(assigns.picked, node.id),
branch_check: branch_check(assigns)
)
~H"""
<li
role="treeitem"
aria-expanded={@branch? && to_string(@expanded?)}
aria-selected={to_string(@selected?)}
>
<div class={[
"flex items-center gap-1 rounded-field pr-2 hover:bg-base-200",
(@selected? and not @multiple) && "bg-primary/10"
]}>
<button
:if={@branch?}
type="button"
phx-click="toggle"
phx-value-id={@node.id}
phx-target={@myself}
class="btn btn-ghost btn-xs btn-square shrink-0"
aria-label={if @expanded?, do: gettext("Collapse"), else: gettext("Expand")}
>
<.icon
name={if @expanded?, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
class="w-4 h-4"
/>
</button>
<span :if={not @branch?} class="w-6 shrink-0"></span>
<button
:if={@multiple and not @pickable? and @branch_check != nil}
type="button"
phx-click="pick_all"
phx-value-id={@node.id}
phx-target={@myself}
data-pick-all={@node.id}
class="shrink-0"
aria-label={gettext("Select all")}
>
<.check_box state={@branch_check} />
</button>
<button
type="button"
phx-click={if @pickable?, do: "pick", else: "toggle"}
phx-value-id={@node.id}
phx-target={@myself}
data-tree-node={@node.id}
class={[
"flex flex-1 items-center gap-2 py-1.5 text-left min-w-0",
not @pickable? && "text-base-content/70"
]}
>
<.check_box :if={@multiple and @pickable?} state={if @selected?, do: :all, else: :none} />
<.icon :if={@node[:icon]} name={@node.icon} class="w-4 h-4 shrink-0 text-base-content/50" />
<span class="truncate">{@node.name}</span>
<span :if={@node[:hint]} class="text-xs text-base-content/50 shrink-0">{@node.hint}</span>
<span :if={@node[:badge]} class="badge badge-xs badge-ghost">{@node.badge}</span>
<span :if={@node.id == @current} class="badge badge-xs badge-outline ml-auto shrink-0">
{gettext("Current")}
</span>
</button>
</div>
<ul :if={@branch? and @expanded?} role="group" class="ml-3 pl-2 border-l border-base-content/10">
<.tree_row
:for={child <- @node.children}
node={child}
open={@open}
picked={@picked}
current={@current}
pickable={@pickable}
multiple={@multiple}
under={@under}
myself={@myself}
/>
</ul>
</li>
"""
end
# A branch's box in multiple mode: :all / :some / :none of the pickable
# rows under it (in the whole tree, not only what a search shows), or
# nil when there are none.
defp branch_check(%{multiple: true, node: node, under: under, picked: picked}) do
case Map.get(under, node.id, []) do
[] ->
nil
ids ->
case Enum.count(ids, &MapSet.member?(picked, &1)) do
0 -> :none
count when count == length(ids) -> :all
_ -> :some
end
end
end
defp branch_check(_assigns), do: nil
attr :state, :atom, required: true
defp check_box(assigns) do
~H"""
<span
data-check={@state}
aria-hidden="true"
class={[
"inline-flex w-4 h-4 shrink-0 items-center justify-center rounded-sm border",
if(@state == :none,
do: "border-base-content/30",
else: "border-primary bg-primary text-primary-content"
)
]}
>
<.icon :if={@state == :all} name="hero-check-mini" class="w-3.5 h-3.5" />
<.icon :if={@state == :some} name="hero-minus-mini" class="w-3.5 h-3.5" />
</span>
"""
end
end