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 - -
- <.intersperse :let={item} enum={[1, 2, 3]}> - <:separator> - | - -
-

Item {item}

-
- -
``` - > #### 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