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_filterFrom 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}</.link>
</:item>
</.folder_explorer>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" … />
</.context_menu>data-context-kind is "folder" or "item", data-context-value is the
uuid, data-context-label the name. They sit on the folder row <div> and
the leaf <li> — never on the wrapping <li> 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(defaulttrue) — show the+toolbar button.show_all_files(defaulttrue) — show the "All Files" flat-view button (only renders whenscope_folder_idisnil; the flag gates that branch).show_trash(defaulttrue) — 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.
Summary
Functions
A folder's colour, for a folder that may not have the concept.
Functions
A folder's colour, for a folder that may not have the concept.
Read with Map.get/3 rather than folder.color on purpose: this component
is reusable, and a consumer's folder struct is its own — the knowledge base's
has a path and a name and nothing else. A missing key raised KeyError and
took the page down the moment such a vault had one folder.
Attributes
id(:string) - Defaults to"folder-explorer".myself(:any) - The consuming LiveComponent's@myself. Passnilfrom a plain LiveView: HEEx omits a nil attribute, sophx-targetis not rendered and the events arrive at the LiveView itself. Defaults tonil.folder_tree(:any) (required)current_folder(:any) - Defaults tonil.expanded_folders(:any) (required)scope_folder_id(:any) - Defaults tonil.scope_folder_name(:string) - Defaults to"Root".renaming_folder(:any) - Defaults tonil.renaming_source(:any) - Defaults tonil.renaming_text(:string) - Defaults to"".filter_trash(:boolean) - Defaults tofalse.file_view(:string) - Defaults tonil.sidebar_collapsed(:boolean) - Defaults tofalse.trash_count(:integer) - Defaults to0.show_create(:boolean) - Defaults totrue.show_all_files(:boolean) - Defaults totrue.show_trash(:boolean) - Defaults totrue.show_rename(:boolean) - Show the inline rename affordance on each folder. Defaults totrue.items(:any) - 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 whatdata-draggable-filecarries, so items are draggable on the same terms as folders with no work from the consumer.Defaults to
%{}.enable_drag(:boolean) - Emit the drag-drop data attributes. Off for a consumer with no move handlers, so folders are not draggable into a void. Defaults totrue.class(:any) - 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"). Defaults to"hidden lg:block".
Slots
item- How one leaf row renders, given the item. Without it a row showsitem.label(oritem.name) as plain text — enough to see the tree, not enough to click it, so a real consumer passes this.
Attributes
node(:map) (required)current_folder(:any) (required)active_path(:any) - UUIDs from a root folder to the current folder; darkens their connector lines. Defaults toMapSet.new([]).connector_mode(:atom) - How this node's guide line is drawn: normal, a darkened pass-through trunk, or the darkened turn into the active branch. Defaults to:normal. Must be one of:normal,:active_trunk, or:active_turn.expanded_folders(:any) (required)renaming_folder(:any) - Defaults tonil.renaming_text(:string) - Defaults to"".renaming_source(:any) - Defaults tonil.filter_trash(:boolean) - Defaults tofalse.depth(:integer) - Defaults to0.myself(:any) - Defaults tonil.items(:any) - Defaults to%{}.item(:any) - Defaults to[].on_navigate(:string) - Event fired when a folder row/name is clicked (sidebar navigates, move modal selects). Defaults to"navigate_folder".on_toggle(:string) - Event fired by the disclosure chevron. Defaults to"toggle_folder_expand".show_rename(:boolean) - Show the inline rename affordance. Defaults totrue.enable_drag(:boolean) - Emit drag-drop data attributes. Defaults totrue.hover_class(:string) - Row hover background utility. Defaults to"hover:bg-base-200".