@@ -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