Packages

phoenix_kit

2.13.16
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
phoenix_kit lib phoenix_kit_web components folder_explorer.ex
Raw

lib/phoenix_kit_web/components/folder_explorer.ex

defmodule PhoenixKitWeb.Components.FolderExplorer do
@moduledoc """
Reusable folder explorer sidebar — folder tree, navigation buttons,
inline rename, and (optional) Trash / All Files / New Folder controls.
Extracted from `PhoenixKitWeb.Components.MediaBrowser` so other LiveViews
can embed folder navigation (folder pickers, category browsers, etc.)
without duplicating the markup.
## Ownership model
Pure presentation function component. The consumer owns all state and
event handlers; FolderExplorer just renders. Every interactive control
fires `phx-target={@myself}` back to the consumer, so the consumer must
implement the relevant `handle_event/3` clauses:
navigate_folder, navigate_root, navigate_view_all,
toggle_folder_expand, toggle_sidebar, open_new_folder_modal,
start_rename_folder, rename_folder_input, rename_folder,
cancel_rename_folder, toggle_trash_filter
**From a plain LiveView**, pass `myself={nil}`: HEEx omits a nil attribute,
so no `phx-target` is rendered and the events arrive at the LiveView. The
event names are not configurable — implement this vocabulary rather than
fork the component.
The drag-drop data attributes (`data-drop-folder`, `data-draggable-folder`,
`data-draggable-file`, `data-drop-trash`) are emitted unless
`enable_drag={false}`; consumers that wire up the `MediaDragDrop` JS hook get
drag-drop for free, others can ignore them.
## Showing what is in the folders
By default this is a tree of folders and nothing else, which is what a media
browser wants — its files live in a grid beside the tree. A consumer whose
leaves *are* the point (a vault of notes, a file manager) passes `items` and
an `:item` slot, and the leaves render inside their folders the way Obsidian
and Finder show them:
<.folder_explorer
folder_tree={@folder_tree}
items={%{"root" => @root_notes, folder_uuid => @notes_in_folder}}
>
<:item :let={note}>
<.link patch={~p"/notes/\#{note.id}"}>{note.label}</.link>
</:item>
</.folder_explorer>
Items are keyed by folder uuid, with `"root"` for the top level, and each
needs an `:id` — that is what `data-draggable-file` carries, so a leaf is
draggable on the same terms as a folder. A folder holding only leaves still
gets a chevron.
## Right-click menus
Every folder row and leaf row carries the attributes `Core.ContextMenu`
reads, so a consumer gets right-click / touch-and-hold menus by declaring
the menus — the explorer needs no flag and no slot:
<.context_menu
id="folder-menu"
selector="[data-context-kind=folder]"
value_name={["folder-uuid", "folder_uuid"]}
>
<.context_menu_button phx-click="start_rename_folder" phx-value-source="sidebar" … />
</.context_menu>
`data-context-kind` is `"folder"` or `"item"`, `data-context-value` is the
uuid, `data-context-label` the name. They sit on the folder row `<div>` and
the leaf `<li>` — never on the wrapping `<li>` of a folder — so a right-click
on a nested row resolves to that row and not to its ancestor folder.
Consumers that declare no menu pay three attributes per row and get the
browser's own menu, unchanged.
## A consumer's folder is its own
`folder.color` is read through `folder_color/1` (`Map.get/2`), because a
consumer's folder schema is not `PhoenixKit.Media.Folder` and need not have
the concept. Reading the field directly raised `KeyError` and took the page
down the moment such a consumer had one folder.
## Usage
<.folder_explorer
id="my-folder-explorer"
myself={@myself}
folder_tree={@folder_tree}
current_folder={@current_folder}
expanded_folders={@expanded_folders}
scope_folder_id={@scope_folder_id}
scope_folder_name={@scope_folder_name}
renaming_folder={@renaming_folder}
renaming_source={@renaming_source}
renaming_text={@renaming_text}
filter_trash={@filter_trash}
file_view={@file_view}
sidebar_collapsed={@sidebar_collapsed}
trash_count={@trash_count}
/>
## Config flags
- `show_create` (default `true`) — show the `+` toolbar button.
- `show_all_files` (default `true`) — show the "All Files" flat-view button
(only renders when `scope_folder_id` is `nil`; the flag gates that branch).
- `show_trash` (default `true`) — show the Trash button + badge.
Folder-color helpers (`folder_color_hex/1`, `folder_icon_style/2`,
`folder_bg_style/1`) live here too since the sidebar and the grid/list
folder cards in MediaBrowser both consume them.
"""
use PhoenixKitWeb, :html
# ──────────────────────────────────────────────────────────────
# Top-level component
# ──────────────────────────────────────────────────────────────
attr :id, :string, default: "folder-explorer"
attr :myself, :any,
default: nil,
doc:
"The consuming LiveComponent's `@myself`. Pass `nil` from a plain " <>
"LiveView: HEEx omits a nil attribute, so `phx-target` is not " <>
"rendered and the events arrive at the LiveView itself."
attr :folder_tree, :any, required: true
attr :current_folder, :any, default: nil
attr :expanded_folders, :any, required: true
attr :scope_folder_id, :any, default: nil
attr :scope_folder_name, :string, default: "Root"
attr :renaming_folder, :any, default: nil
attr :renaming_source, :any, default: nil
attr :renaming_text, :string, default: ""
attr :filter_trash, :boolean, default: false
attr :file_view, :string, default: nil
attr :sidebar_collapsed, :boolean, default: false
attr :trash_count, :integer, default: 0
attr :show_create, :boolean, default: true
attr :show_all_files, :boolean, default: true
attr :show_trash, :boolean, default: true
attr :show_rename, :boolean,
default: true,
doc: "Show the inline rename affordance on each folder."
attr :items, :any,
default: %{},
doc: """
Leaf rows to show *inside* folders, as `%{folder_uuid => [item]}`. Items at
the top level go under the key `"root"`.
A folder tree that cannot show what is in the folders is half a tree: a
vault of markdown files, or any consumer whose leaves are the point, needs
the files interleaved with the folders the way a file manager does it.
Empty by default, which is the folders-only tree MediaBrowser renders.
Each item must be a map with an `:id`. That is what
`data-draggable-file` carries, so items are draggable on the same terms as
folders with no work from the consumer.
"""
attr :enable_drag, :boolean,
default: true,
doc:
"Emit the drag-drop data attributes. Off for a consumer with no move " <>
"handlers, so folders are not draggable into a void."
attr :class, :any,
default: "hidden lg:block",
doc:
"Visibility/extra classes for the wrapper. The default reproduces the " <>
"MediaBrowser behavior (desktop-only sidebar); consumers embedding the " <>
"explorer elsewhere can lower the breakpoint (e.g. \"hidden md:block\")."
slot :item,
doc: """
How one leaf row renders, given the item. Without it a row shows
`item.label` (or `item.name`) as plain text — enough to see the tree, not
enough to click it, so a real consumer passes this.
"""
def folder_explorer(assigns) do
# UUIDs on the path from a root folder down to (and including) the
# current folder. Each node's guide-line connector is darkened when its
# uuid is in this set, so the user can trace the branch they're inside.
assigns =
assign(
assigns,
:active_path,
active_path_uuids(assigns.folder_tree, assigns.current_folder)
)
~H"""
<div
id={@id}
class={[@class, "shrink-0 h-full min-h-0"]}
style={if !@sidebar_collapsed, do: "width: 240px; max-width: 240px;"}
>
<%= if @sidebar_collapsed do %>
<%!-- Collapsed strip --%>
<div class="w-10">
<button
phx-click="toggle_sidebar"
phx-target={@myself}
class="btn btn-ghost btn-sm w-full"
title={gettext("Show folders")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
<% else %>
<%!-- Expanded sidebar --%>
<div
class="h-full min-h-0 flex flex-col border-r border-base-200 pr-3 mr-3 overflow-hidden"
style="width: 240px; max-width: 240px;"
>
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-sm text-base-content/70 uppercase tracking-wider">
{gettext("Folders")}
</h3>
<div class="flex gap-0.5">
<button
:if={@show_create}
phx-click="open_new_folder_modal"
phx-target={@myself}
class="btn btn-ghost btn-xs"
title={gettext("New folder")}
>
<.icon name="hero-folder-plus" class="w-4 h-4" />
</button>
<button
phx-click="toggle_sidebar"
phx-target={@myself}
class="btn btn-ghost btn-xs"
title={gettext("Collapse sidebar")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
</div>
</div>
<%!-- All Files flat view (only when unscoped — admin media page) --%>
<%= if @show_all_files and is_nil(@scope_folder_id) do %>
<button
phx-click="navigate_view_all"
phx-target={@myself}
class={[
"flex items-center gap-2 w-full px-2 py-1.5 rounded-lg text-sm transition-colors mb-1 text-left",
if(@file_view == "all" and not @filter_trash,
do: "bg-primary/10 font-semibold text-primary",
else: "hover:bg-base-200"
)
]}
>
<.icon name="hero-rectangle-stack" class="w-4 h-4 shrink-0" /> {gettext("All Files")}
</button>
<% end %>
<%!-- Root (navigate to real root folder) --%>
<button
phx-click="navigate_root"
phx-target={@myself}
data-drop-folder="root"
class={[
"flex items-center gap-2 w-full px-2 py-1.5 rounded-lg text-sm transition-colors mb-1 text-left",
if(@current_folder == nil and @file_view != "all" and not @filter_trash,
do: "bg-primary/10 font-semibold text-primary",
else: "hover:bg-base-200"
)
]}
>
<.icon name="hero-inbox" class="w-4 h-4 shrink-0" /> {@scope_folder_name}
</button>
<div class="divider my-1 h-0"></div>
<%!-- Folder Tree --%>
<%!-- Scrolls both ways: deep folders extend past the 240px width and
keep their full names (no truncation); scroll right to read them. --%>
<ul class="space-y-0.5 w-full min-h-0 flex-1 overflow-auto pr-1">
<%= for node <- @folder_tree do %>
<.folder_tree_node
node={node}
current_folder={@current_folder}
active_path={@active_path}
expanded_folders={@expanded_folders}
renaming_folder={@renaming_folder}
renaming_source={@renaming_source}
renaming_text={@renaming_text}
filter_trash={@filter_trash}
depth={0}
myself={@myself}
show_rename={@show_rename}
enable_drag={@enable_drag}
items={@items}
item={@item}
/>
<% end %>
<%!-- Items at the vault root, after the folders, as a file
manager orders them. --%>
<.tree_item
:for={entry <- items_for(@items, "root")}
entry={entry}
enable_drag={@enable_drag}
item={@item}
/>
</ul>
<%!-- Trash --%>
<%= if @show_trash do %>
<div class="divider my-1 h-0"></div>
<button
phx-click="toggle_trash_filter"
phx-target={@myself}
data-drop-trash="true"
class={[
"flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm transition-colors w-full",
if(@filter_trash,
do: "bg-error/10 font-semibold text-error",
else: "hover:bg-base-200 text-base-content/60"
)
]}
>
<.icon name="hero-trash" class="w-4 h-4 shrink-0" /> {gettext("Trash")}
<%= if @trash_count > 0 do %>
<span class="badge badge-sm badge-error ml-auto">{@trash_count}</span>
<% end %>
</button>
<% end %>
</div>
<% end %>
</div>
"""
end
# ──────────────────────────────────────────────────────────────
# Leaf row
# ──────────────────────────────────────────────────────────────
attr :entry, :map, required: true
attr :enable_drag, :boolean, default: true
attr :item, :any, default: []
@doc false
# One leaf inside a folder. The `<li>` carries `data-draggable-file`, so an
# item is draggable on the same terms as a folder and the consumer's slot can
# stay a plain row — the drag-drop hook finds it either way.
def tree_item(assigns) do
~H"""
<li
data-draggable-file={@enable_drag && Map.get(@entry, :id)}
data-context-kind="item"
data-context-value={Map.get(@entry, :id)}
data-context-label={Map.get(@entry, :label) || Map.get(@entry, :name)}
class="pl-5 pr-1 rounded hover:bg-base-200"
>
<%= if @item == [] do %>
<span class="block text-sm truncate py-1 text-base-content/70">
{Map.get(@entry, :label) || Map.get(@entry, :name)}
</span>
<% else %>
{render_slot(@item, @entry)}
<% end %>
</li>
"""
end
@doc false
# Leaves for one folder. Tolerant of a missing key and of a `nil` map, so a
# consumer can pass items for the two folders it cares about.
def items_for(items, key) when is_map(items), do: Map.get(items, key, [])
def items_for(_items, _key), do: []
# ──────────────────────────────────────────────────────────────
# Recursive tree node
# ──────────────────────────────────────────────────────────────
attr :node, :map, required: true
attr :current_folder, :any, required: true
attr :active_path, :any,
default: MapSet.new(),
doc: "UUIDs from a root folder to the current folder; darkens their connector lines."
attr :connector_mode, :atom,
default: :normal,
values: [:normal, :active_trunk, :active_turn],
doc:
"How this node's guide line is drawn: normal, a darkened pass-through trunk, or the darkened turn into the active branch."
attr :expanded_folders, :any, required: true
attr :renaming_folder, :any, default: nil
attr :renaming_text, :string, default: ""
attr :renaming_source, :any, default: nil
attr :filter_trash, :boolean, default: false
attr :depth, :integer, default: 0
attr :myself, :any, default: nil
attr :items, :any, default: %{}
# The `:item` slot, forwarded down the recursion as a plain assign — a slot
# is a list of maps, so it travels as an attr without ceremony.
attr :item, :any, default: []
# Behavior config so the same recursive node powers both the sidebar and the
# move-destination picker. Defaults reproduce the sidebar; the move modal
# passes its own select/toggle events and turns off rename + drag.
attr :on_navigate, :string,
default: "navigate_folder",
doc: "Event fired when a folder row/name is clicked (sidebar navigates, move modal selects)."
attr :on_toggle, :string,
default: "toggle_folder_expand",
doc: "Event fired by the disclosure chevron."
attr :show_rename, :boolean, default: true, doc: "Show the inline rename affordance."
attr :enable_drag, :boolean, default: true, doc: "Emit drag-drop data attributes."
attr :hover_class, :string, default: "hover:bg-base-200", doc: "Row hover background utility."
def folder_tree_node(assigns) do
# In trash view no folder is "active" in the file sense — the user is
# looking at trashed files, not a folder's contents. We keep
# `@current_folder` populated in the socket so toggling trash off
# restores the previous folder, but the tree highlight is suppressed
# while filter_trash is on (the sidebar Trash button carries the
# active highlight instead).
assigns =
assign(
assigns,
:is_active,
(not assigns.filter_trash and assigns.current_folder) &&
assigns.current_folder.uuid == assigns.node.folder.uuid
)
assigns =
assign(
assigns,
:is_expanded,
MapSet.member?(assigns.expanded_folders, assigns.node.folder.uuid)
)
assigns = assign(assigns, :has_children, assigns.node.children != [])
assigns = assign(assigns, :node_items, items_for(assigns.items, assigns.node.folder.uuid))
# `has_children` stays folder-only: the connector geometry is drawn from
# it, and changing its meaning would move lines under MediaBrowser, which
# passes no items. Expansion is the thing that has to widen — a folder
# holding nothing but notes still opens.
assigns =
assign(assigns, :expandable?, assigns.has_children or assigns.node_items != [])
assigns =
assign(
assigns,
:is_renaming,
(assigns.show_rename and
assigns.renaming_folder == assigns.node.folder.uuid) &&
assigns.renaming_source == "sidebar"
)
# This node's own connector style comes from its parent (`@connector_mode`).
# For ITS children we find which one (if any) continues the active branch:
# children above it get a darkened vertical trunk (`:active_trunk`), the
# branch child itself gets the darkened turn (`:active_turn`), the rest stay
# normal. Suppressed in trash view (the tree highlight is off there).
assigns =
assign(
assigns,
:on_path_child_index,
if(assigns.filter_trash,
do: nil,
else:
Enum.find_index(
assigns.node.children,
&MapSet.member?(assigns.active_path, &1.folder.uuid)
)
)
)
assigns =
assign(
assigns,
:tree_connector_class,
tree_connector_class(assigns.depth, assigns.has_children, assigns.connector_mode)
)
~H"""
<li class={[@tree_connector_class]}>
<%!--
Whole row is clickable to open the folder. LiveView resolves a click
to the closest `phx-click` element, so the nested chevron (toggle) and
rename buttons still handle their own clicks — only clicks elsewhere on
the row fall through to `navigate_folder`. The click is suppressed while
the inline rename form is open so clicking the text field doesn't
navigate away. The inner folder button is kept for keyboard access.
--%>
<div
phx-click={!@is_renaming && @on_navigate}
phx-target={@myself}
phx-value-folder-uuid={@node.folder.uuid}
data-context-kind="folder"
data-context-value={@node.folder.uuid}
data-context-label={@node.folder.name}
class={[
"flex items-center gap-0.5 rounded-lg px-1 py-1 transition-colors group min-w-max",
@hover_class,
!@is_renaming && "cursor-pointer",
@is_active && "font-semibold"
]}
style={
if @is_active,
do:
"background-color: #{folder_color_hex(folder_color(@node.folder)) || "oklch(var(--p))"}25"
}
>
<%!-- Chevron (expand/collapse) --%>
<%= if @expandable? do %>
<button
phx-click={@on_toggle}
phx-target={@myself}
phx-value-folder-uuid={@node.folder.uuid}
class="btn btn-ghost btn-xs p-0 min-h-0 h-5 w-5"
>
<.icon
name={if @is_expanded, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
class="w-4 h-4 text-base-content/40"
/>
</button>
<% else %>
<span class="w-5"></span>
<% end %>
<%= if @is_renaming do %>
<%!-- Inline rename form --%>
<form
id={"folder-tree-rename-form-#{@node.folder.uuid}"}
phx-submit="rename_folder"
phx-change="rename_folder_input"
phx-target={@myself}
class="flex items-center gap-1.5 flex-1 min-w-0"
>
<input type="hidden" name="folder_uuid" value={@node.folder.uuid} />
<span style={folder_icon_style(folder_color(@node.folder))}>
<.icon name="hero-folder" class="w-4 h-4 shrink-0" />
</span>
<%!--
Minimal bordered input — pairs with the row's
`ring-2 ring-primary` above. Sits flush with the row's
natural height (no daisyUI `input input-bordered input-xs`
chunkiness) and uses a thin primary border + white bg so
it reads as "edit field" without overwhelming the row.
--%>
<input
type="text"
name="name"
id={"rename-folder-#{@node.folder.uuid}"}
value={@renaming_text}
class="bg-base-100 text-sm rounded px-1.5 py-0 flex-1 min-w-0 border border-primary/60 focus:outline-none focus:border-primary"
phx-hook="SelectOnMount"
required
phx-keydown="cancel_rename_folder"
phx-key="Escape"
phx-blur="cancel_rename_folder"
phx-target={@myself}
phx-debounce="50"
/>
</form>
<% else %>
<%!-- Folder button (uncontrolled: phx-click instead of .link navigate) --%>
<button
phx-click={@on_navigate}
phx-target={@myself}
phx-value-folder-uuid={@node.folder.uuid}
data-drop-folder={@enable_drag && @node.folder.uuid}
data-draggable-folder={@enable_drag && @node.folder.uuid}
class="flex items-center gap-1.5 flex-1 text-sm text-left"
>
<span style={folder_icon_style(folder_color(@node.folder), @is_active)}>
<.icon
name={if @is_expanded, do: "hero-folder-open", else: "hero-folder"}
class="w-4 h-4 shrink-0"
/>
</span>
<span
class={[
"whitespace-nowrap block",
@renaming_folder == @node.folder.uuid && !@is_renaming && "renaming-preview"
]}
title={@node.folder.name}
>
<%= if @renaming_folder == @node.folder.uuid && @renaming_text != "" do %>
{@renaming_text}
<% else %>
{@node.folder.name}
<% end %>
</span>
</button>
<%!-- Rename button (visible on hover) --%>
<button
:if={@show_rename}
phx-click="start_rename_folder"
phx-target={@myself}
phx-value-folder-uuid={@node.folder.uuid}
phx-value-source="sidebar"
class="btn btn-ghost btn-xs p-0 min-h-0 h-5 w-5 opacity-0 group-hover:opacity-100"
title={gettext("Rename")}
>
<.icon name="hero-pencil" class="w-3 h-3 text-base-content/40" />
</button>
<% end %>
</div>
<%!-- Children (expanded) --%>
<%= if @expandable? && @is_expanded do %>
<%!--
Tree guide lines are drawn per child <li> (see the connector
classes on the <li> below), not as a single full-height border on
this <ul>. That lets the LAST child's vertical segment stop at its
own row and curl right (an elbow), instead of the line overshooting
past the last item. The parent folder's color is handed down as an
inheriting CSS variable so every child connector picks it up; a
deeper nested <ul> overrides it with its own folder color.
--%>
<ul
class="ml-3"
style={"--pk-tree-line: #{tree_line_color(folder_color(@node.folder))}; --pk-tree-line-active: #{tree_line_color_active(folder_color(@node.folder))}"}
>
<%= for {child, idx} <- Enum.with_index(@node.children) do %>
<.folder_tree_node
node={child}
current_folder={@current_folder}
active_path={@active_path}
connector_mode={child_connector_mode(@on_path_child_index, idx)}
expanded_folders={@expanded_folders}
renaming_folder={@renaming_folder}
renaming_source={@renaming_source}
renaming_text={@renaming_text}
filter_trash={@filter_trash}
depth={@depth + 1}
myself={@myself}
on_navigate={@on_navigate}
on_toggle={@on_toggle}
show_rename={@show_rename}
enable_drag={@enable_drag}
hover_class={@hover_class}
items={@items}
item={@item}
/>
<% end %>
<%!-- This folder's own leaves, after its subfolders. --%>
<.tree_item
:for={entry <- @node_items}
entry={entry}
enable_drag={@enable_drag}
item={@item}
/>
</ul>
<% end %>
</li>
"""
end
# Tree guide-line connector for a nested row (`depth > 0`). Returns a
# literal Tailwind class string (kept whole so the JIT picks it up — never
# interpolate the utility tokens):
#
# * a vertical line down the row's left edge (`before`), full height so it
# flows to the next sibling — `last:` shortens it to the row's center and
# turns it into a left+bottom bordered box with a rounded corner, so the
# last row curls right into the folder instead of overshooting.
# * a horizontal elbow into the row (`after`, hidden on the last row since
# the bordered box already draws it).
#
# The elbow length depends on whether the row has a disclosure chevron: a
# childless row runs the line across its empty chevron column right up to the
# folder icon (`w-9`), while a row with a chevron stops the line at the
# chevron (`w-4`) so it never crosses the `>` glyph. Root rows (`depth == 0`)
# get no connector.
# Color for the tree guide lines (`--pk-tree-line`), rendered at 50% opacity
# so the lines read lighter rather than a solid, dark stroke. A colored folder
# uses its hex with a `80` alpha suffix (~50%); an uncolored folder uses the
# theme text color at 50% via `color-mix` (theme-adaptive — dark in light
# mode, light in dark mode). The previous `oklch(var(--bc) / …)` neutral was
# invalid under daisyUI 5's renamed variables, so its border fell back to a
# solid-black `currentColor`.
@doc false
def tree_line_color(color) do
case folder_color_hex(color) do
nil -> "color-mix(in oklab, currentColor 50%, transparent)"
hex -> hex <> "80"
end
end
# Darker (less transparent) variant of the same line color, used for the
# connectors on the active root→current branch — same hue, just bolder. A
# colored folder bumps alpha `80` (~50%) → `E6` (~90%); the neutral falls
# back to currentColor at 85% (still theme-adaptive, not solid black).
@doc false
def tree_line_color_active(color) do
case folder_color_hex(color) do
nil -> "color-mix(in oklab, currentColor 85%, transparent)"
hex -> hex <> "E6"
end
end
# Which connector mode a child renders, given the index of the branch child
# in the same group (or nil when none): everything above the branch child is
# a darkened pass-through trunk, the branch child is the darkened turn, the
# rest are normal.
defp child_connector_mode(nil, _idx), do: :normal
defp child_connector_mode(branch_idx, idx) when idx < branch_idx, do: :active_trunk
defp child_connector_mode(branch_idx, idx) when idx == branch_idx, do: :active_turn
defp child_connector_mode(_branch_idx, _idx), do: :normal
# Connector classes are returned as whole literal Tailwind strings (never
# interpolate the utility tokens — the JIT scans source for complete class
# names, so each variant is spelled out in full).
#
# Three modes:
# * :normal — light vertical trunk + light elbow into the row.
# * :active_trunk — the active branch descends PAST this side row, so its
# vertical trunk is darkened while the elbow into the row
# stays light (the path doesn't enter here).
# * :active_turn — the active branch turns INTO this row. The trunk stays
# light so it can continue down to later siblings, and a
# darkened L-elbow (`after`) draws the turn over its top.
# A last child has no trunk below, so its `before` becomes
# the darkened elbow instead.
#
# `w-4` vs `w-9`: a row with a disclosure chevron stops the elbow at the
# chevron (`w-4`); a childless row runs it across the empty chevron column to
# the folder icon (`w-9`).
@doc false
def tree_connector_class(0, _has_children, _mode), do: false
def tree_connector_class(_depth, true = _has_children, :normal) do
"relative pl-3.5 " <>
"before:content-[''] before:absolute before:left-0 before:top-0 before:h-full before:w-0.5 before:bg-[var(--pk-tree-line)] " <>
"after:content-[''] after:absolute after:left-0 after:top-[0.8125rem] after:h-0.5 after:w-4 after:bg-[var(--pk-tree-line)] " <>
"last:before:h-[0.875rem] last:before:w-4 last:before:bg-transparent " <>
"last:before:border-l-2 last:before:border-b-2 last:before:border-[var(--pk-tree-line)] last:before:rounded-bl-lg " <>
"last:after:hidden"
end
def tree_connector_class(_depth, false = _has_children, :normal) do
"relative pl-3.5 " <>
"before:content-[''] before:absolute before:left-0 before:top-0 before:h-full before:w-0.5 before:bg-[var(--pk-tree-line)] " <>
"after:content-[''] after:absolute after:left-0 after:top-[0.8125rem] after:h-0.5 after:w-9 after:bg-[var(--pk-tree-line)] " <>
"last:before:h-[0.875rem] last:before:w-9 last:before:bg-transparent " <>
"last:before:border-l-2 last:before:border-b-2 last:before:border-[var(--pk-tree-line)] last:before:rounded-bl-lg " <>
"last:after:hidden"
end
def tree_connector_class(_depth, true = _has_children, :active_trunk) do
"relative pl-3.5 " <>
"before:content-[''] before:absolute before:left-[-1px] before:top-0 before:h-full before:w-1 before:bg-[var(--pk-tree-line-active)] " <>
"after:content-[''] after:absolute after:left-0 after:top-[0.8125rem] after:h-0.5 after:w-4 after:bg-[var(--pk-tree-line)] " <>
"last:before:h-[0.875rem] last:before:w-4 last:before:bg-transparent " <>
"last:before:left-[-1px] last:before:border-l-4 last:before:border-b-4 last:before:border-[var(--pk-tree-line-active)] last:before:rounded-bl-lg " <>
"last:after:hidden"
end
def tree_connector_class(_depth, false = _has_children, :active_trunk) do
"relative pl-3.5 " <>
"before:content-[''] before:absolute before:left-[-1px] before:top-0 before:h-full before:w-1 before:bg-[var(--pk-tree-line-active)] " <>
"after:content-[''] after:absolute after:left-0 after:top-[0.8125rem] after:h-0.5 after:w-9 after:bg-[var(--pk-tree-line)] " <>
"last:before:h-[0.875rem] last:before:w-9 last:before:bg-transparent " <>
"last:before:left-[-1px] last:before:border-l-4 last:before:border-b-4 last:before:border-[var(--pk-tree-line-active)] last:before:rounded-bl-lg " <>
"last:after:hidden"
end
def tree_connector_class(_depth, true = _has_children, :active_turn) do
"relative pl-3.5 " <>
"before:content-[''] before:absolute before:left-0 before:top-0 before:h-full before:w-0.5 before:bg-[var(--pk-tree-line)] " <>
"after:content-[''] after:absolute after:left-[-1px] after:top-0 after:h-[0.8125rem] after:w-4 after:bg-transparent " <>
"after:border-l-4 after:border-b-4 after:border-[var(--pk-tree-line-active)] after:rounded-bl-[0.15rem] " <>
"last:before:h-[0.875rem] last:before:w-4 last:before:bg-transparent " <>
"last:before:left-[-1px] last:before:border-l-4 last:before:border-b-4 last:before:border-[var(--pk-tree-line-active)] last:before:rounded-bl-lg " <>
"last:after:hidden"
end
def tree_connector_class(_depth, false = _has_children, :active_turn) do
"relative pl-3.5 " <>
"before:content-[''] before:absolute before:left-0 before:top-0 before:h-full before:w-0.5 before:bg-[var(--pk-tree-line)] " <>
"after:content-[''] after:absolute after:left-[-1px] after:top-0 after:h-[0.8125rem] after:w-9 after:bg-transparent " <>
"after:border-l-4 after:border-b-4 after:border-[var(--pk-tree-line-active)] after:rounded-bl-[0.15rem] " <>
"last:before:h-[0.875rem] last:before:w-9 last:before:bg-transparent " <>
"last:before:left-[-1px] last:before:border-l-4 last:before:border-b-4 last:before:border-[var(--pk-tree-line-active)] last:before:rounded-bl-lg " <>
"last:after:hidden"
end
# ──────────────────────────────────────────────────────────────
# Active-branch path (root → current folder)
# ──────────────────────────────────────────────────────────────
# Set of folder UUIDs on the path from a root node down to (and including)
# the current folder. Empty when there is no current folder or it isn't in
# the tree. Walks the already-nested tree, so it's O(n) over visible nodes.
@doc false
def active_path_uuids(_tree, nil), do: MapSet.new()
def active_path_uuids(tree, current_folder) do
case find_node_path(tree, current_folder.uuid) do
nil -> MapSet.new()
path -> MapSet.new(path)
end
end
defp find_node_path(nodes, target_uuid) do
Enum.find_value(nodes, fn node ->
if node.folder.uuid == target_uuid do
[target_uuid]
else
case find_node_path(node.children, target_uuid) do
nil -> nil
sub -> [node.folder.uuid | sub]
end
end
end)
end
# ──────────────────────────────────────────────────────────────
# Folder color helpers (shared with grid/list folder cards)
# ──────────────────────────────────────────────────────────────
def folder_bg_style(color) do
case folder_color_hex(color) do
nil -> nil
hex -> "background-color: #{hex}15"
end
end
def folder_icon_style(color, _active? \\ false) do
case folder_color_hex(color) do
nil -> "color: oklch(var(--wa))"
hex -> "color: #{hex}"
end
end
@doc """
A folder's colour, for a folder that may not have the concept.
Read with `Map.get/3` rather than `folder.color` on purpose: this component
is reusable, and a consumer's folder struct is its own — the knowledge base's
has a path and a name and nothing else. A missing key raised `KeyError` and
took the page down the moment such a vault had one folder.
"""
@spec folder_color(map()) :: String.t() | nil
def folder_color(folder), do: Map.get(folder, :color)
def folder_color_hex("red"), do: "#ef4444"
def folder_color_hex("orange"), do: "#f97316"
def folder_color_hex("amber"), do: "#f59e0b"
def folder_color_hex("yellow"), do: "#eab308"
def folder_color_hex("lime"), do: "#84cc16"
def folder_color_hex("green"), do: "#22c55e"
def folder_color_hex("emerald"), do: "#10b981"
def folder_color_hex("teal"), do: "#14b8a6"
def folder_color_hex("cyan"), do: "#06b6d4"
def folder_color_hex("sky"), do: "#0ea5e9"
def folder_color_hex("blue"), do: "#3b82f6"
def folder_color_hex("violet"), do: "#8b5cf6"
def folder_color_hex("purple"), do: "#a855f7"
def folder_color_hex("fuchsia"), do: "#d946ef"
def folder_color_hex("pink"), do: "#ec4899"
def folder_color_hex("rose"), do: "#f43f5e"
def folder_color_hex(_), do: nil
end