Packages

phoenix_kit

1.7.146
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, 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"""
<div
id={@id}
class="hidden lg:block 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">
<%= if is_nil(@scope_folder_id) do %>
<h3 class="font-semibold text-sm text-base-content/70 uppercase tracking-wider">
{gettext("Folders")}
</h3>
<% else %>
<div></div>
<% end %>
<div class="flex gap-0.5">
<button
:if={@show_create}
phx-click="create_untitled_folder"
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 --%>
<ul class="space-y-0.5 w-full min-h-0 flex-1 overflow-y-auto pr-1">
<%= 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 %>
</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
# ──────────────────────────────────────────────────────────────
# 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"""
<li class={["overflow-hidden", @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}
class={[
"flex items-center gap-0.5 rounded-lg px-1 py-1 transition-colors group overflow-hidden min-w-0",
@hover_class,
!@is_renaming && "cursor-pointer",
@is_active && "font-semibold"
]}
style={
if @is_active,
do: "background-color: #{folder_color_hex(@node.folder.color) || "oklch(var(--p))"}25"
}
>
<%!-- Chevron (expand/collapse) --%>
<%= if @has_children 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
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(@node.folder.color)}>
<.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 min-w-0 overflow-hidden text-sm text-left"
>
<span style={folder_icon_style(@node.folder.color, @is_active)}>
<.icon
name={if @is_expanded, do: "hero-folder-open", else: "hero-folder"}
class="w-4 h-4 shrink-0"
/>
</span>
<span
class={[
"truncate block min-w-0",
@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 @has_children && @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 overflow-hidden"
style={"--pk-tree-line: #{tree_line_color(@node.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 %>
</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
@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