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"""
0 && "md:pl-64"]}>
<.topbar class={build_slot_class(@topbar)}> {render_slot(@topbar)}
{render_slot(@inner_block)}
{render_slot(@footer)} <.footer :if={@footer == []} />
""" end defp build_slot_class(slot), do: Enum.map(slot, &Map.get(&1, :class)) @doc """ Renders a topbar. """ @doc type: :component attr :class, :string, default: "", doc: "additional class to be added to the component" slot :inner_block def topbar(assigns) do ~H"""
{render_slot(@inner_block)}
""" end @doc """ Renders flash messages. """ @doc type: :component attr :flash, :map, required: true, doc: "flash map that will be passed to `Phoenix.Flash.get/2`" attr :close_label, :string, default: "Close alert" def flash_messages(assigns) do ~H"""
<.alert :for={kind <- ~w(info success warning error)a} kind={kind} flash={@flash} close_label={@close_label} />
<.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 --%> <%!-- Mobile Icon --%>
<.icon name="hero-swatch" class="size-6 md:hidden" />
<: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)}
  • """ 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"""
  • <.link class={[@class, @active && "bg-neutral text-neutral-content"]} {@extra}> {render_slot(@inner_block)}
  • """ 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""" <.focus_wrap id={"#{@id}-box"} class={["modal-box duration-0 p-0", @box_class]} phx-key="escape" phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")} phx-click-away={JS.exec("data-cancel", to: "##{@id}")} >
    {@title}
    {render_slot(@inner_block)}
    """ 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