LiveView 0.18 undefined attribute "href" for component Phoenix.Component.dynamic_tag/1

I am building a Button component with LiveView 0.18.3 that should render either a <button> or an <a> tag depending on whether a url attribute is present:

defmodule AppWeb.Sirius do
  use Phoenix.Component

  attr :url, :string, default: nil

  def button(assigns) do
    assigns =
      if assigns[:url] != nil do
        assign(assigns, :tag, "a")
      else
        assign(assigns, :tag, "button")
      end

    ~H"""
    <.dynamic_tag name={@tag} href={@url} class="Sirius-Button">
      <%= render_slot(@inner_block) %>
    </.dynamic_tag>
    """
  end
end

The compiler warns me about href being an undefined attribute:

Compiling 1 file (.ex)
  warning: undefined attribute "href" for component Phoenix.Component.dynamic_tag/1

Looking at the LiveView code, href is indeed not in the globals list triggering the warning (phoenix_live_view/lib/phoenix_component/declarative.ex:1090).

Aside from the warning, the component does work as expected, either rendering out a <button class="Sirius-Button">...</button> when no url is present, or an <a href="..." class="Sirius-Button>...</a> when url is present.

Any ideas what can I could do to the get rid of the compiler warning?

Update: made some progress today re-defining a “custom” version of the dynamic_tag component included with LiveView 0.18.3 and injecting href into globals. Would be much nicer if we could pass additional includes for :rest to the dynamic_tag component that ships with LiveView :wink:

defmodule AppWeb.Components.Sirius.DynamicTag do
  use Phoenix.Component

  @doc type: :component
  attr :name, :string, required: true, doc: "The name of the tag, such as `div`."

  attr :rest, :global,
    include: ~w(href),
    doc: """
    Additional HTML attributes to add to the tag, ensuring proper escaping.
    """

  def dynamic_tag(%{name: name, rest: rest} = assigns) do
    tag_name = to_string(name)

    tag =
      case Phoenix.HTML.html_escape(tag_name) do
        {:safe, ^tag_name} ->
          tag_name

        {:safe, _escaped} ->
          raise ArgumentError,
                "expected dynamic_tag name to be safe HTML, got: #{inspect(tag_name)}"
      end

    assigns =
      assigns
      |> assign(:tag, tag)
      |> assign(:escaped_attrs, Phoenix.HTML.attributes_escape(rest))

    if assigns.inner_block != [] do
      ~H"""
      <%= {:safe, [?<, @tag]} %><%= @escaped_attrs %><%= {:safe, [?>]} %><%= render_slot(@inner_block) %><%= {:safe, [?<, ?/, @tag, ?>]} %>
      """
    else
      ~H"""
      <%= {:safe, [?<, @tag]} %><%= @escaped_attrs %><%= {:safe, [?/, ?>]} %>
      """
    end
  end
end

I am not sure if you need dynamic tag.

Sample code - you can some code which modifies assigns to append “Sirius-Button” to class attribute.

defmodule AppWeb.Sirius do
  use Phoenix.Component

  attr :url, :string, default: nil

  def button(assigns) do
      attributes = assigns_to_attributes(assigns, [:attribute_to_be_excluded, :attribute2_to_be_excluded])
       assigns = assign(assigns, :attributes, attributes)
      if assigns[:url] != nil do
        ~H"""
        <.link {@attributes}>
          <%= render_slot(@inner_block) %>
        </.link>
        """
      else
        """
        <button {@attributes}>
          <%= render_slot(@inner_block) %>
        </button>
        """
      end
  end
end
1 Like

@kartheek yes that can work for simple components, but the ~H markup (outside from the simplified example I posted) is a lot more involved, and duplicating the entire embedded heex template with the only difference being the tag_name make it a lot harder to maintain. Because then I need to remember to change markup in multiple embedded templates …

You can completely extract the “switching” logic to a seperate component keeping all the complexity you don’t want to copy in the parent.

def button(assigns) do
  assigns =
    if assigns[:url] != nil do
      assign(assigns, :tag, "a")
    else
      assign(assigns, :tag, "button")
    end

  ~H"""
  <.link_or_button name={@tag} href={@url} class="Sirius-Button">
    <%= render_slot(@inner_block) %>
  </.link_or_button>
  """
end

attr :rest, :global, include: ~w(href)
slot :inner_block, required: true
defp link_or_button(assigns) do
  ~H"""
  <%= if Map.has_key?(@rest, :href) && @rest.href do %>
    <a {@rest}><%= render_slot(@inner_block) %></a>
  <% else %>
    <button {@rest}><%= render_slot(@inner_block) %></a>
  <% end %>
  """
end
1 Like

If you don’t want to duplicate inner_block of the component, you can wrap it with another component like LostKobrakai’s example.

Code in second post of yours is a heex wrapper over eex template. I would prefer to stay in heex and leverage components like link/1 than dynamically generating parts of html tag. Initially when migrating to heex, I was trying some things with eex and generating tags - but now everything is mostly heex.