tomekowal

tomekowal

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 API Reference — ExEffectiveBootstrap v0.1.17

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 GitHub - dczombera/html_builder: A small Elixir HTML library that uses its own DSL for easily creating HTML tags · GitHub

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.

First 6 of 6 Posts Switch mode

sfusato

sfusato

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).

tomekowal

tomekowal OP

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: Proposal: conditionally setting css classes · Issue #276 · phoenixframework/phoenix_html · GitHub

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:

Brainiac

Brainiac

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.

tomekowal

tomekowal OP

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:

abitdodgy

abitdodgy

I started working on ex_component unaware of this thread. My idea was to be able to create standardised, reusable components and keep them consistent in different related projects. Here is an example that uses the library to generate all possible Bootstrap grid HTML.

defmodule ExBs.Layout do
  import ExComponent
  @break_points ExBs.Config.get_config(:break_points)
  @grid_size ExBs.Config.get_config(:grid_size)

  @container_variants for break_point <- @break_points ++ [:fluid],
                          into: [],
                          do: {break_point, class: break_point, prefix: true, merge: false}

  @col_grid Enum.map(@grid_size, &String.to_atom("#{&1}"))

  @col_variants for variant <- @break_points ++ @col_grid ++ [:auto],
                    into: [],
                    do: {variant, class: variant, prefix: true, merge: false, option: true}

  @col_options [order: [class: "order"]]

  defcontenttag(:container, tag: :div, class: "container", variants: @container_variants)
  defcontenttag(:row, tag: :div, class: "row")
  defcontenttag(:col, tag: :div, class: "col", variants: @col_variants, options: @col_options)
end

The lets you do:

Layout.container("Container!") #=> ~s(<div class="container">Container!</div>)
Layout.container(:fluid, "Container!") #=> ~s(<div class="container-fluid">Container!</div>)

Layout.row("Row!") #=> ~s(<div class="row">Row!</div>)

Layout.col("Col!") #=> ~s(<div class="col">Col!</div>)
Layout.col(:sm, "Col!") #=> ~s(<div class="col-sm">Col!</div>)
Layout.col("Col!", md: 6) #=> ~s(<div class="col col-md-6">Col!</div>)
Layout.col(:auto, "Col!", md: 6) #=> ~s(<div class="col-auto col-md-6">Col!</div>)
Layout.col(12, "Col!", md: 6) #=> ~s(<div class="col-12 col-md-6">Col!</div>)

Layout.col(6, "Col!", order: 2) #=> ~s(<div class="col-6 order-2">Col!</div>)

You can checkout out ExBs which was written on top of it as a proof of concept. The idea is to produce a lib specific to our company projects and not let the HTML layer turn into the wild west.

Curios to know your thoughts!

Just an edit, here’s what I came up with for the tables:

defmodule ExBs.Content.Tables do
  import ExComponent

  @theme_colors ExBs.Config.get_config(:theme_colors)

  @table_types ~w[dark striped bordered borderless hover sm]a

  @table_variants for variant <- @theme_colors ++ @table_types,
                      into: [],
                      do: {variant, class: variant, prefix: true, option: true}

  defcontenttag(:table, tag: :table, class: "table", variants: @table_variants)
end

Which gets you:

Tables.table("...") #=> ~s(<table class="table">...</table>)
Tables.table(:striped, "...") #=> ~s(<table class="table table-striped">...</table>)
Tables.table("...", striped: true) #=> ~s(<table class="table table-striped">...</table>)
dorgan

dorgan

For my use cases I have this simple helper:

def attr_list(attrs, joiner \\ " ") when is_list(attrs) do
  Enum.reduce(attrs, [], fn
    attr, acc when is_binary(attr) -> acc ++ [attr]
    {attr, true}, acc when is_binary(attr) -> acc ++ [attr]
    {attr, predicate}, acc when is_binary(attr) and  is_function(predicate) ->
      if predicate.(), do: acc ++ [attr], else: acc
    fun, acc when is_function(fun) -> acc ++ [fun.()]
    _, acc -> acc
  end)
  |> Enum.join(joiner)
end

iex> attr_list(["foo", nil, {"active", user.active}])
"foo active"

I also use it for stimulus controllers:

<article class="post", data-controller="<%= attr_list(["post", {"content-warning", post.nsfw?}]) %>">

I reuse common elements with helpers, too:

def sidebar(opts, do: content) do
  sidebar(content, opts)
end
def sidebar(contents, opts) do
  Keyword.merge(opts, id: "sidebar")
  content_tag(:nav, contents, opts)
end
<%= sidebar do %>
  <ul>...</ul>
<% end %>

For this last case the ComposableHtml thing may be less verbose and allow for more complex composition, but so far this has served me well :slight_smile:

— All posts loaded —

Where Next? Top

Trending in Questions Top

stjefim
Hello! Suppose you are building workflow (order / task / payment) processing system with the following requirements: Each workflow con...
New
jonnycharles
I’m in search of an Elixir library that offers PDF generation capabilities similar to Ruby’s Prawn. While there have been discussions abo...
New
spammy
I’m looking to build a personal workflow to quickly deploy web applications written in elixir/phoenix, for local consumption (ie not on t...
New
dli
Before I dive in myself, did anyone successfully sprinkle Hologram into their existing LiveView app? Looking for hints regarding: Addi...
New
roeland
Kia ora, We have been using elixir-google-api to connect to Google Drive. However, with the updates to Tesla due to CVEs this is now bro...
New
bottlenecked
Hi all, I wanted to ask how the community is dealing with post-release steps. Today we have Ecto migrations, which make sure that the db...
New
rahultumpala
Hello, I have an Elixir backend that implements a custom protocol over TCP. I want to load test the backend and assess the performance o...
New

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New
ausimian
Emily is an Elixir library that runs Nx computations on Apple’s MLX. Install it as the default Nx backend and Nx, defn, Axon, Nx.Serving,...
New
type1fool
I just stumbled on a newly redesigned elixir-lang.org. :tada: It looks like @Software_Mansion did the work, and I think it is generally a...
New

We're in Beta

About us Mission Statement