Phoenix.LiveView.ColocatedCSS behaviour (Phoenix LiveView v1.2.0-rc.2)

Copy Markdown View Source

Building blocks for a special HEEx :type that extracts any CSS styles from a colocated <style> tag at compile time.

To actually use ColocatedCSS, you must define a module including use Phoenix.LiveView.ColocatedCSS and implement the ColocatedCSS behaviour.

Note: To use ColocatedCSS, you need to run Phoenix 1.8+.

Note: ColocatedCSS must be defined at the very beginning of the template in which it is used.

Colocated CSS uses the same folder structures as Colocated JS. See Phoenix.LiveView.ColocatedJS for more information.

To bundle and use colocated CSS with esbuild, you can import it like this in your app.js file:

import "phoenix-colocated/my_app/colocated.css"

Importing CSS in your app.js file will cause esbuild to generate a separate app.css file. To load it, simply add a second <link> to your root.html.heex file, like so:

<link phx-track-static rel="stylesheet" href={~p"/assets/js/app.css"} />

Global CSS

If all you need is global CSS, which is extracted as is, you can define your ColocatedCSS module like this:

defmodule MyAppWeb.ColocatedCSS do
  use Phoenix.LiveView.ColocatedCSS

  @impl true
  def transform("style", _attrs, css, _meta) do
    {:ok, css, []}
  end
end

Scoped CSS

The idea behind scoped CSS is to restrict the elements that CSS rules apply to to only the elements of the current template / component.

One way to scope CSS is to use CSS @scope rules. A scoped ColocatedCSS module using CSS @scope can be implemented like this:

defmodule MyAppWeb.ColocatedScopedCSS do
  use Phoenix.LiveView.ColocatedCSS

  @impl true
  def transform("style", attrs, css, meta) do
    validate_opts!(attrs)

    {scope, css} = do_scope(css, attrs, meta)

    {:ok, css, [root_tag_attribute: {"phx-css-#{scope}", true}]}
  end

  defp validate_opts!(opts) do
    Enum.each(opts, fn {key, val} -> validate_opt!({key, val}, Map.delete(opts, key)) end)
  end

  defp validate_opt!({"lower-bound", val}, _other_opts) when val in ["inclusive", "exclusive"] do
    :ok
  end

  defp validate_opt!({"lower-bound", val}, _other_opts) do
    raise ArgumentError,
          ~s|expected "inclusive" or "exclusive" for the `lower-bound` attribute of colocated css, got: #{inspect(val)}|
  end

  defp validate_opt!(_opt, _other_opts), do: :ok

  defp do_scope(css, opts, meta) do
    scope = hash("#{meta.module}_#{meta.line}: #{css}")

    root_tag_attribute = root_tag_attribute()

    upper_bound_selector = ~s|[phx-css-#{scope}]|
    lower_bound_selector = ~s|[#{root_tag_attribute}]|

    lower_bound_selector =
      case opts do
        %{"lower-bound" => "inclusive"} -> lower_bound_selector <> " > *"
        _ -> lower_bound_selector
      end

    css = "@scope (#{upper_bound_selector}) to (#{lower_bound_selector}) { #{css} }"

    {scope, css}
  end

  defp hash(string) do
    # It is important that we do not pad
    # the Base32 encoded value as we use it in
    # an HTML attribute name and = (the padding character)
    # is not valid.
    string
    |> then(&:crypto.hash(:md5, &1))
    |> Base.encode32(case: :lower, padding: false)
  end

  defp root_tag_attribute() do
    case Application.get_env(:phoenix_live_view, :root_tag_attribute) do
      configured_attribute when is_binary(configured_attribute) ->
        configured_attribute

      configured_attribute ->
        message = """
        a global :root_tag_attribute must be configured to use scoped css

        Expected global :root_tag_attribute to be a string, got: #{inspect(configured_attribute)}

        The global :root_tag_attribute is usually configured to `"phx-r"`, but it needs to be explicitly enabled in your configuration:

            config :phoenix_live_view, root_tag_attribute: "phx-r"

        You can also use a different value than `"phx-r"`.
        """

        raise ArgumentError, message
    end
  end
end

This module transforms a given style tag like

<%!-- Note that :type accepts aliases as well! --%>
<style :type={MyAppWeb.ColocatedScopedCSS}>
  .my-class { color: red; }
</style>

into

@scope ([phx-css-abc123]) to ([phx-r]) {
  .my-class { color: red; }
}

and if lower-bound is set to inclusive, it transforms it into

@scope ([phx-css-abc123]) to ([phx-r] > *) {
  .my-class { color: red; }
}

This applies any styles defined in the colocated CSS block to any element between a local root and a component. It relies on LiveView's global :root_tag_attribute, which is an attribute that LiveView adds to all root tags, no matter if colocated CSS is used or not. When the browser encounters a phx-r attribute, which in this case is assumed to be the configured global :root_tag_attribute, it stops the scoped CSS rule.

Another way to implement scoped CSS could be to use PostCSS and apply an attribute to all tags in a template.

Summary

Callbacks

Callback invoked for each colocated CSS tag.

Callbacks

transform(tag_name, attrs, css, meta)

@callback transform(tag_name :: binary(), attrs :: map(), css :: binary(), meta :: map()) ::
  {:ok, binary(), keyword()} | {:error, term()}

Callback invoked for each colocated CSS tag.

The callback receives the tag name, the string attributes and a map of metadata.

For example, for the following tag:

<style :type={MyAppWeb.ColocatedCSS} data-scope="my-scope" foo={@bar}>
  .my-class { color: red; }
</style>

The callback would receive the following arguments:

  • tag_name: "style"
  • attrs: %{"data-scope" => "my-scope"}
  • meta: %{file: "path/to/file.ex", module: MyApp.MyModule, line: 10}

The callback must return either {:ok, scoped_css, directives} or {:error, reason}. If an error is returned, it will be logged and the CSS will not be extracted.

The directives needs to be a keyword list that supports the following options:

  • root_tag_attribute: A {key, value} tuple that will be added as an attribute to all "root tags" of the template defining the scoped CSS tag. See the section on root tags below for more information.
  • tag_attribute: A {key, value} tuple that will be added as an attribute to all HTML tags in the template defining the scoped CSS tag.

Root tags

In a HEEx template, all outermost tags are considered "root tags" and are affected by the root_tag_attribute directive. If a template uses components, the slots of those components are considered as root tags as well.

Here's an example showing which elements would be considered root tags:

<div>                              <---- root tag
  <span>Hello</span>               <---- not a root tag

  <.my_component>
    <p>World</p>                   <---- root tag
  </.my_component>
</div>

<.my_component>
  <span>World</span>               <---- root tag

  <:a_named_slot>
    <div>                          <---- root tag
      Foo
      <p>Bar</p>                   <---- not a root tag
    </div>
  </:a_named_slot>
</.my_component>