diff --git a/config/e2e.exs b/config/e2e.exs index 33e9a3fe20..349e351e7e 100644 --- a/config/e2e.exs +++ b/config/e2e.exs @@ -1,3 +1,5 @@ import Config config :logger, :level, :error + +config :phoenix_live_view, :root_tag_attribute, "phx-r" diff --git a/lib/mix/tasks/compile/phoenix_live_view.ex b/lib/mix/tasks/compile/phoenix_live_view.ex index 957698d013..a2a8683b19 100644 --- a/lib/mix/tasks/compile/phoenix_live_view.ex +++ b/lib/mix/tasks/compile/phoenix_live_view.ex @@ -2,8 +2,8 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do @moduledoc """ A LiveView compiler for HEEx macro components. - Right now, only `Phoenix.LiveView.ColocatedHook` and `Phoenix.LiveView.ColocatedJS` - are handled. + Right now, only `Phoenix.LiveView.ColocatedHook`, `Phoenix.LiveView.ColocatedJS`, + and `Phoenix.LiveView.ColocatedCSS` are handled. You must add it to your `mix.exs` as: @@ -30,5 +30,6 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do defp compile do Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedCSS.compile() end end diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex new file mode 100644 index 0000000000..1933604fd5 --- /dev/null +++ b/lib/phoenix_live_view/colocated_css.ex @@ -0,0 +1,397 @@ +defmodule Phoenix.LiveView.ColocatedCSS do + @moduledoc ~S''' + A special HEEx `:type` that extracts any CSS styles from a colocated ` + ``` + + ## 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). + + To prevent Colocated CSS styles from being scoped to the current template you can provide the `global` + attribute, for example: + + ```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: + + ```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. + + > #### 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). + + ## Internals + + While compiling the template, colocated CSS is extracted into a special folder inside the + `Mix.Project.build_path()`, called `phoenix-colocated-css`. This is customizable, as we'll see below, + but it is important that it is a directory that is not tracked by version control, because the + components are the source of truth for the code. Also, the directory is shared between applications + (this also applies to applications in umbrella projects), so it should typically also be a shared + directory not specific to a single application. + + The colocated CSS directory follows this structure: + + ```text + _build/$MIX_ENV/phoenix-colocated-css/ + _build/$MIX_ENV/phoenix-colocated-css/my_app/ + _build/$MIX_ENV/phoenix-colocated-css/my_app/colocated.css + _build/$MIX_ENV/phoenix-colocated-css/my_app/MyAppWeb.DemoLive/line_HASH.css + _build/$MIX_ENV/phoenix-colocated-css/my_dependency/MyDependency.Module/line_HASH.css + ... + ``` + + Each application has its own folder. Inside, each module also gets its own folder, which allows + us to track and clean up outdated code. + + > #### A note on dependencies and umbrella projects {: .info} + > + > For each application that uses colocated CSS, a separate directory is created + > inside the `phoenix-colocated-css` folder. This allows to have clear separation between + > styles of dependencies, but also applications inside umbrella projects. + + To use colocated CSS, your bundler needs to be configured to resolve the + `phoenix-colocated-css` folder. For new Phoenix applications, this configuration is already included + in the esbuild configuration inside `config.exs`: + + config :esbuild, + ... + my_app: [ + args: + ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), + cd: Path.expand("../assets", __DIR__), + env: %{ + "NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()] + } + ] + + The important part here is the `NODE_PATH` environment variable, which tells esbuild to also look + for packages inside the `deps` folder, as well as the `Mix.Project.build_path()`, which resolves to + `_build/$MIX_ENV`. If you use a different bundler, you'll need to configure it accordingly. If it is not + possible to configure the `NODE_PATH`, you can also change the folder to which LiveView writes colocated + CSS by setting the `:target_directory` option in your `config.exs`: + + ```elixir + config :phoenix_live_view, :colocated_css, + target_directory: Path.expand("../assets/css/phoenix-colocated-css", __DIR__) + ``` + + > #### Tip {: .info} + > + > If you remove or modify the contents of the `:target_directory` folder, you can use + > `mix clean --all` and `mix compile` to regenerate all colocated CSS. + + > #### Warning! {: .warning} + > + > LiveView assumes full ownership over the configured `:target_directory`. When + > compiling, it will **delete** any files and folders inside the `:target_directory`, + > that it does not associate with a colocated CSS file. + + To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file: + + ```javascript + import "phoenix-colocated-css/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 + + ``` + + ## Options + + Colocated CSS can be configured through the attributes of the ` + """ + end + + defp scoped_colocated_css(assigns) do + ~H""" + +
+ Should have blue background + <.scoped_css_inner_block_one> + + Should have no background (scope root) + + Should have blue background + + + + <.scoped_css_inner_block_one> + + Should have no background (scope root) + + Should have blue background + + + + <.scoped_css_inner_block_two> + + Should have no background (scope root) + + Should have blue background + + + + <.scoped_css_slot_one> + <:test> + + Should have no background (scope root) + + Should have blue background + + + + + <.scoped_css_slot_two> + <:test> + + Should have no background (scope root) + + Should have blue background + + + + +
+ """ + end + + slot :inner_block, required: true + + defp scoped_css_inner_block_one(assigns) do + ~H""" + {render_slot(@inner_block)} + """ + end + + slot :inner_block, required: true + + defp scoped_css_inner_block_two(assigns) do + ~H""" + +
+ Should have yellow background + {render_slot(@inner_block)} +
+ """ + end + + slot :test, required: true + + defp scoped_css_slot_one(assigns) do + ~H""" + {render_slot(@test)} + """ + end + + slot :test, required: true + + defp scoped_css_slot_two(assigns) do + ~H""" + +
+ Should have green background + {render_slot(@test)} +
+ """ + end + + defp scoped_exclusive_lower_bound_colocated_css(assigns) do + ~H""" + +
+ <.flex_items should_flex?={false} /> +
+ """ + end + + defp scoped_inclusive_lower_bound_colocated_css(assigns) do + ~H""" + +
+ <.flex_items should_flex?={true} /> +
+ """ + end + + attr :should_flex?, :boolean, required: true + + defp flex_items(assigns) do + ~H""" +

+ {if @should_flex?, do: "Should", else: "Shouldn't"} Flex {x} +

+ """ + end end diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index 5b60354ca2..538485a441 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -277,6 +277,10 @@ defmodule Phoenix.LiveViewTest.E2E.Endpoint do from: Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view"), at: "/assets/colocated" + plug Plug.Static, + from: Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view"), + at: "/assets/colocated_css" + plug Plug.Static, from: System.tmp_dir!(), at: "/tmp" plug :health_check @@ -316,8 +320,9 @@ end IO.puts("Starting e2e server on port #{Phoenix.LiveViewTest.E2E.Endpoint.config(:http)[:port]}") -# we need to manually compile the colocated hooks / js +# we need to manually compile the colocated hooks / js and css Phoenix.LiveView.ColocatedJS.compile() +Phoenix.LiveView.ColocatedCSS.compile() if not IEx.started?() do # when running the test server manually, we halt after diff --git a/test/e2e/tests/colocated.spec.js b/test/e2e/tests/colocated.spec.js index b0d6e1c672..2adfdd2daf 100644 --- a/test/e2e/tests/colocated.spec.js +++ b/test/e2e/tests/colocated.spec.js @@ -41,3 +41,85 @@ test("custom macro component works (syntax highlighting)", async ({ page }) => { page.locator("pre").nth(1).getByText("@temperature"), ).toHaveClass("na"); }); + +test("global colocated css works", async ({ page }) => { + await page.goto("/colocated"); + await syncLV(page); + + await expect(page.locator('[data-test="global"]')).toHaveCSS( + "background-color", + "rgb(255, 0, 0)", + ); +}); + +test("scoped colocated css works", async ({ page }) => { + await page.goto("/colocated"); + await syncLV(page); + + await expect(page.locator('[data-test="scoped"]')).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + + const blueLocator = page.locator('[data-test-scoped="blue"]'); + + await expect(blueLocator).toHaveCount(6); + + for (const shouldBeBlue in blueLocator.all()) { + await expect(shouldBeBlue).toHaveCSS("background-color", "rgb(0, 0, 255)"); + } + + const noneLocator = page.locator('[data-test-scoped="none"]'); + + await expect(noneLocator).toHaveCount(5); + + for (const shouldBeTransparent in noneLocator.all()) { + await expect(shouldBeTransparent).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + } + + await expect(page.locator('[data-test-scoped="yellow"]')).toHaveCSS( + "background-color", + "rgb(255, 255, 0)", + ); + + await expect(page.locator('[data-test-scoped="green"]')).toHaveCSS( + "background-color", + "rgb(0, 255, 0)", + ); +}); + +test("scoped colocated css lower bound inclusive/exclusive works", async ({ + page, +}) => { + await page.goto("/colocated"); + await syncLV(page); + + const lowerBoundContainerLocator = page.locator( + "[data-test-lower-bound-container]", + ); + + await expect(lowerBoundContainerLocator).toHaveCount(2); + + for (const shouldBeFlex in lowerBoundContainerLocator.all()) { + await expect(shouldBeFlex).toHaveCSS("display", "flex"); + } + + const inclusiveFlexItemsLocator = page.locator('[data-test-inclusive="yes"]'); + + await expect(inclusiveFlexItemsLocator).toHaveCount(3); + + for (const shouldFlex in inclusiveFlexItemsLocator.all()) { + await expect(shouldFlex).toHaveCSS("flex", "1"); + } + + const exclusiveFlexItemsLocator = page.locator('[data-test-inclusive="yes"]'); + + await expect(exclusiveFlexItemsLocator).toHaveCount(3); + + for (const shouldntFlex in exclusiveFlexItemsLocator.all()) { + await expect(shouldntFlex).not().toHaveCSS("flex", "1"); + } +}); diff --git a/test/phoenix_live_view/colocated_css_test.exs b/test/phoenix_live_view/colocated_css_test.exs new file mode 100644 index 0000000000..edb9fb8fd3 --- /dev/null +++ b/test/phoenix_live_view/colocated_css_test.exs @@ -0,0 +1,284 @@ +defmodule Phoenix.LiveView.ColocatedCSSTest do + # we set async: false because we call the colocated CSS compiler + # and it reads / writes to a shared folder, and also because + # we manipulate the Application env for :root_tag_attribute + use ExUnit.Case, async: false + + alias Phoenix.LiveView.TagEngine.Tokenizer.ParseError + + setup do + Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r") + on_exit(fn -> Application.delete_env(:phoenix_live_view, :root_tag_attribute) end) + end + + describe "global styles" do + test "are extracted and available under manifest import" do + defmodule TestGlobalComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + + assert module_folders = + File.ls!( + Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view") + ) + + assert folder = + Enum.find(module_folders, fn folder -> + folder =~ ~r/#{inspect(__MODULE__)}\.TestGlobalComponent$/ + end) + + assert [style] = + Path.wildcard( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css" + ) + ) + + assert File.read!(style) == "\n .sample-class { background-color: #FFFFFF; }\n" + + # now write the manifest manually as we are in a test + Phoenix.LiveView.ColocatedCSS.compile() + + assert manifest = + File.read!( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated-css/phoenix_live_view/colocated.css" + ) + ) + + path = + Path.relative_to( + style, + Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/") + ) + + # style is in manifest + assert manifest =~ ~s[@import "./#{path}";\n] + after + :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 + test "with exclusive (default) lower-bound is extracted and available under manifest import" do + defmodule TestScopedExclusiveComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + + assert module_folders = + File.ls!( + Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view") + ) + + assert folder = + Enum.find(module_folders, fn folder -> + folder =~ ~r/#{inspect(__MODULE__)}\.TestScopedExclusiveComponent$/ + end) + + assert [style] = + Path.wildcard( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css" + ) + ) + + file_contents = File.read!(style) + + file_contents = + Regex.replace(~r/phx-css=".+?"/, file_contents, ~s|phx-css="SCOPE_HERE"|) + + # The scope is a generated value, so for testing reliability we just replace it with a known + # value to assert against. + assert file_contents == + ~s|@scope ([phx-css="SCOPE_HERE"]) to ([phx-r]) { \n .sample-class { background-color: #FFFFFF; }\n }| + + # now write the manifest manually as we are in a test + Phoenix.LiveView.ColocatedCSS.compile() + + assert manifest = + File.read!( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated-css/phoenix_live_view/colocated.css" + ) + ) + + path = + Path.relative_to( + style, + Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/") + ) + + # style is in manifest + assert manifest =~ ~s[@import "./#{path}";\n] + after + :code.delete(__MODULE__.TestScopedExclusiveComponent) + :code.purge(__MODULE__.TestScopedExclusiveComponent) + end + + test "with inclusive lower-bound is extracted and available under manifest import" do + defmodule TestScopedInclusiveComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + + assert module_folders = + File.ls!( + Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view") + ) + + assert folder = + Enum.find(module_folders, fn folder -> + folder =~ ~r/#{inspect(__MODULE__)}\.TestScopedInclusiveComponent$/ + end) + + assert [style] = + Path.wildcard( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css" + ) + ) + + file_contents = File.read!(style) + + file_contents = + Regex.replace(~r/phx-css=".+?"/, file_contents, ~s|phx-css="SCOPE_HERE"|) + + # The scope is a generated value, so for testing reliability we just replace it with a known + # value to assert against. + assert file_contents == + ~s|@scope ([phx-css="SCOPE_HERE"]) to ([phx-r] > *) { \n .sample-class { background-color: #FFFFFF; }\n }| + + # now write the manifest manually as we are in a test + Phoenix.LiveView.ColocatedCSS.compile() + + assert manifest = + File.read!( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated-css/phoenix_live_view/colocated.css" + ) + ) + + path = + Path.relative_to( + style, + Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/") + ) + + # style is in manifest + assert manifest =~ ~s[@import "./#{path}";\n] + after + :code.delete(__MODULE__.TestScopedInclusiveComponent) + :code.purge(__MODULE__.TestScopedInclusiveComponent) + end + + test "raises for invalid lower-bound attribute value" do + message = + ~r/expected "inclusive" or "exclusive" for the `lower-bound` attribute of colocated css, got: "unknown"/ + + assert_raise ParseError, + message, + fn -> + defmodule TestBadLowerBoundAttrComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + end + after + :code.delete(__MODULE__.TestBadLowerBoundAttrComponent) + :code.purge(__MODULE__.TestBadLowerBoundAttrComponent) + end + end + + test "writes empty manifest when no colocated styles exist" do + manifest = + Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/colocated.css") + + Phoenix.LiveView.ColocatedCSS.compile() + assert File.exists?(manifest) + assert File.read!(manifest) == "" + end +end