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, create_untitled_folder,
start_rename_folder, rename_folder_input, rename_folder,
cancel_rename_folder, toggle_trash_filter
The drag-drop data attributes (`data-drop-folder`, `data-draggable-folder`,
`data-drop-trash`) are present unconditionally; consumers that wire up the
`MediaDragDrop` JS hook get drag-drop for free, others can ignore them.
## 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, required: true
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
def folder_explorer(assigns) do
~H"""
<%= if @sidebar_collapsed do %>
<%!-- Collapsed strip --%>
<% else %>
<%!-- Expanded sidebar --%>
<%= if is_nil(@scope_folder_id) do %>
{gettext("Folders")}
<% else %>
<% end %>
<%!-- All Files flat view (only when unscoped — admin media page) --%>
<%= if @show_all_files and is_nil(@scope_folder_id) do %>
<% end %>
<%!-- Root (navigate to real root folder) --%>
<%!-- Folder Tree --%>
<%= for node <- @folder_tree do %>
<.folder_tree_node
node={node}
current_folder={@current_folder}
expanded_folders={@expanded_folders}
renaming_folder={@renaming_folder}
renaming_source={@renaming_source}
renaming_text={@renaming_text}
filter_trash={@filter_trash}
depth={0}
myself={@myself}
/>
<% end %>
<%!-- Trash --%>
<%= if @show_trash do %>
<% end %>
<% end %>
"""
end
# ──────────────────────────────────────────────────────────────
# Recursive tree node
# ──────────────────────────────────────────────────────────────
attr :node, :map, required: true
attr :current_folder, :any, required: true
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, required: true
# 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,
:is_renaming,
(assigns.show_rename and
assigns.renaming_folder == assigns.node.folder.uuid) &&
assigns.renaming_source == "sidebar"
)
assigns =
assign(
assigns,
:tree_connector_class,
tree_connector_class(assigns.depth, assigns.has_children)
)
~H"""
<%!--
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.
--%>
<%!-- Chevron (expand/collapse) --%>
<%= if @has_children do %>
<% else %>
<% end %>
<%= if @is_renaming do %>
<%!-- Inline rename form --%>
<% else %>
<%!-- Folder button (uncontrolled: phx-click instead of .link navigate) --%>
<%!-- Rename button (visible on hover) --%>
<% end %>
<%!-- Children (expanded) --%>
<%= if @has_children && @is_expanded do %>
<%!--
Tree guide lines are drawn per child
(see the connector
classes on the
below), not as a single full-height border on
this
. 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
overrides it with its own folder color.
--%>
<%= for child <- @node.children do %>
<.folder_tree_node
node={child}
current_folder={@current_folder}
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}
/>
<% end %>
<% end %>
"""
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
@doc false
def tree_connector_class(0, _has_children), do: false
def tree_connector_class(_depth, true = _has_children) 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) 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
# ──────────────────────────────────────────────────────────────
# 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
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