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} 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" … /> `data-context-kind` is `"folder"` or `"item"`, `data-context-value` is the uuid, `data-context-label` the name. They sit on the folder row `
` and the leaf `
  • ` — never on the wrapping `
  • ` 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"""
    <%= if @sidebar_collapsed do %> <%!-- Collapsed strip --%>
    <% else %> <%!-- Expanded sidebar --%>

    {gettext("Folders")}

    <%!-- 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 --%> <%!-- Scrolls both ways: deep folders extend past the 240px width and keep their full names (no truncation); scroll right to read them. --%>
      <%= 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} />
    <%!-- Trash --%> <%= if @show_trash do %> <% end %>
    <% end %>
    """ 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 `
  • ` 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"""
  • <%= if @item == [] do %> {Map.get(@entry, :label) || Map.get(@entry, :name)} <% else %> {render_slot(@item, @entry)} <% end %>
  • """ 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"""
  • <%!-- 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 @expandable? do %> <% else %> <% end %> <%= if @is_renaming do %> <%!-- Inline rename form --%>
    <.icon name="hero-folder" class="w-4 h-4 shrink-0" /> <%!-- 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. --%>
    <% else %> <%!-- Folder button (uncontrolled: phx-click instead of .link navigate) --%> <%!-- Rename button (visible on hover) --%> <% end %>
    <%!-- Children (expanded) --%> <%= if @expandable? && @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