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 alias Phoenix.LiveView.JS # ────────────────────────────────────────────────────────────── # 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 :sidebar_rev, :integer, default: 0, doc: """ Bump it whenever the SERVER changes `sidebar_collapsed` on its own (not in answer to the chevron). The chevron flips the panes client-side, and LiveView keeps a `JS` class change sticky: it is re-applied after every patch, over whatever class the server renders. So a server-side `sidebar_collapsed: false` alone changed nothing on screen, and left the assign and the DOM disagreeing until a reload. The revision is part of the panes' ids, so a bump replaces them and the sticky change goes with them. """ 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""" <%!-- Both states stay in the DOM and the chevrons swap them CLIENT-side (JS.toggle_class) before pushing toggle_sidebar — collapsing used to wait a full round trip plus the re-render it dragged in, which read as the chevron being slow. The server still hears the event, so the assign (and the persisted preference) catch up, and its re-render then agrees with what the client already shows. The container takes no width of its own: whichever pane is visible sizes it. --%>
    <%!-- Collapsed strip --%>
    <%!-- Expanded sidebar (wrapper, so visibility is a plain block-level `hidden` toggle that cannot fight the inner pane's `flex`) --%>

    {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. [scrollbar-gutter:stable]: navigating expands and collapses nodes, so the list crosses its own scroll threshold constantly. Without a reserved gutter the 15px bar appears and disappears and every row — icon included — jumps sideways with it. --%>
      <%= 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 # The optimistic half of the sidebar toggle: swap the two panes in the # DOM this instant, then tell the server — which flips the assign, # persists the preference, and re-renders to the same picture. defp toggle_sidebar_js(id, rev, myself) do js = %JS{} |> JS.toggle_class("hidden", to: "##{id}-collapsed-#{rev}") |> JS.toggle_class("hidden", to: "##{id}-expanded-#{rev}") if myself, do: JS.push(js, "toggle_sidebar", target: myself), else: JS.push(js, "toggle_sidebar") 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). While its click waits on the server (`.phx-click-loading` on this button) the chevron turns into a spinner. The `>` variant keys on the button itself, not on the row around it, so opening the folder spins the row's icon only. Chevron and spinner are stacked in the same 16px box rather than swapped in the flow: trading one for the other used to resize the box (the icon is w-4, daisyUI's `loading-xs` 14px) and slide the rest of the row sideways and back on every click. The spinner only fades in after 300ms of waiting. `delay-300` applies while `.phx-click-loading` is on and NOT when it comes off, so a reply that beats the delay never starts the fade and shows nothing at all — no blink on a fast click — while a slow one still says "working". Same treatment on the folder icon below. --%> <%= 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). Opening a folder replies only once the new listing has rendered, so the icon turns into a spinner for as long as that takes — whether the click landed on this button or on the row around it. --%> <%!-- 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