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 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 # 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""" """ end # ────────────────────────────────────────────────────────────── # 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, 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" ) # 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 @has_children 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 @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