Skip to content

Commit d5f2b1f

Browse files
committed
scoper behaviour for colocated css
1 parent 316c77a commit d5f2b1f

File tree

7 files changed

+340
-152
lines changed

7 files changed

+340
-152
lines changed

config/e2e.exs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ import Config
33
config :logger, :level, :error
44

55
config :phoenix_live_view, :root_tag_attribute, "phx-r"
6+
7+
config :phoenix_live_view, Phoenix.LiveView.ColocatedCSS,
8+
scoper: Phoenix.LiveViewTest.Support.CSSScoper

lib/phoenix_live_view/colocated_css.ex

Lines changed: 160 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -16,76 +16,148 @@ defmodule Phoenix.LiveView.ColocatedCSS do
1616
</style>
1717
```
1818
19+
Colocated CSS uses the same folder structures as Colocated JS. See `Phoenix.LiveView.ColocatedJS` for more information.
20+
21+
To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file:
22+
23+
```javascript
24+
import "phoenix-colocated/my_app/colocated.css"
25+
```
26+
27+
Importing CSS in your `app.js` file will cause esbuild to generate a separate `app.css` file.
28+
To load it, simply add a second `<link>` to your `root.html.heex` file, like so:
29+
30+
```html
31+
<link phx-track-static rel="stylesheet" href={~p"/assets/js/app.css"} />
32+
```
33+
1934
## Scoped CSS
2035
21-
By default, Colocated CSS styles are scoped at compile time to the template in which they are defined.
22-
This provides style encapsulation preventing CSS rules within a component from unintentionally applying
23-
to elements in other nested components. Scoping is performed via the use of the `@scope` CSS at-rule.
24-
For more information, see [the docs on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope).
36+
By default, Colocated CSS is not scoped. This means that the styles defined in a Colocated CSS block are extracted as is.
37+
However, LiveView supports scoping Colocated CSS by defining a `:scoper` module implementing the `Phoenix.LiveView.ColocatedCSS.Scoper`
38+
behaviour. When a `:scoper` is configured, Colocated CSS that is not defined with the `global` attribute will be scoped
39+
according to the configured scoper.
2540
26-
To prevent Colocated CSS styles from being scoped to the current template you can provide the `global`
27-
attribute, for example:
41+
An example scoper using CSS `@scope` can be implemented like this:
2842
29-
```heex
30-
<style :type={Phoenix.LiveView.ColocatedCSS} global>
31-
.sample-class {
32-
background-color: #FFFFFF;
33-
}
34-
</style>
43+
```elixir
44+
defmodule MyAppWeb.CSSScoper do
45+
@behaviour Phoenix.LiveView.ColocatedCSS.Scoper
46+
47+
@impl true
48+
def scope("style", attrs, css, meta) do
49+
validate_opts!(attrs)
50+
51+
{scope, css} = do_scope(css, attrs, meta)
52+
53+
{:ok, css, [root_tag_attribute: {"phx-css-#{scope}", true}]}
54+
end
55+
56+
defp validate_opts!(opts) do
57+
Enum.each(opts, fn {key, val} -> validate_opt!({key, val}, Map.delete(opts, key)) end)
58+
end
59+
60+
defp validate_opt!({"lower-bound", val}, _other_opts) when val in ["inclusive", "exclusive"] do
61+
:ok
62+
end
63+
64+
defp validate_opt!({"lower-bound", val}, _other_opts) do
65+
raise ArgumentError,
66+
~s|expected "inclusive" or "exclusive" for the `lower-bound` attribute of colocated css, got: #{inspect(val)}|
67+
end
68+
69+
defp validate_opt!(_opt, _other_opts), do: :ok
70+
71+
defp do_scope(css, opts, meta) do
72+
scope = hash("#{meta.module}_#{meta.line}: #{css}")
73+
74+
root_tag_attribute = root_tag_attribute()
75+
76+
upper_bound_selector = ~s|[phx-css-#{scope}]|
77+
lower_bound_selector = ~s|[#{root_tag_attribute}]|
78+
79+
lower_bound_selector =
80+
case opts do
81+
%{"lower-bound" => "inclusive"} -> lower_bound_selector <> " > *"
82+
_ -> lower_bound_selector
83+
end
84+
85+
css = "@scope (#{upper_bound_selector}) to (#{lower_bound_selector}) { #{css} }"
86+
87+
{scope, css}
88+
end
89+
90+
defp hash(string) do
91+
# It is important that we do not pad
92+
# the Base32 encoded value as we use it in
93+
# an HTML attribute name and = (the padding character)
94+
# is not valid.
95+
string
96+
|> then(&:crypto.hash(:md5, &1))
97+
|> Base.encode32(case: :lower, padding: false)
98+
end
99+
100+
defp root_tag_attribute() do
101+
case Application.get_env(:phoenix_live_view, :root_tag_attribute) do
102+
configured_attribute when is_binary(configured_attribute) ->
103+
configured_attribute
104+
105+
configured_attribute ->
106+
message = """
107+
a global :root_tag_attribute must be configured to use scoped css
108+
109+
Expected global :root_tag_attribute to be a string, got: #{inspect(configured_attribute)}
110+
111+
The global :root_tag_attribute is usually configured to `"phx-r"`, but it needs to be explicitly enabled in your configuration:
112+
113+
config :phoenix_live_view, root_tag_attribute: "phx-r"
114+
115+
You can also use a different value than `"phx-r"`.
116+
"""
117+
118+
raise ArgumentError, message
119+
end
120+
end
121+
end
122+
```
123+
124+
To use this scoper, you would configure it in your `config.exs` like this:
125+
126+
```elixir
127+
config :phoenix_live_view, Phoenix.LiveView.ColocatedCSS, scoper: MyAppWeb.CSSScoper
35128
```
36129
37-
**Note:** When using Scoped Colocated CSS with implicit `inner_block` slots or named slots, the content
38-
provided will be scoped to the parent template which is providing the content, not the component which
39-
defines the slot. For example, in the following snippet the elements within [`intersperse/1`](`Phoenix.Component.intersperse/1`)'s
40-
`inner_block` and `separator` slots will both be styled by the `.sample-class` rule, not any rules defined within the
41-
[`intersperse/1`](`Phoenix.Component.intersperse/1`) component itself:
130+
This scoper transforms a given style tag like
42131
43132
```heex
44133
<style :type={Phoenix.LiveView.ColocatedCSS}>
45-
.sample-class {
46-
background-color: #FFFFFF;
47-
}
134+
.my-class { color: red; }
48135
</style>
49-
<div class="sample-class">
50-
<.intersperse :let={item} enum={[1, 2, 3]}>
51-
<:separator>
52-
<span class="sample-class">|</span>
53-
</:separator>
54-
<div class="sample-class">
55-
<p>Item {item}</p>
56-
</div>
57-
</.intersperse>
58-
</div>
59136
```
60137
61-
> #### Warning! {: .warning}
62-
>
63-
> The `@scope` CSS at-rule is Baseline available as of the end of 2025. To ensure that Scoped CSS will
64-
> work on the browsers you need, be sure to check [Can I Use?](https://caniuse.com/css-cascade-scope) for
65-
> browser compatibility.
66-
67-
> #### Tip {: .info}
68-
>
69-
> When Colocated CSS is scoped via the `@scope` rule, all "local root" elements in the given template serve as scoping roots.
70-
> "Local root" elements are the outermost elements of the template itself and the outermost elements of any content passed to
71-
> child components' slots. For selectors in your Colocated CSS to target the scoping root, you will need to
72-
> specify the scoping root in the selector via the use of the `:scope` pseudo-selector. For more details,
73-
> see [the docs on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope#scope_pseudo-class_within_scope_blocks).
138+
into
74139
75-
Colocated CSS uses the same folder structures as Colocated JS. See `Phoenix.LiveView.ColocatedJS` for more information.
140+
```css
141+
@scope ([phx-css-abc123]) to ([phx-r]) {
142+
.my-class { color: red; }
143+
}
144+
```
76145
77-
To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file:
146+
and if `lower-bound` is set to `inclusive`, it transforms it into
78147
79-
```javascript
80-
import "phoenix-colocated/my_app/colocated.css"
148+
```css
149+
@scope ([phx-css-abc123]) to ([phx-r] > *) {
150+
.my-class { color: red; }
151+
}
81152
```
82153
83-
Importing CSS in your `app.js` file will cause esbuild to generate a separate `app.css` file.
84-
To load it, simply add a second `<link>` to your `root.html.heex` file, like so:
154+
This uses [CSS donut scoping](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope) to
155+
apply any styles defined in the colocated CSS block to any element between a local root and a component.
156+
It relies on LiveView's global `:root_tag_attribute`, which is an attribute that LiveView adds to all root tags,
157+
no matter if colocated CSS is used or not. When the browser encounters a `phx-r` attribute, which in this case
158+
is assumed to be the configured global `:root_tag_attribute`, it stops the scoped CSS rule.
85159
86-
```html
87-
<link phx-track-static rel="stylesheet" href={~p"/assets/js/app.css"} />
88-
```
160+
Another way to implement a scoper could be to use PostCSS and apply a tag to all tags in a template.
89161
90162
## Options
91163
@@ -94,13 +166,8 @@ defmodule Phoenix.LiveView.ColocatedCSS do
94166
95167
* `global` - If provided, the Colocated CSS rules contained within the `<style>` tag
96168
will not be scoped to the template within which it is defined, and will instead act
97-
as global CSS rules.
169+
as global CSS rules, even if a scoper is configured.
98170
99-
* `lower-bound` - Configure whether or not the the lower-bound of Scoped Colocated CSS is inclusive, that is,
100-
root elements of child components can be styled by the parent component's Colocated CSS. This can be
101-
useful for applying styles to the child component's root elements for layout purposes. Valid values are
102-
`"inclusive"` and `"exclusive"`. Scoped Colocated CSS defaults to `"exclusive"`, so that styles are entirely
103-
scoped to the parent unless otherwise specified.
104171
'''
105172

106173
@behaviour Phoenix.Component.MacroComponent
@@ -114,10 +181,10 @@ defmodule Phoenix.LiveView.ColocatedCSS do
114181

115182
validate_opts!(opts)
116183

117-
{scope, data} = extract(opts, text_content, meta)
184+
{data, directives} = extract(opts, text_content, meta)
118185

119186
# we always drop colocated CSS from the rendered output
120-
{:ok, "", data, [root_tag_attribute: {"phx-css-#{scope}", true}]}
187+
{:ok, "", data, directives}
121188
end
122189

123190
def transform(_ast, _meta) do
@@ -136,100 +203,68 @@ defmodule Phoenix.LiveView.ColocatedCSS do
136203
Enum.each(opts, fn {key, val} -> validate_opt!({key, val}, Map.delete(opts, key)) end)
137204
end
138205

139-
defp validate_opt!({"global", val}, other_opts) when val in [nil, true] do
140-
case other_opts do
141-
%{"lower-bound" => _} ->
142-
raise ArgumentError,
143-
"colocated css must be scoped to use the `lower-bound` attribute, but `global` attribute was provided"
144-
145-
_ ->
146-
:ok
147-
end
206+
defp validate_opt!({"global", val}, _other_opts) when val in [nil, true] do
207+
:ok
148208
end
149209

150210
defp validate_opt!({"global", val}, _other_opts) do
151211
raise ArgumentError,
152212
"expected nil or true for the `global` attribute of colocated css, got: #{inspect(val)}"
153213
end
154214

155-
defp validate_opt!({"lower-bound", val}, _other_opts) when val in ["inclusive", "exclusive"] do
156-
:ok
157-
end
158-
159-
defp validate_opt!({"lower-bound", val}, _other_opts) do
160-
raise ArgumentError,
161-
~s|expected "inclusive" or "exclusive" for the `lower-bound` attribute of colocated css, got: #{inspect(val)}|
162-
end
163-
164215
defp validate_opt!(_opt, _other_opts), do: :ok
165216

166217
@doc false
167218
def extract(opts, text_content, meta) do
168-
scope = scope(text_content, meta)
169-
root_tag_attribute = root_tag_attribute()
170-
171-
upper_bound_selector = ~s|[phx-css-#{scope}]|
172-
lower_bound_selector = ~s|[#{root_tag_attribute}]|
173-
174-
lower_bound_selector =
219+
global =
175220
case opts do
176-
%{"lower-bound" => "inclusive"} -> lower_bound_selector <> " > *"
177-
_ -> lower_bound_selector
221+
%{"global" => val} -> val in [true, nil]
222+
_ -> false
178223
end
179224

180-
styles =
181-
case opts do
182-
%{"global" => _} ->
183-
text_content
184-
185-
_ ->
186-
"@scope (#{upper_bound_selector}) to (#{lower_bound_selector}) { #{text_content} }"
225+
{styles, directives} =
226+
case Application.get_env(:phoenix_live_view, Phoenix.LiveView.ColocatedCSS, [])[:scoper] do
227+
nil ->
228+
{text_content, []}
229+
230+
_scoper when global in [true, nil] ->
231+
{text_content, []}
232+
233+
scoper ->
234+
scope_meta = %{
235+
module: meta.env.module,
236+
file: meta.env.file,
237+
line: meta.env.line
238+
}
239+
240+
case scoper.scope("style", opts, text_content, scope_meta) do
241+
{:ok, scoped_css, directives} when is_binary(scoped_css) and is_list(directives) ->
242+
{scoped_css, directives}
243+
244+
{:error, reason} ->
245+
raise ArgumentError,
246+
"the scoper returned an error: #{inspect(reason)}"
247+
248+
other ->
249+
raise ArgumentError,
250+
"expected the ColocatedCSS scoper to return {:ok, scoped_css, directives} or {:error, term}, got: #{inspect(other)}"
251+
end
187252
end
188253

189254
filename = "#{meta.env.line}_#{hash(styles)}.css"
190255

191256
data =
192257
Phoenix.LiveView.ColocatedAssets.extract(__MODULE__, meta.env.module, filename, styles, nil)
193258

194-
{scope, data}
195-
end
196-
197-
defp scope(text_content, meta) do
198-
hash("#{meta.env.module}_#{meta.env.line}: #{text_content}")
259+
{data, directives}
199260
end
200261

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

211-
defp root_tag_attribute() do
212-
case Application.get_env(:phoenix_live_view, :root_tag_attribute) do
213-
configured_attribute when is_binary(configured_attribute) ->
214-
configured_attribute
215-
216-
configured_attribute ->
217-
message = """
218-
a global :root_tag_attribute must be configured to use colocated css
219-
220-
Expected global :root_tag_attribute to be a string, got: #{inspect(configured_attribute)}
221-
222-
The global :root_tag_attribute is usually configured to `"phx-r"`, but it needs to be explicitly enabled in your configuration:
223-
224-
config :phoenix_live_view, root_tag_attribute: "phx-r"
225-
226-
You can also use a different value than `"phx-r"`.
227-
"""
228-
229-
raise ArgumentError, message
230-
end
231-
end
232-
233268
@impl Phoenix.LiveView.ColocatedAssets
234269
def build_manifests(files) do
235270
if files == [] do

0 commit comments

Comments
 (0)