defmodule Backpex.HTML.Layout do
@moduledoc """
Contains all Backpex layout components.
"""
use BackpexWeb, :html
import Backpex.HTML.CoreComponents
alias Backpex.Router
require Backpex
@doc false
slot :inner_content
def layout(assigns) do
case assigns.live_resource.config(:layout) do
{module, fun} -> apply(module, fun, [assigns])
fun when is_function(fun, 1) -> fun.(assigns)
end
end
@doc """
Renders an app shell representing the base of your layout.
"""
@doc type: :component
attr :live_resource, :atom, default: nil, doc: "live resource module"
attr :class, :string, default: nil, doc: "class added to the app shell container"
attr :fluid, :boolean, default: false, doc: "toggles fluid layout"
slot :inner_block
slot :topbar, doc: "content to be displayed in the topbar" do
attr :class, :string, doc: "additional class that will be added to the component"
end
slot :sidebar, doc: "content to be displayed in the sidebar" do
attr :class, :string, doc: "additional class that will be added to the component"
end
slot :footer, doc: "content to be displayed in the footer"
def app_shell(assigns) do
~H"""
<.alert
id="client-error"
class="hidden"
kind={:error}
closable={false}
title={Backpex.__("We can't find the internet!")}
phx-disconnected={JS.remove_class("hidden", to: ".phx-client-error #client-error")}
phx-connected={JS.add_class("hidden")}
>
<:icon><.icon name="hero-arrow-path" class="size-5 motion-safe:animate-spin" />
{Backpex.__("Attempting to reconnect...")}
<.alert
id="server-error"
class="hidden"
kind={:error}
closable={false}
title={Backpex.__("Something went wrong!")}
phx-disconnected={JS.remove_class("hidden", to: ".phx-server-error #server-error")}
phx-connected={JS.add_class("hidden")}
>
<:icon><.icon name="hero-arrow-path" class="size-5 motion-safe:animate-spin" />
{Backpex.__("Hang in there while we get back on track...")}
"""
end
@doc """
Renders an alert.
"""
@doc type: :component
attr :id, :string, doc: "optional id of the alert container; when not provided, a default of \"flash-\" is used"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :class, :string, default: nil, doc: "additional class to be added to the component"
attr :kind, :atom, required: true, values: ~w(info success warning error)a, doc: "used for styling"
attr :closable, :boolean, default: true, doc: "show or hide the close button"
attr :on_close, JS, default: nil, doc: "optional event triggered on alert close"
attr :close_label, :string, default: "Close alert"
attr :title, :string, default: nil, doc: "title for the alert"
attr :rest, :global
slot :inner_block
slot :icon
def alert(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
"""
end
@doc """
Renders a footer. It provides a default look when no content is provided.
"""
@doc type: :component
attr :class, :string, default: "", doc: "additional class that will be added to the component"
slot :inner_block
def footer(assigns) do
~H"""
"""
end
defp version, do: Application.spec(:backpex, :vsn) |> to_string()
@doc """
Renders the topbar branding.
"""
@doc type: :component
attr :class, :string, default: "", doc: "additional class that will be added to the component"
attr :title, :string, default: "Backpex", doc: "title that will be displayed next to the logo"
attr :hide_title, :boolean, default: false, doc: "if the title should be hidden"
slot :logo, doc: "the logo of the branding"
def topbar_branding(assigns) do
~H"""
<%= if @logo === [] do %>
<.backpex_logo class="w-8" />
<% else %>
{render_slot(@logo)}
<% end %>
<%= unless @hide_title do %>
{@title}
<% end %>
"""
end
@doc """
Renders a theme selector.
"""
@doc type: :component
attr :socket, :any, required: true
attr :class, :string, default: nil
attr :label, :string, default: "Theme"
attr :themes, :list,
doc: "A list of tuples with {theme_label, theme_name} format",
examples: [[{"Light", "light"}, {"Dark", "dark"}]]
def theme_selector(assigns) do
~H"""
<.dropdown id="backpex-theme-selector" phx-hook="BackpexThemeSelector" class={["dropdown-end", @class]}>
<:trigger aria_label={@label}>
<%!-- Desktop Icon --%>
<:menu class="w-48 max-h-96 overflow-y-scroll">
"""
end
@doc """
Get the Backpex logo SVG.
"""
@doc type: :component
attr :class, :string, required: false, default: nil, doc: "class that will be added to the SVG element"
def backpex_logo(assigns) do
~H"""
"""
end
@doc """
Renders a topbar dropdown.
"""
@doc type: :component
attr :class, :string, required: false, default: nil, doc: "additional class that will be added to the component"
attr :aria_label, :string, required: false, default: "User menu", doc: "accessible label for screen readers"
slot :label, required: true, doc: "label of the dropdown"
def topbar_dropdown(assigns) do
~H"""
<.dropdown id="topbar-dropdown" class="dropdown-end">
<:trigger aria_label={@aria_label}>
{render_slot(@label)}
<:menu class="w-52 p-2">
{render_slot(@inner_block)}
"""
end
@doc """
Container to wrap main elements and add margin.
"""
@doc type: :component
attr :class, :string, default: "", doc: "additional class that will be added to the component"
slot :inner_block
def main_container(assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
@doc """
Renders a title.
"""
@doc type: :component
attr :class, :string, default: "", doc: "additional class that will be added to the component"
slot :inner_block
def main_title(assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
@doc """
Renders a sidebar section.
"""
@doc type: :component
attr :class, :string, default: nil, doc: "additional class that will be added to the component"
attr :id, :string,
default: "section",
doc:
"The id for this section. It will be used to save and load the opening state of this section from local storage."
slot :inner_block
slot :label, required: true, doc: "label to be displayed on the section."
def sidebar_section(assigns) do
~H"""
{render_slot(@label)}
{render_slot(@inner_block)}
"""
end
@doc """
Renders a sidebar item. It uses `Phoenix.Component.link/1` component, so you can can use link and href navigation.
"""
@doc type: :component
attr :class, :string, default: "", doc: "additional class that will be added to the component"
attr :current_url, :string, doc: "the current url"
attr :navigate, :string
attr :patch, :string
attr :href, :any
slot :inner_block
def sidebar_item(assigns) do
path =
case assigns do
%{navigate: to} -> to
%{patch: to} -> to
%{href: href} -> href
end
assigns =
assigns
|> assign(:active, Router.active?(assigns.current_url, path))
|> assign(:extra, assigns_to_attributes(assigns))
~H"""
"""
end
@doc """
Renders the form label and input with corresponding margin and alignment.
"""
@doc type: :component
attr :class, :any, default: nil, doc: "extra classes to be added"
slot :label do
attr :align, :atom, values: [:top, :center, :bottom]
end
slot :inner_block
def field_container(%{label: []} = assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
def field_container(assigns) do
assigns =
update(assigns, :label, fn
[label] -> label
_other -> raise ArgumentError, "Expected a single label slot, got: #{inspect(assigns.label)}"
end)
~H"""
{render_slot(@label)}
{render_slot(@inner_block)}
"""
end
@doc """
Renders a modal.
"""
@doc type: :component
attr :id, :string, required: true, doc: "modal ID"
attr :class, :string, default: nil, doc: "class for the modal wrapper"
attr :box_class, :string, default: "max-w-xl", doc: "class for the modal box"
attr :title, :string, default: nil, doc: "modal title"
attr :close_label, :string, default: "Close modal"
attr :open, :boolean, default: true, doc: "modal open"
attr :on_cancel, JS, default: %JS{}, doc: "event triggered on modal close"
attr :rest, :global
slot :inner_block, required: true
def modal(assigns) do
~H"""
"""
end
def open_modal(js \\ %JS{}, id) do
js
|> JS.focus_first(to: "##{id}-box")
|> JS.set_attribute({"open", "open"}, to: "##{id}")
|> JS.add_class("modal-open", to: "##{id}")
end
def close_modal(js \\ %JS{}, id) do
js
|> JS.pop_focus()
|> JS.remove_attribute("open", to: "##{id}")
|> JS.remove_class("modal-open", to: "##{id}")
end
@doc """
Renders a text to be used as a label for an input.
"""
@doc type: :component
attr :as, :string, default: "label", doc: "html tag name"
attr :text, :string, doc: "text of the label"
attr :for, :any, default: nil, doc: "form element the label is bound to"
attr :rest, :global
def input_label(assigns) do
assigns =
case assigns.for do
%Phoenix.HTML.FormField{} = field -> assign(assigns, :rest, Map.put(assigns.rest, :for, field.id))
id -> assign(assigns, :rest, Map.put(assigns.rest, :for, id))
end
~H"""
<.dynamic_tag tag_name={@as} class="text-content block break-words text-sm font-medium" {@rest}>
{@text}
"""
end
@doc """
Filters fields by certain panel.
## Examples
iex> Backpex.HTML.Layout.visible_fields_by_panel([field1: %{panel: :default}, field2: %{panel: :panel}], :default, nil)
[field1: %{panel: :default}]
iex> Backpex.HTML.Layout.visible_fields_by_panel([field1: %{panel: :default, visible: fn _assigns -> false end}, field2: %{panel: :panel}], :default, nil)
[]
iex> Backpex.HTML.Layout.visible_fields_by_panel([field1: %{panel: :default}], :panel, nil)
[]
"""
def visible_fields_by_panel(fields, panel, assigns) do
fields
|> Keyword.filter(fn {_name, field_options} ->
get_panel(field_options) == panel and visible?(field_options, assigns)
end)
end
defp get_panel(%{panel: panel} = _field), do: panel
defp get_panel(_field), do: :default
defp visible?(%{visible: visible} = _field_options, assigns), do: visible.(assigns)
defp visible?(_field_options, _assigns), do: true
defp get_align_class(align) do
case align do
:top -> "sm:self-start"
:center -> "sm:self-center"
:bottom -> "sm:self-end"
_align -> "sm:self-center"
end
end
end