Current section

Files

Jump to
phoenix_duskmoon lib phoenix_duskmoon component action button.ex
Raw

lib/phoenix_duskmoon/component/action/button.ex

defmodule PhoenixDuskmoon.Component.Action.Button do
@moduledoc """
Button component using el-dm-button custom element.
Provides three button modes:
- Standard button with variants and sizes
- Button with confirmation dialog
- Noise effect button (decorative)
## Examples
<.dm_btn>Click me</.dm_btn>
<.dm_btn variant="primary" size="lg">Primary</.dm_btn>
<.dm_btn variant="error" confirm="Are you sure?">Delete</.dm_btn>
<.dm_btn noise content="SUBMIT">Submit</.dm_btn>
"""
use Phoenix.Component
# The el-dm-button custom element only supports: primary, secondary, tertiary,
# ghost, outline. For other variants (info, success, warning, error, accent, link)
# we map to a supported variant and override colors via inline CSS custom properties.
@variant_map %{
"accent" => "tertiary",
"link" => "ghost"
}
@color_override_variants ~w(info success warning error)
@submit_onclick [
"if (!this.hasAttribute('disabled')) {",
"const formId = this.getAttribute('form');",
"const form = formId ? document.getElementById(formId) : this.closest('form');",
"if (form) {",
"const submitter = document.createElement('button');",
"submitter.type = 'submit';",
"submitter.hidden = true;",
"if (this.hasAttribute('name')) submitter.name = this.getAttribute('name');",
"if (this.hasAttribute('value')) submitter.value = this.getAttribute('value');",
"form.appendChild(submitter);",
"form.requestSubmit(submitter);",
"submitter.remove();",
"}",
"}"
]
|> Enum.join(" ")
defp map_variant(nil), do: nil
defp map_variant(v) when is_map_key(@variant_map, v), do: @variant_map[v]
defp map_variant(v) when v in @color_override_variants, do: "primary"
defp map_variant(v), do: v
defp variant_style(v) when v in @color_override_variants do
"--color-primary: var(--color-#{v}); --color-primary-content: var(--color-#{v}-content);"
end
defp variant_style(_), do: nil
@doc """
Generates a button.
## Examples
<.dm_btn id="show-btn">Show</.dm_btn>
<.dm_btn variant="primary" size="lg">Primary Button</.dm_btn>
<.dm_btn navigate="/dashboard" variant="ghost">Dashboard</.dm_btn>
<.dm_btn confirm="Are you sure?" confirm_title="Confirm Action">Delete</.dm_btn>
"""
@doc type: :component
attr(:id, :any, default: nil, doc: "HTML id attribute")
attr(:class, :any, default: nil, doc: "Additional CSS classes")
attr(:variant, :string,
default: nil,
values: [
nil,
"primary",
"secondary",
"accent",
"info",
"success",
"warning",
"error",
"ghost",
"link",
"outline"
],
doc: "Button color variant"
)
attr(:size, :string,
default: nil,
values: [nil, "xs", "sm", "md", "lg"],
doc: "Button size"
)
attr(:shape, :string,
default: nil,
values: [nil, "square", "circle"],
doc: "Button shape"
)
attr(:loading, :boolean, default: false, doc: "Show loading state")
attr(:disabled, :boolean, default: false, doc: "Disable the button")
attr(:navigate, :string,
default: nil,
doc: "Navigates from a LiveView to a new LiveView"
)
attr(:patch, :string,
default: nil,
doc: "Patches the current LiveView"
)
attr(:href, :any,
default: nil,
doc: "Uses traditional browser navigation to the new location"
)
attr(:replace, :boolean,
default: false,
doc: "Replace browser history when using navigate or patch"
)
# Noise button attributes
attr(:noise, :boolean, default: false, doc: "Use noise effect button style")
attr(:content, :string, default: "", doc: "Content text for noise button")
# Confirm dialog attributes
attr(:confirm, :string, default: "", doc: "Confirmation message (enables confirm dialog)")
attr(:confirm_title, :string, default: "", doc: "Title for confirmation dialog")
attr(:confirm_text, :string, default: "Yes", doc: "Text for the confirm button in dialog")
attr(:cancel_text, :string, default: "Cancel", doc: "Text for the cancel button in dialog")
attr(:confirm_class, :any, default: nil, doc: "CSS class for confirm button")
attr(:cancel_class, :any, default: nil, doc: "CSS class for cancel button")
attr(:show_cancel_action, :boolean, default: true, doc: "Show cancel button in dialog")
attr(:confirm_dialog_label, :string,
default: "Confirmation",
doc: "Accessible fallback label for confirm dialog when no title is set (i18n)"
)
attr(:rest, :global,
include: ~w(phx-click phx-target phx-value-id phx-disable-with name value type form),
doc: "Additional HTML attributes"
)
slot(:inner_block,
required: true,
doc: "Button content"
)
slot(:prefix,
required: false,
doc: "Content before button text (e.g., icon)"
)
slot(:suffix,
required: false,
doc: "Content after button text (e.g., icon)"
)
slot(:confirm_action,
required: false,
doc: "Custom confirm action button content"
)
@spec dm_btn(map()) :: Phoenix.LiveView.Rendered.t()
def dm_btn(%{confirm: confirm} = assigns) when confirm != "" do
assigns =
assigns
|> assign(:id, assigns.id || "btn-#{System.unique_integer([:positive])}")
|> assign(:confirm_rest, Map.put(assigns.rest, "type", "submit"))
|> assign_button_element()
~H"""
<el-dm-button
id={@id}
variant={@el_variant}
size={@size}
shape={@shape}
loading={@loading}
disabled={@disabled}
aria-disabled={@disabled && "true"}
aria-busy={@loading && "true"}
class={@class}
style={@el_style}
onclick={"document.getElementById('confirm-dialog-#{@id}').show()"}
>
<span :for={prefix <- @prefix} slot="prefix">{render_slot(prefix)}</span>
{render_slot(@inner_block)}
<span :for={suffix <- @suffix} slot="suffix">{render_slot(suffix)}</span>
</el-dm-button>
<el-dm-dialog
id={"confirm-dialog-#{@id}"}
role="dialog"
aria-modal="true"
aria-labelledby={@confirm_title != "" && "confirm-dialog-#{@id}-title"}
aria-label={@confirm_title == "" && @confirm_dialog_label}
>
<span id={"confirm-dialog-#{@id}-title"} slot="header" :if={@confirm_title != ""}>
{@confirm_title}
</span>
<p>{@confirm}</p>
<div slot="footer">
<template :if={@confirm_action != []}>
{render_slot(@confirm_action)}
</template>
<form :if={@confirm_action == []} method="dialog">
<el-dm-button variant="primary" class={@confirm_class} {@confirm_rest}>
{@confirm_text}
</el-dm-button>
</form>
<form :if={@show_cancel_action} method="dialog">
<el-dm-button variant="ghost" class={@cancel_class} type="submit">
{@cancel_text}
</el-dm-button>
</form>
</div>
</el-dm-dialog>
"""
end
def dm_btn(%{noise: true} = assigns) do
assigns =
assigns
|> assign_new(:id, fn -> nil end)
|> assign_new(:inner_block, fn -> [] end)
~H"""
<button
type="button"
id={@id}
class={["btn-noise", @class]}
data-content={@content}
aria-label={@content}
{@rest}
>
<span aria-hidden="true"><i :for={_ <- 1..72} /></span>
<span :if={@inner_block != []} class="sr-only">{render_slot(@inner_block)}</span>
</button>
"""
end
def dm_btn(%{} = assigns) do
link? =
is_binary(assigns.navigate) || is_binary(assigns.patch) ||
(!is_nil(assigns.href) && assigns.href != "#")
assigns =
assigns
|> then(fn assigns ->
if link?, do: assign_button_style(assigns), else: assign_button_element(assigns)
end)
|> assign(:link?, link?)
~H"""
<.button_link :if={@link?} {assigns} />
<.button_element :if={!@link?} {assigns} />
"""
end
defp assign_button_style(assigns) do
assigns
|> assign_new(:id, fn -> nil end)
|> assign(:el_variant, map_variant(assigns.variant))
|> assign(:el_style, variant_style(assigns.variant))
end
defp assign_button_element(assigns) do
submit_onclick = submit_onclick(assigns.rest)
assigns
|> assign(:rest, rest_without_onclick(assigns.rest, submit_onclick))
|> assign(:submit_onclick, submit_onclick)
|> assign_button_style()
end
attr(:id, :any, default: nil)
attr(:el_variant, :string, default: nil)
attr(:size, :string, default: nil)
attr(:shape, :string, default: nil)
attr(:loading, :boolean, default: false)
attr(:disabled, :boolean, default: false)
attr(:class, :any, default: nil)
attr(:el_style, :string, default: nil)
attr(:submit_onclick, :string, default: nil)
attr(:rest, :global)
slot(:inner_block)
slot(:prefix)
slot(:suffix)
defp button_element(assigns) do
~H"""
<el-dm-button
id={@id}
variant={@el_variant}
size={@size}
shape={@shape}
loading={@loading}
disabled={@disabled}
aria-disabled={@disabled && "true"}
aria-busy={@loading && "true"}
class={@class}
style={@el_style}
phx-hook={if @rest["phx-click"], do: "WebComponentHook"}
onclick={@submit_onclick}
{@rest}
>
<span :for={prefix <- @prefix} slot="prefix">{render_slot(prefix)}</span>
{render_slot(@inner_block)}
<span :for={suffix <- @suffix} slot="suffix">{render_slot(suffix)}</span>
</el-dm-button>
"""
end
attr(:navigate, :string, default: nil)
attr(:patch, :string, default: nil)
attr(:href, :any, default: nil)
attr(:replace, :boolean, default: false)
attr(:id, :any, default: nil)
attr(:el_variant, :string, default: nil)
attr(:size, :string, default: nil)
attr(:shape, :string, default: nil)
attr(:loading, :boolean, default: false)
attr(:disabled, :boolean, default: false)
attr(:class, :any, default: nil)
attr(:el_style, :string, default: nil)
attr(:rest, :global)
slot(:inner_block)
slot(:prefix)
slot(:suffix)
defp button_link(assigns) do
assigns =
assigns
|> assign(:button_class, [
"btn",
"btn-#{assigns.el_variant || "primary"}",
assigns.size && "btn-#{assigns.size}",
assigns.shape && "btn-#{assigns.shape}",
assigns.loading && "btn-loading",
assigns.disabled && "opacity-50",
assigns.disabled && "cursor-not-allowed",
assigns.disabled && "pointer-events-none",
assigns.class
])
|> assign(:inert_rest, inert_link_rest(assigns.rest))
~H"""
<.link
:if={!@disabled && !@loading}
navigate={@navigate}
patch={@patch}
href={@href}
replace={@replace}
id={@id}
class={@button_class}
style={@el_style}
{@rest}
>
<span :for={prefix <- @prefix} class="inline-flex items-center">{render_slot(prefix)}</span>
{render_slot(@inner_block)}
<span :for={suffix <- @suffix} class="inline-flex items-center">{render_slot(suffix)}</span>
</.link>
<a
:if={@disabled || @loading}
id={@id}
role="link"
class={@button_class}
style={@el_style}
aria-disabled="true"
aria-busy={@loading && "true"}
{@inert_rest}
>
<span :for={prefix <- @prefix} class="inline-flex items-center">{render_slot(prefix)}</span>
{render_slot(@inner_block)}
<span :for={suffix <- @suffix} class="inline-flex items-center">{render_slot(suffix)}</span>
</a>
"""
end
defp inert_link_rest(rest) do
Map.reject(rest, fn {key, _value} ->
key = to_string(key)
key == "tabindex" || String.starts_with?(key, ["on", "phx-"])
end)
end
# WORKAROUND(upstream): duskmoon-dev/duskmoon-elements#66
defp submit_onclick(rest) do
if rest_attr(rest, "type") == "submit" do
merge_onclick(rest_attr(rest, "onclick"), @submit_onclick)
end
end
defp rest_attr(rest, "type"), do: Map.get(rest, "type") || Map.get(rest, :type)
defp rest_attr(rest, "onclick"), do: Map.get(rest, "onclick") || Map.get(rest, :onclick)
defp merge_onclick(nil, submit_onclick), do: submit_onclick
defp merge_onclick("", submit_onclick), do: submit_onclick
defp merge_onclick(onclick, submit_onclick), do: "#{onclick}; #{submit_onclick}"
defp rest_without_onclick(rest, nil), do: rest
defp rest_without_onclick(rest, _submit_onclick) do
rest
|> Map.delete("onclick")
|> Map.delete(:onclick)
end
end