Would you find ComposableHtml library useful?

I’ve recently found myself writing many Phoenix templates and wanted to streamline my experience.

I often want to set some defaults, e.g. in Bootstrap a table usually has a class “table” <table class="table"> But then I’d like to take the default and specify that this time the table should be striped.

I can’t use Phoenix.HTML.Tag.content_tag in this case. Everything in Phoenix.HTML produces final io lists that can’t be modified later. I figured I need some middleware.

defmodule ComposableHtml.Tag do
  defstruct [:tag_name, :content, :attrs]
end

This way I can define a tag and extend it:

table = %Tag{tag_name: :table, content: ..., attrs: [class: "table"]}
...
table_striped = tag |> update_attr(:class, &"#{&1} table-striped")

I saw the same problem in the Elm community https://www.youtube.com/watch?v=PDyWP-0H4Zo
The talk concludes that a data structure that allows overwriting its fields is a nice API.

I’d like to turn my helpers into a lib generic enough to be a building block for other libraries like https://hexdocs.pm/ex_effective_bootstrap/0.1.17/api-reference.html

The idea is also to be able to use the ComposableHtml.Tag directly in the templates by implementing Phoenix.HTML.Safe protocol:

defmodule ComposableHtml.Tag do
  ...
  def to_phoenix_html(%Tag{} = tag) do
    if tag.content do
      PhoenixTag.content_tag(tag.tag_name, to_phoenix_html(tag.content), tag.attrs)
    else
      PhoenixTag.tag(tag.tag_name, tag.attrs)
    end
  end

  def to_phoenix_html(list_of_tags) when is_list(list_of_tags) do
    Enum.map(list_of_tags, &to_phoenix_html/1)
  end

  # We pass the data in the `content_tag`, so we don't have to escape here
  def to_phoenix_html(data), do: data
end

defimpl Phoenix.HTML.Safe, for: ComposableHtml.Tag do
  alias ComposableHtml.Tag

  def to_iodata(data) do
    {:safe, data} = Tag.to_phoenix_html(data)
    data
  end
end

I’ve found existing HTML builder that uses macros https://github.com/dczombera/html_builder

But it is not composable.

My questions are:

  1. Would you find such a library useful?
  2. If it is useful, are there any existing libraries for composing HTML that I missed?
  3. If I am going to create a new library, could you help me with API design?

Ad.3 The with_* approach from Brian’s talk seems compelling but it is more specific than I like. E.g. in the talk there is |> withRole Danger |> withFormat Outline and in Bootstrap they would both translate to a class <button class="btn btn-outline btn-danger">

I am not sure what would be some proper names for functions that append to an attribute (e.g. changing class="table" to class="table table-striped"), and what names to use for functions overwriting the attributes.

3 Likes

There’s this new library that has just been announced which seems to be doing what you want (and more than that): x_component. If there’s something that I miss from Vue/React, it’s this kind of component composability. Looks very powerful (also check the benchmarks).

Then there’s also Surface which is aimed directly at Liveview (discussion).

4 Likes

Thank you! Those are very useful links!

Both libraries emphasize composability which makes me think I am on a good track :slight_smile:

However, the scope of what I am thinking about is much much smaller. I don’t want to define new templating language.
Let me give a before and after example.

Let’s say I want to define a table helper for bootstrap:

In my view_helpers.ex I need this:

def bootstrap_table(content) do
  content_tag(:table, content, class: "table")
end

In the template, I can use <%= bootstrap_table(@content) %>

But then in some other place, I need “table-striped” so I modify the herlper

def bootstrap_table(content, opts \\ []) do
  additional_classes = Keyword.get(opts, :additional_classes, "")
  content_tag(:table, content, class: "table #{additional_classes}")
end

But then in yet another place I need to set border, so I modify the helper:

def bootstrap_table(content, opts \\ []) do
  additional_classes = Keyword.get(opts, :additional_classes, "")
  border = Keyword.get(opts, :border, "0")
  content_tag(:table, content, class: "table #{additional_classes}", border: border)
end

And before I realize, I add cellpadding, cellspacing and finally all attributes supported by table tag.

Let’s say that instead, I use ComposableHtml.Tag

def bootstrap_table(content) do
  %Tag{tag_name: :table, content: content, attrs: [class: "table"]}
end

The usage in the template would look the same <%= bootstrap_table(content) %> provided that %Tag{} struct implements Phoenix.HTML.Safe.

But now, if I need to add a class, I can do it differently:

def striped(table_tag) do
  ComposableHtml.add_class(table_tag, "table-striped")
end

def bordered(table_tag) do
  ComposableHtml.add_attribute(:border, "1")
end

Now in the template, I can do <%= bootstrap_table(content) |> striped() |> bordered() %>

Another nice example is what @wojtekmach wants here: https://github.com/phoenixframework/phoenix_html/issues/276

Let’s say you want to conditionally add active class based on current path:

<%= link_to("Home", "/", class: (["button"] ++ (if path == "/", do: ["active"], else: [])) %> 

If link_to returned ComposableHtml.Tag, you could write:

<%= link_to("Home", "/", class: "button") |> maybe_add_active_class(path, "/") %>

and define a helper:

def maybe_add_active_class(tag, path, active_path) do
  if path == active_path do
    ComposableHtml.add_class(tag, "active")
  else
    tag
  end
end

I’d like the library to make HTML composable in a similar way that Ecto.Query makes SQL composable :slight_smile:

3 Likes

It seems like a natural extension of Phoenix.HTML's template functions. It would make more sense for the composability to be an in-built feature of the framework instead of a separate library.

1 Like

I think I would start with a separate library to test how it feels and what would be a nice API. If it catches on and Crhis likes the idea, it could become part of Phoenix.HTML :slight_smile:

2 Likes