diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 83a0301de3..e6f0f6a859 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -173,7 +173,7 @@ jobs:
runs-on: ubuntu-latest
container:
- image: mcr.microsoft.com/playwright:v1.58.0-jammy
+ image: mcr.microsoft.com/playwright:v1.58.2-jammy
env:
ImageOS: ubuntu22
HOME: /root
diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex
index 13f2c3d978..7f9fea242b 100644
--- a/lib/phoenix_live_view/colocated_css.ex
+++ b/lib/phoenix_live_view/colocated_css.ex
@@ -1,126 +1,256 @@
defmodule Phoenix.LiveView.ColocatedCSS do
@moduledoc ~S'''
- A special HEEx `:type` that extracts any CSS styles from a colocated `
+ To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file:
+
+ ```javascript
+ 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 `` to your `root.html.heex` file, like so:
+
+ ```html
+
+ ```
+
+ ## Global CSS
+
+ If all you need is global CSS, which is extracted as is, you can define your ColocatedCSS module like this:
+
+ ```elixir
+ defmodule MyAppWeb.ColocatedCSS do
+ use Phoenix.LiveView.ColocatedCSS
+
+ @impl true
+ def transform("style", _attrs, css, _meta) do
+ {:ok, css, []}
+ end
+ end
```
## Scoped CSS
- By default, Colocated CSS styles are scoped at compile time to the template in which they are defined.
- This provides style encapsulation preventing CSS rules within a component from unintentionally applying
- to elements in other nested components. Scoping is performed via the use of the `@scope` CSS at-rule.
- For more information, see [the docs on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope).
+ 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](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope).
+ A scoped `ColocatedCSS` module using CSS `@scope` can be implemented like this:
- To prevent Colocated CSS styles from being scoped to the current template you can provide the `global`
- attribute, for example:
+ ```elixir
+ 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
```heex
-
```
- **Note:** When using Scoped Colocated CSS with implicit `inner_block` slots or named slots, the content
- provided will be scoped to the parent template which is providing the content, not the component which
- defines the slot. For example, in the following snippet the elements within [`intersperse/1`](`Phoenix.Component.intersperse/1`)'s
- `inner_block` and `separator` slots will both be styled by the `.sample-class` rule, not any rules defined within the
- [`intersperse/1`](`Phoenix.Component.intersperse/1`) component itself:
+ into
+
+ ```css
+ @scope ([phx-css-abc123]) to ([phx-r]) {
+ .my-class { color: red; }
+ }
+ ```
+
+ and if `lower-bound` is set to `inclusive`, it transforms it into
+
+ ```css
+ @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.
+ '''
+
+ @doc """
+ 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:
```heex
-
-
```
- > #### Warning! {: .warning}
- >
- > The `@scope` CSS at-rule is Baseline available as of the end of 2025. To ensure that Scoped CSS will
- > work on the browsers you need, be sure to check [Can I Use?](https://caniuse.com/css-cascade-scope) for
- > browser compatibility.
+ The callback would receive the following arguments:
- > #### Tip {: .info}
- >
- > When Colocated CSS is scoped via the `@scope` rule, all "local root" elements in the given template serve as scoping roots.
- > "Local root" elements are the outermost elements of the template itself and the outermost elements of any content passed to
- > child components' slots. For selectors in your Colocated CSS to target the scoping root, you will need to
- > specify the scoping root in the selector via the use of the `:scope` pseudo-selector. For more details,
- > see [the docs on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope#scope_pseudo-class_within_scope_blocks).
+ * tag_name: `"style"`
+ * attrs: %{"data-scope" => "my-scope"}
+ * meta: `%{file: "path/to/file.ex", module: MyApp.MyModule, line: 10}`
- Colocated CSS uses the same folder structures as Colocated JS. See `Phoenix.LiveView.ColocatedJS` for more information.
+ 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.
- To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file:
+ The `directives` needs to be a keyword list that supports the following options:
- ```javascript
- import "phoenix-colocated/my_app/colocated.css"
- ```
+ * `root_tag_attribute`: A `{key, value}` tuple that will be added a
+ 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.
- Importing CSS in your `app.js` file will cause esbuild to generate a separate `app.css` file.
- To load it, simply add a second `` to your `root.html.heex` file, like so:
+ ## Root tags
- ```html
-
- ```
+ 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.
- ## Options
+ Here's an example showing which elements would be considered root tags:
- Colocated CSS can be configured through the attributes of the `
"""
@@ -203,7 +205,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do
defp scoped_colocated_css(assigns) do
~H"""
-
@@ -268,7 +270,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do
defp scoped_css_inner_block_two(assigns) do
~H"""
-
@@ -290,7 +292,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do
defp scoped_css_slot_two(assigns) do
~H"""
-
@@ -302,7 +304,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do
defp scoped_exclusive_lower_bound_colocated_css(assigns) do
~H"""
-
"""
@@ -68,53 +68,6 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do
:code.delete(__MODULE__.TestGlobalComponent)
:code.purge(__MODULE__.TestGlobalComponent)
end
-
- test "raises for invalid global attribute value" do
- message = ~r/expected nil or true for the `global` attribute of colocated css, got: "bad"/
-
- assert_raise ParseError,
- message,
- fn ->
- defmodule TestBadGlobalAttrComponent do
- use Phoenix.Component
-
- def fun(assigns) do
- ~H"""
-
- """
- end
- end
- end
- after
- :code.delete(__MODULE__.TestBadGlobalAttrComponent)
- :code.purge(__MODULE__.TestBadGlobalAttrComponent)
- end
-
- test "raises if scoped css specific options are provided" do
- message =
- ~r/colocated css must be scoped to use the `lower-bound` attribute, but `global` attribute was provided/
-
- assert_raise ParseError,
- message,
- fn ->
- defmodule TestScopedAttrWhileGlobalComponent do
- use Phoenix.Component
-
- def fun(assigns) do
- ~H"""
-
- """
- end
- end
- end
- after
- :code.delete(__MODULE__.TestScopedAttrWhileGlobalComponent)
- :code.purge(__MODULE__.TestScopedAttrWhileGlobalComponent)
- end
end
describe "scoped styles" do
@@ -124,7 +77,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do
def fun(assigns) do
~H"""
-
"""
@@ -189,7 +142,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do
def fun(assigns) do
~H"""
-
"""
@@ -260,7 +213,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do
def fun(assigns) do
~H"""
-
"""
diff --git a/test/support/colocated_css.ex b/test/support/colocated_css.ex
new file mode 100644
index 0000000000..686b320c88
--- /dev/null
+++ b/test/support/colocated_css.ex
@@ -0,0 +1,87 @@
+defmodule Phoenix.LiveViewTest.Support.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
+
+defmodule Phoenix.LiveViewTest.Support.ColocatedGlobalCSS do
+ use Phoenix.LiveView.ColocatedCSS
+
+ @impl true
+ def transform("style", _attrs, css, _meta) do
+ {:ok, css, []}
+ end
+end