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 + +
Item {item}
++ {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