defmodule PlausibleWeb.Components.Generic do @moduledoc """ Generic reusable components """ use Phoenix.Component, global_prefixes: ~w(x-) import PlausibleWeb.Components.Icons alias PlausibleWeb.Router.Helpers, as: Routes @notice_themes %{ gray: %{ bg: "bg-gray-100 dark:bg-gray-800", icon: "text-gray-600 dark:text-gray-300", title_text: "text-sm text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-800 dark:text-gray-200 leading-5" }, yellow: %{ bg: "bg-yellow-100/60 dark:bg-yellow-900/40", icon: "text-yellow-500", title_text: "text-sm text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" }, red: %{ bg: "bg-red-100 dark:bg-red-900/30", icon: "text-red-600 dark:text-red-500", title_text: "text-sm text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" }, white: %{ bg: "bg-white dark:bg-gray-900 shadow-sm dark:shadow-none", icon: "text-gray-600 dark:text-gray-400", title_text: "text-sm text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-600 dark:text-gray-300 leading-5" } } # Themes/sizes/base are defined as component classes in # assets/css/app.css and mirrored by the React `Button` component in # assets/js/dashboard/components/button.tsx. Update there only. @button_themes %{ "primary" => "btn-theme-primary", "secondary" => "btn-theme-secondary", "yellow" => "btn-theme-yellow", "danger" => "btn-theme-danger", "ghost" => "btn-theme-ghost", "icon" => "btn-theme-icon" } @button_base_class "btn-base" @button_sizes %{ "sm" => "btn-sm", "md" => "btn-md" } attr(:class, :string, default: "") slot(:inner_block, required: true) def auth_container(assigns) do ~H"""
{render_slot(@inner_block)}
""" end attr(:type, :string, default: "button") attr(:theme, :string, default: "primary") attr(:size, :string, default: "md") attr(:class, :string, default: "") attr(:disabled, :boolean, default: false) attr(:mt?, :boolean, default: true) attr(:rest, :global, include: ~w(name)) slot(:inner_block) def button(assigns) do assigns = assign(assigns, button_base_class: @button_base_class, theme_class: @button_themes[assigns.theme], size_class: @button_sizes[assigns.size] ) ~H""" """ end attr(:href, :string, required: true) attr(:class, :string, default: "") attr(:theme, :string, default: "primary") attr(:size, :string, default: "md") attr(:disabled, :boolean, default: false) attr(:method, :string, default: "get") attr(:mt?, :boolean, default: true) attr(:rest, :global) slot(:inner_block) def button_link(assigns) do extra = if assigns.method == "get" do [] else [ "data-csrf": Phoenix.Controller.get_csrf_token(), "data-method": assigns.method, "data-to": assigns.href ] end assigns = assign(assigns, extra: extra) theme_class = if assigns.disabled do "bg-gray-400 text-white transition-all duration-150 dark:text-white dark:text-gray-400 dark:bg-gray-700 cursor-not-allowed" else @button_themes[assigns.theme] end onclick = if assigns.disabled do "return false;" else assigns[:onclick] end assigns = assign(assigns, onclick: onclick, button_base_class: @button_base_class, theme_class: theme_class, size_class: @button_sizes[assigns.size] ) ~H""" <.link href={@href} onclick={@onclick} class={[ @mt? && "mt-6", @button_base_class, @size_class, @theme_class, @class ]} {@extra} {@rest} > {render_slot(@inner_block)} """ end attr(:slug, :string, required: true) attr(:class, :string, default: nil) def docs_info(assigns) do ~H"""
<.tooltip enabled?={true} centered?={true}> <:tooltip_content> Learn more
""" end attr(:title, :any, default: "") attr(:class, :string, default: "") attr(:rest, :global) slot(:icon, required: true) slot(:inner_block) def upgrade(assigns) do ~H"""
{render_slot(@icon)}

{@title}

{render_slot(@inner_block)}

""" end attr(:title, :any, default: nil) attr(:theme, :atom, default: :yellow) attr(:dismissable_id, :any, default: nil) attr(:show_icon, :boolean, default: true) attr(:class, :string, default: "") attr(:icon_class, :string, default: "") attr(:rest, :global) slot(:inner_block) slot(:actions) slot(:icon) def notice(assigns) do assigns = assign(assigns, :theme, Map.fetch!(@notice_themes, assigns.theme)) ~H"""
<%= if @icon != [] do %> {render_slot(@icon)} <% else %> <.exclamation_triangle_icon class={"size-4.5 #{@theme.icon}"} /> <% end %>

{@title}

{render_slot(@inner_block)}

{render_slot(@actions)}
""" end attr(:href, :string, default: "#") attr(:new_tab, :boolean, default: false) attr(:class, :string, default: "") attr(:rest, :global, include: ~w(patch)) attr(:method, :string, default: "get") slot(:inner_block) def styled_link(assigns) do ~H""" <.unstyled_link new_tab={@new_tab} href={@href} method={@method} class={"text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-400 transition-colors duration-150 " <> @class} {@rest} > {render_slot(@inner_block)} """ end attr :class, :string, default: "" attr :id, :string, default: nil slot :button, required: true do attr(:class, :string) end slot :menu, required: true do attr(:class, :string) end def dropdown(assigns) do assigns = assign(assigns, :menu_class, assigns.menu |> List.first() |> Map.get(:class, "")) ~H"""
""" end attr(:href, :string) attr(:class, :string, default: "") attr(:id, :string, default: nil) attr(:new_tab, :boolean, default: false) attr(:disabled, :boolean, default: false) attr(:rest, :global, include: ~w(method)) slot(:inner_block, required: true) @base_class "block rounded-md text-sm/6 text-gray-900 ui-disabled:text-gray-500 dark:text-gray-100 dark:ui-disabled:text-gray-400 px-3 py-1.5" @clickable_class "hover:bg-gray-100 dark:hover:bg-gray-700/80" def dropdown_item(assigns) do assigns = if assigns[:disabled] do assign(assigns, :state, "disabled") else assign(assigns, :state, "") end if assigns[:href] && !assigns[:disabled] do assigns = assign(assigns, :class, [assigns[:class], @base_class, @clickable_class]) ~H""" <.unstyled_link id={@id} class={@class} new_tab={@new_tab} href={@href} x-on:click="close()" data-ui-state={@state} {@rest} > {render_slot(@inner_block)} """ else assigns = assign(assigns, :class, [assigns[:class], @base_class]) ~H"""
{render_slot(@inner_block)}
""" end end def dropdown_divider(assigns) do ~H""" """ end attr(:href, :string, required: true) attr(:new_tab, :boolean, default: false) attr(:class, :string, default: "") attr(:rest, :global) attr(:method, :string, default: "get") slot(:inner_block) def unstyled_link(assigns) do extra = if assigns.method == "get" do [] else [ "data-csrf": Phoenix.Controller.get_csrf_token(), "data-method": assigns.method, "data-to": assigns.href ] end assigns = assign(assigns, extra: extra) if assigns[:new_tab] do assigns = assign(assigns, :icon_class, icon_class(assigns)) ~H""" <.link class={[ "inline-flex items-center gap-x-1", @class ]} href={@href} target="_blank" rel="noopener noreferrer" {@extra} {@rest} > {render_slot(@inner_block)} <.external_link_icon class={[@icon_class]} /> """ else ~H""" <.link class={@class} href={@href} {@extra} {@rest}>{render_slot(@inner_block)} """ end end attr(:class, :any, default: "") attr(:rest, :global) def spinner(assigns) do ~H""" """ end attr :id, :string, required: true attr :js_active_var, :string, default: nil attr :checked, :boolean, default: nil attr :id_suffix, :string, default: "" attr :disabled, :boolean, default: false attr(:rest, :global) @doc """ Renders toggle input. Can be used in two modes: 1. Alpine JS mode: Pass `:js_active_var` to control toggle state via Alpine JS. Set this outside this component with `x-data="{ : }"`. 2. Server-side mode: Pass `:checked` boolean and `phx-click` event handler. ### Examples - Alpine JS mode ```
``` ### Examples - Server-side mode ``` <.toggle_switch id="my_toggle" checked={@my_toggle} phx-click="toggle-my-setting" phx-target={@myself} /> ``` """ def toggle_switch(assigns) do server_mode? = not is_nil(assigns.checked) assigns = assign(assigns, :server_mode?, server_mode?) ~H""" """ end attr :id, :string, required: true attr :js_active_var, :string, required: true attr :id_suffix, :string, default: "" attr :disabled, :boolean, default: false attr :label, :string, required: true attr :help_text, :string, default: nil attr :show_help_text_only_when_active?, :boolean, default: false attr :mt?, :boolean, default: true attr(:rest, :global) @doc """ Renders toggle input with a label. Clicking the label also toggles the toggle. Needs `:js_active_var` that controls toggle state. Set this outside this component with `x-data="{ : }"` Can be configured to always show a description of the field / help text `:help_text`, or only show the help text when the toggle is activated `:show_help_text_only_when_active?`. """ def toggle_field(assigns) do ~H"""
{@label}

{@help_text}

""" end def settings_tiles(assigns) do ~H"""
{render_slot(@inner_block)}
""" end attr :docs, :string, default: nil slot :inner_block, required: true slot :title, required: true slot :subtitle, required: false attr :feature_mod, :atom, default: nil attr :feature_toggle?, :boolean, default: false attr :current_team, :any, default: nil attr :current_user, :any, default: nil attr :site, :any, default: nil attr :conn, :any, default: nil attr :show_content?, :boolean, default: true def tile(assigns) do ~H"""
<.title> {render_slot(@title)} <.docs_info :if={@docs} slug={@docs} class="absolute top-4 right-4 z-1" />
{render_slot(@subtitle)}
<.live_component :if={@feature_toggle?} module={PlausibleWeb.Components.Site.Feature.ToggleLive} id={"feature-toggle-#{@site.id}-#{@feature_mod}"} site={@site} feature_mod={@feature_mod} current_user={@current_user} />
<%= if @feature_mod do %>
{render_slot(@inner_block)}
<% else %>
{render_slot(@inner_block)}
<% end %>
""" end attr(:sticky?, :boolean, default: true) attr(:enabled?, :boolean, default: true) attr(:centered?, :boolean, default: false) attr(:testid, :string, default: nil) slot(:inner_block, required: true) slot(:tooltip_content, required: true) def tooltip(assigns) do wrapper_data = if assigns[:sticky?], do: "{sticky: false, hovered: false}", else: "{hovered: false}" show_inner = if assigns[:sticky?], do: "hovered || sticky", else: "hovered" base_classes = [ "absolute", "pb-2", "top-0", "-translate-y-full", "z-[1000]", "sm:max-w-64", "w-max" ] tooltip_position_classes = if assigns.centered? do base_classes ++ ["left-1/2", "-translate-x-1/2"] else base_classes end assigns = assign(assigns, wrapper_data: wrapper_data, show_inner: show_inner, tooltip_position_classes: tooltip_position_classes ) if assigns.enabled? do ~H"""
{render_slot(@tooltip_content)}
{render_slot(@inner_block)}
""" else ~H"{render_slot(@inner_block)}" end end slot :inner_block, required: true def accordion_menu(assigns) do ~H"""
{render_slot(@inner_block)}
""" end attr :id, :string, required: true attr :title, :string, required: true attr :open_by_default, :boolean, default: false attr :title_class, :string, default: "" slot :inner_block, required: true def accordion_item(assigns) do ~H"""
{render_slot(@inner_block)}
""" end attr(:rest, :global, include: ~w(fill stroke stroke-width class)) attr(:name, :atom, required: true) attr(:outline, :boolean, default: true) attr(:solid, :boolean, default: false) attr(:mini, :boolean, default: false) def dynamic_icon(assigns) do case assigns.name do :tag -> PlausibleWeb.Components.Icons.tag_icon(%{class: assigns.rest[:class]}) :subscription -> PlausibleWeb.Components.Icons.subscription_icon(%{class: assigns.rest[:class]}) :api_keys -> PlausibleWeb.Components.Icons.key_icon(%{class: assigns.rest[:class]}) icon_name -> apply(Heroicons, icon_name, [assigns]) end end attr(:width, :integer, default: 100) attr(:height, :integer, default: 100) attr(:id, :string, default: "shuttle") defp icon_class(link_assigns) do classes = List.wrap(link_assigns[:class]) |> Enum.join(" ") cond do String.contains?(classes, "text-sm") -> ["size-3.5"] String.contains?(classes, "text-xs") -> ["size-3"] true -> ["size-4"] end end slot(:item, required: true) def focus_list(assigns) do ~H"""
  1. {render_slot(item)}
""" end slot :title slot :subtitle slot :inner_block, required: true slot :footer attr :padding?, :boolean, default: true attr :rest, :global def focus_box(assigns) do ~H"""
<.title :if={@title != []}> {render_slot(@title)}
{render_slot(@subtitle)}
{render_slot(@inner_block)}
{render_slot(@inner_block)}
{render_slot(@footer)}
""" end attr :rest, :global attr :width, :string, default: "min-w-full" attr :rows, :list, default: [] attr :row_attrs, :any, default: nil slot :thead, required: false slot :tbody, required: true slot :inner_block, required: false def table(assigns) do ~H""" tbody>tr:first-child>td]:pt-0 [&>tbody>tr:last-child>td]:pb-0", @width ]} {@rest} > {render_slot(@thead)} {render_slot(@tbody, item)} {render_slot(@inner_block)}
""" end slot :inner_block, required: true attr :truncate, :boolean, default: false attr :max_width, :string, default: "" attr :height, :string, default: "" attr :class, :string, default: "" attr :actions, :boolean, default: nil attr :hide_on_mobile, :boolean, default: nil attr :rest, :global def td(assigns) do max_width = cond do assigns.max_width != "" -> assigns.max_width assigns.truncate -> "max-w-sm" true -> "" end assigns = assign(assigns, max_width: max_width) ~H"""
{render_slot(@inner_block)}
{render_slot(@inner_block)}
""" end slot :inner_block, required: true attr :invisible, :boolean, default: false attr :hide_on_mobile, :boolean, default: nil def th(assigns) do class = if assigns[:invisible] do "invisible" else "px-6 first:pl-0 last:pr-0 py-3 text-left text-sm font-semibold" end assigns = assign(assigns, class: class) ~H""" {render_slot(@inner_block)} """ end attr :set_to, :boolean, default: false attr :disabled?, :boolean, default: false slot :inner_block, required: true def toggle_submit(assigns) do ~H"""
{render_slot(@inner_block)}
""" end attr :href, :string, required: false attr :icon_name, :atom, required: false attr :theme, :string, default: "default" attr :class, :string, default: "" attr :icon_class, :string, default: "" attr :size, :string, default: "4" attr :rest, :global, include: ~w(method disabled) slot :inner_block, required: false def icon_button(assigns) do icon_source = case {assigns.inner_block, assigns[:icon_name]} do {[], nil} -> raise ArgumentError, "Either `icon_name` attribute or icon inner block must be provided" {[_ | _], icon_name} when not is_nil(icon_name) -> raise ArgumentError, "Only one of `icon_name` and icon inner block must be provided" {[_ | _], nil} -> :inner_block {[], icon_name} when not is_nil(icon_name) -> :icon_name end text = case assigns.theme do "default" -> %{ light: "text-indigo-700", light_hover: "text-indigo-600", dark: "text-indigo-500", dark_hover: "text-indigo-400" } "danger" -> %{ light: "text-red-700", light_hover: "text-red-500", dark: "text-red-500", dark_hover: "text-red-400" } _ -> raise ArgumentError, "Invalid `theme` provided" end button_class = [ "group/button", "w-fit h-fit", "p-2", "hover:bg-gray-100", "dark:hover:bg-gray-800", "rounded-md", "transition-colors", "duration-150", assigns.class ] icon_class = [ "size-#{assigns.size}", text.light, "group-hover/button:" <> text.light_hover, "dark:" <> text.dark, "dark:group-hover/button:" <> text.dark_hover, "transition-colors", "duration-150", "group-disabled/button:opacity-50", assigns.icon_class ] assigns = assigns |> assign(:icon_source, icon_source) |> assign(:button_class, button_class) |> assign(:icon_class, icon_class) if assigns[:href] do ~H""" <.unstyled_link href={@href} class={@button_class} {@rest}> {render_slot(@inner_block, @icon_class)} <.dynamic_icon :if={@icon_source == :icon_name} name={@icon_name} class={@icon_class} /> """ else ~H""" """ end end attr :href, :string, default: nil attr :class, :string, default: "" attr :rest, :global, include: ~w(method disabled) def edit_button(assigns) do ~H""" <.icon_button :let={icon_class} href={@href} class={@class} {@rest}> """ end attr :href, :string, default: nil attr :class, :string, default: "" attr :icon, :atom, default: :trash attr :rest, :global, include: ~w(method disabled) def delete_button(assigns) do ~H""" <.icon_button icon_name={@icon} theme="danger" class={@class} href={@href} {@rest} /> """ end attr :filter_text, :string, default: "" attr :placeholder, :string, default: "" attr :filtering_enabled?, :boolean, default: true slot :inner_block, required: false def filter_bar(assigns) do ~H"""
{render_slot(@inner_block)}
""" end slot :inner_block, required: true attr :class, :any, default: nil attr :rest, :global def h2(assigns) do ~H"""

{render_slot(@inner_block)}

""" end slot :inner_block, required: true attr :class, :any, default: nil def title(assigns) do ~H""" <.h2 class={["text-lg font-medium text-gray-900 dark:text-gray-100 leading-7", @class]}> {render_slot(@inner_block)} """ end alias Phoenix.LiveView.JS slot :inner_block, required: true def disclosure(assigns) do ~H"""
{render_slot(@inner_block)}
""" end slot :inner_block, required: true attr :class, :any, default: nil def disclosure_button(assigns) do ~H""" """ end slot :inner_block, required: true def disclosure_panel(assigns) do ~H""" """ end slot :inner_block, required: true def highlighted(assigns) do ~H""" {render_slot(@inner_block)} """ end attr(:class, :string, default: "") attr(:color, :atom, default: :gray, values: [:gray, :indigo, :yellow, :green, :red]) attr(:rest, :global) slot(:inner_block, required: true) def pill(assigns) do assigns = assign(assigns, :color_classes, get_pill_color_classes(assigns.color)) ~H""" {render_slot(@inner_block)} """ end attr(:plan, :string, required: true) attr(:color, :atom, default: :indigo, values: [:gray, :indigo, :yellow, :green, :red]) attr(:rest, :global) def upgrade_pill(assigns) do ~H""" <.link href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} class="inline-block" {@rest} > <.pill color={@color} class="gap-x-1"> <.diamond_icon class="size-3.5 [&_path]:stroke-2" /> {@plan} """ end defp get_pill_color_classes(:gray) do "bg-gray-100 text-gray-800 dark:bg-gray-750 dark:text-gray-200" end defp get_pill_color_classes(:indigo) do "bg-indigo-100/60 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-300" end defp get_pill_color_classes(:yellow) do "bg-yellow-100/80 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" end defp get_pill_color_classes(:green) do "bg-green-100/70 text-green-800 dark:bg-green-900/40 dark:text-green-300" end defp get_pill_color_classes(:red) do "bg-red-100/60 text-red-700 dark:bg-red-800/40 dark:text-red-300" end end