view_component-scoped_styles 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 206f58f9f063b3311d866937c80bcd1a1566621102ebf7135d2aa373f89d32e4
4
- data.tar.gz: f0ac0545c6cb63d6dd6739e9e4356ee6b0ba465025e39920345724afa2ce396e
3
+ metadata.gz: cbe5f5accd78d4f725e75db0267dad949175cbe53ed68bb003dccc4745c294f4
4
+ data.tar.gz: 2f90b7dd291d0b5b3910a8c4e5e8be1e9c5000d8a69b07315582afac30abd2df
5
5
  SHA512:
6
- metadata.gz: fae7281bb414c6329bd19c5a58a4c0b23e0ac2eb11f24262ac34e6bbe278b7542de58b186f8838030eb3db9b439b0d618a4db12f4691cbe62ffe6b3daefe25f0
7
- data.tar.gz: 0ff154a44d59306b1e9c793656b45696fd91be6715036ee3eebd62c1bb3f8c1fb988130f4877afe72c9630783a70dd8d55cc8b7d2e27f522817746d9e520316c
6
+ metadata.gz: 89c318654efd8e5dbba628f5aade499754d32588b35fee364c7e91a45bc3fb0ae2001c1e1b3fdd390ede8890f081d060ed221551e513ee021c5639aafc708b4b
7
+ data.tar.gz: 0d6adc27535b6ab7219935e78964b305cc13fd5b8590a82487b9dbc2d418deaddc15d0d870cd87bf8d1a4d62e96a993437909f4fe807d0a413dda3851a5eb05d
data/README.md CHANGED
@@ -15,8 +15,9 @@ E.g. `.button` becomes `.button_a1b2c3d4`
15
15
  - [1. Using a sidecar stylesheet](#1-using-a-sidecar-stylesheet)
16
16
  - [2. Using a styles block in the component](#2-using-a-styles-block-in-the-component)
17
17
  - [Referencing classes](#referencing-classes)
18
- - [Ignoring classes](#ignoring-classes)
18
+ - [Global classes](#global-classes)
19
19
  - [Using the scoped CSS](#using-the-scoped-css)
20
+ - [Multiple stylesheets](#multiple-stylesheets)
20
21
  - [Configuration](#configuration)
21
22
  - [Related projects](#related-projects)
22
23
  - [Development](#development)
@@ -248,36 +249,76 @@ end
248
249
 
249
250
  **Upgrading from 0.4.x:** set `config.css_class_prefix = "c-"` (and per-component overrides) to keep the previous `c-a1b2c3d4` class names without updating templates.
250
251
 
251
- ### Ignoring classes
252
+ ### Global classes
252
253
 
253
- Ignored classes are left unchanged in generated CSS:
254
+ Use CSS Modules-style `:global(...)` to leave class selectors unchanged in a sidecar stylesheet or `styles` block:
254
255
 
255
- ```ruby
256
- class ExampleComponent < ViewComponent::Base
257
- include ViewComponent::ScopedStyles
256
+ ```css
257
+ .component:global(.is-open) {
258
+ display: block;
259
+ }
258
260
 
259
- ignored_css_classes "is-open", "active"
261
+ :global(.active) .label {
262
+ font-weight: bold;
263
+ }
264
+ ```
260
265
 
261
- styles do
262
- <<~CSS
263
- .component { ... }
264
- .is-open { ... } # stays .is-open in components.scoped.css
265
- CSS
266
- end
267
- end
266
+ The compiler removes `:global(...)` and leaves its contents unscoped. In these examples, `.component` and `.label` get scoped names, while `.is-open` and `.active` stay unchanged. Nested selector functions work too:
267
+
268
+ ```css
269
+ .component:has(:global(.external:not(.disabled))) {
270
+ border-color: green;
271
+ }
272
+ ```
273
+
274
+ Bare `:global` makes the rest of the current selector global. Use `:local(...)` for a local selector within it, or bare `:local` to switch back. Scope resets at each comma, at the end of a selector function, and for each new rule.
275
+
276
+ ```css
277
+ .component :global .external .icon :local(.label) {
278
+ color: red;
279
+ }
268
280
  ```
269
- In your view, you can either reference the class directly:
281
+
282
+ For selector lists, write `:global(.first), :global(.second)`. Lists inside a single `:global(...)` are not supported.
283
+
284
+ Global scope applies to each occurrence. A class can be global in one selector and local in another. `component_class("name")` returns the scoped name when there is a local occurrence, or the original name when every occurrence is global. The root helper uses the configured root class when present, otherwise the first local class, or the first global class if there are no local classes.
285
+
286
+ In your view, reference a global class directly:
287
+
270
288
  ```erb
271
289
  <div class="<%= component_class %> is-open">
272
290
  ```
273
- or via the `component_class` helper:
291
+
292
+ Or use the helper for a class that appears only in global scope:
293
+
274
294
  ```erb
275
295
  <div class="<%= component_class %> <%= component_class("is-open") %>">
276
296
  ```
277
297
 
298
+ #### Migrating from ignored_css_classes
299
+
300
+ `ignored_css_classes` is deprecated and emits an ActiveSupport deprecation warning. It continues to leave every occurrence of the listed classes unchanged, including their helper results.
301
+
302
+ Remove the Ruby declaration and wrap each occurrence of those classes in `:global(...)`:
303
+
304
+ ```ruby
305
+ # Remove this from your component:
306
+ ignored_css_classes "is-open", "active"
307
+ ```
308
+
309
+ ```css
310
+ /* Before */
311
+ .component.is-open .active { color: red; }
312
+
313
+ /* After */
314
+ .component:global(.is-open) :global(.active) { color: red; }
315
+ ```
316
+
317
+ Regenerate the bundled stylesheet after migrating. Scoped names are content-derived, so changing the CSS changes their hashes.
318
+
278
319
  ### Using the scoped CSS
279
320
 
280
- All scoped CSS will be compiled into a single bundled stylesheet. By default that is `app/assets/stylesheets/components.scoped.css`; both the directory and filename are configurable (see [Configuration](#configuration)).
321
+ Scoped CSS is compiled into `app/assets/stylesheets/components.scoped.css` by default. Components can select a different output bundle with `stylesheet`. The default stylesheet is `app/assets/stylesheets/components.scoped.css`; both the directory and filename are configurable (see [Configuration](#configuration)).
281
322
 
282
323
  You should import this stylesheet within your app:
283
324
 
@@ -287,6 +328,38 @@ You should import this stylesheet within your app:
287
328
  @import url("./components.scoped.css");
288
329
  ```
289
330
 
331
+ ### Multiple stylesheets
332
+
333
+ Set `stylesheet` before declaring a `styles` block to choose the component's output bundle. The gem appends `.scoped.css` to the name. This also works with sidecar CSS:
334
+
335
+ ```ruby
336
+ class Admin::ButtonComponent < ViewComponent::Base
337
+ include ViewComponent::ScopedStyles
338
+
339
+ stylesheet "admin"
340
+
341
+ styles do
342
+ ".component { color: blue; }"
343
+ end
344
+ end
345
+
346
+ class Admin::CardComponent < ViewComponent::Base
347
+ include ViewComponent::ScopedStyles
348
+
349
+ stylesheet "admin"
350
+ end
351
+ ```
352
+
353
+ Both components compile into `app/assets/stylesheets/admin.scoped.css`. Any number of components can use the same bundle name. Components without a selection use `config.stylesheet_name`. Subclasses inherit their parent's selection and can override it.
354
+
355
+ All output files use `config.assets_path` and, when configured, `config.components_layer`. Include each bundle where you need it:
356
+
357
+ ```erb
358
+ <%= stylesheet_link_tag "admin.scoped", "data-turbo-track": "reload" %>
359
+ ```
360
+
361
+ Cross-component class references still work across bundles. Load both bundles when both components' styles are needed. When moving an existing component between bundles, remove its marked CSS block from the old generated file, since registration updates the selected file only.
362
+
290
363
  ## Configuration
291
364
 
292
365
  Run the install generator in your Rails app:
@@ -7,7 +7,7 @@ module ViewComponent
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  CACHED_VARIABLES = %i[@component_styles @component_id @component_class_map].freeze
10
- CLASS_SELECTOR_PATTERN = /\.([a-zA-Z_][\w-]*)\b/
10
+ DEPRECATOR = ActiveSupport::Deprecation.new("a future release", "ViewComponent::ScopedStyles")
11
11
  COMPONENT_REFERENCE_PATTERN = /
12
12
  :component\(
13
13
  \s*
@@ -23,6 +23,21 @@ module ViewComponent
23
23
  IGNORED_CSS_CLASSES = [].freeze
24
24
 
25
25
  class_methods do
26
+ # Selects an output bundle, written as <name>.scoped.css in assets_path.
27
+ # Subclasses inherit this choice unless they specify their own bundle.
28
+ def stylesheet(name = nil)
29
+ if name
30
+ @scoped_stylesheet = name.to_s
31
+ register_styles_if_rails_loaded
32
+ end
33
+
34
+ if defined?(@scoped_stylesheet)
35
+ @scoped_stylesheet
36
+ elsif superclass.respond_to?(:stylesheet)
37
+ superclass.stylesheet
38
+ end
39
+ end
40
+
26
41
  # Sets which CSS class is the root for +component_class+ (no argument).
27
42
  # Also triggers style registration in development when Rails is loaded
28
43
  # (registration also runs via the Railtie for all styled components).
@@ -52,7 +67,12 @@ module ViewComponent
52
67
  # Clears cached generated styles when the list changes.
53
68
  #
54
69
  # @param classes [String, Symbol] selector names without a leading dot
70
+ # @deprecated Wrap selectors in +:global(...)+ in the stylesheet instead.
55
71
  def ignored_css_classes(*classes)
72
+ DEPRECATOR.warn(
73
+ "ignored_css_classes is deprecated. Use :global(...) in your CSS instead, " \
74
+ "for example :global(.active)."
75
+ )
56
76
  if classes.any?
57
77
  names = classes.flatten.map { |css_class| css_class.to_s.delete_prefix(".") }
58
78
  const_set(:IGNORED_CSS_CLASSES, names.freeze)
@@ -134,13 +154,15 @@ module ViewComponent
134
154
  component_resolution_stack.push(self)
135
155
  pushed = true
136
156
  styles_content = generate_styles_content
137
- css_classes = extract_css_classes(styles_content)
138
- primary_class = primary_css_class(css_classes)
157
+ selectors = CssSelectors.new(styles_content)
158
+ css_classes = selectors.local_classes
159
+ primary_class = primary_css_class(css_classes + selectors.global_classes)
139
160
 
140
- @component_id = component_id_for(primary_class, styles_content)
141
161
  @component_class_map = build_component_class_map(styles_content, css_classes, primary_class)
162
+ selectors.global_classes.each { |name| @component_class_map[name] ||= name }
163
+ @component_id = @component_class_map[primary_class]
142
164
  @component_styles = replace_component_references(
143
- replace_css_classes(styles_content, @component_class_map)
165
+ selectors.render(@component_class_map)
144
166
  )
145
167
  ensure
146
168
  component_resolution_stack.pop if pushed
@@ -150,10 +172,6 @@ module ViewComponent
150
172
  @styles_block ? @styles_block.call : File.read(stylesheet_path)
151
173
  end
152
174
 
153
- def extract_css_classes(styles_content)
154
- styles_content.scan(CLASS_SELECTOR_PATTERN).flatten.uniq
155
- end
156
-
157
175
  def primary_css_class(css_classes)
158
176
  configured = self::COMPONENT_CSS_CLASS.delete_prefix(".")
159
177
  css_classes.include?(configured) ? configured : css_classes.first
@@ -169,20 +187,6 @@ module ViewComponent
169
187
  end
170
188
  end
171
189
 
172
- def replace_css_classes(styles_content, class_map)
173
- scoped_map = class_map.reject do |css_class, scoped|
174
- css_class == scoped
175
- end
176
-
177
- sorted_classes = scoped_map.keys.sort_by(&:length).reverse
178
-
179
- sorted_classes.reduce(styles_content) do |content, css_class|
180
- scoped = class_map[css_class]
181
- escaped = CssClassPrefix.escape_for_css_selector(scoped)
182
- content.gsub(/\.#{Regexp.escape(css_class)}\b/, ".#{escaped}")
183
- end
184
- end
185
-
186
190
  def replace_component_references(styles_content)
187
191
  styles_content.gsub(COMPONENT_REFERENCE_PATTERN) do
188
192
  component_name = Regexp.last_match[:component_name]
@@ -194,14 +198,6 @@ module ViewComponent
194
198
  end
195
199
  end
196
200
 
197
- def component_id_for(primary_class, styles_content)
198
- if ignored_css_class?(primary_class)
199
- primary_class
200
- else
201
- generate_scoped_class_id(styles_content, primary_class, primary_class)
202
- end
203
- end
204
-
205
201
  def ignored_css_class?(css_class)
206
202
  self::IGNORED_CSS_CLASSES.include?(css_class)
207
203
  end
@@ -275,7 +271,8 @@ module ViewComponent
275
271
  # Scoped CSS class for a selector (e.g. +"c-99d08d5a"+).
276
272
  #
277
273
  # With no argument, returns the scoped root class ({COMPONENT_CSS_CLASS} when it
278
- # appears in the CSS, otherwise the first class in the stylesheet).
274
+ # appears in the CSS, otherwise the first local class, or the first global
275
+ # class when the stylesheet has no local classes).
279
276
  #
280
277
  # @param name [String, Symbol] CSS class without a leading dot (e.g. +"input-box"+)
281
278
  # @param from [Class] component class to read scoped CSS classes from
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module ViewComponent
6
+ module ScopedStyles
7
+ # Records each class occurrence with its scope, preserving non-selector CSS.
8
+ class CssSelectors
9
+ COMMENT = %r{/\*.*?\*/}m
10
+ PROTECTED = %r{/\*.*?\*/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\\.}m
11
+ CLASS_SELECTOR = /\.([a-zA-Z_][\w-]*)\b/
12
+ ClassSelector = Struct.new(:name, :local)
13
+
14
+ attr_reader :local_classes, :global_classes
15
+
16
+ def initialize(css)
17
+ @local_classes = []
18
+ @global_classes = []
19
+ @parts = parse_stylesheet(css)
20
+ @local_classes.uniq!
21
+ @global_classes.uniq!
22
+ end
23
+
24
+ def render(class_map)
25
+ @parts.map do |part|
26
+ if part.is_a?(ClassSelector)
27
+ name = part.local ? class_map.fetch(part.name) : part.name
28
+ ".#{CssClassPrefix.escape_for_css_selector(name)}"
29
+ else
30
+ part
31
+ end
32
+ end.join
33
+ end
34
+
35
+ private
36
+
37
+ # A rule's prelude ends at an opening brace. Semicolons and closing
38
+ # braces flush declarations without interpreting them as selectors.
39
+ def parse_stylesheet(css)
40
+ scanner = StringScanner.new(css)
41
+ parts = []
42
+ prelude = +""
43
+ depth = 0
44
+
45
+ until scanner.eos?
46
+ if (literal = scanner.scan(PROTECTED))
47
+ prelude << literal
48
+ next
49
+ end
50
+
51
+ char = scanner.getch
52
+ case char
53
+ when "(", "["
54
+ depth += 1
55
+ when ")", "]"
56
+ depth -= 1
57
+ end
58
+
59
+ if depth.zero? && "{};".include?(char)
60
+ if char == "{"
61
+ parts.concat(parse_prelude(prelude))
62
+ else
63
+ parts << prelude
64
+ end
65
+ parts << char
66
+ prelude = +""
67
+ else
68
+ prelude << char
69
+ end
70
+ end
71
+
72
+ parts << prelude
73
+ end
74
+
75
+ def parse_prelude(prelude)
76
+ rule = prelude.gsub(COMMENT, "").lstrip
77
+ scanner = StringScanner.new(prelude)
78
+ return parse_selector(scanner) unless rule.start_with?("@") && !rule.match?(/\A@scope\b/)
79
+ return [prelude] unless rule.match?(/\A@supports\b/)
80
+
81
+ parts = []
82
+ until scanner.eos?
83
+ if (literal = scanner.scan(PROTECTED))
84
+ parts << literal
85
+ elsif scanner.scan(/\bselector\(/)
86
+ parts << "selector("
87
+ parts.concat(parse_selector(scanner, closing: ")"))
88
+ parts << ")"
89
+ else
90
+ parts << scanner.getch
91
+ end
92
+ end
93
+ parts
94
+ end
95
+
96
+ def parse_selector(scanner, local: true, closing: nil, scope_function: false)
97
+ parts = []
98
+ initial_scope = local
99
+
100
+ until scanner.eos?
101
+ if closing && scanner.scan(/#{Regexp.escape(closing)}/)
102
+ return parts
103
+ elsif (literal = scanner.scan(PROTECTED))
104
+ parts << literal
105
+ elsif scanner.scan(/\[/)
106
+ parts << "[" << read_attribute(scanner)
107
+ elsif scanner.scan(/:(global|local)\(/)
108
+ scope = scanner[1] == "local"
109
+ parts.concat(parse_selector(scanner, local: scope, closing: ")", scope_function: true))
110
+ elsif scanner.scan(/:(global|local)(?=\s)/)
111
+ local = scanner[1] == "local"
112
+ scanner.scan(/\s+/)
113
+ elsif scanner.scan(CLASS_SELECTOR)
114
+ name = scanner[1]
115
+ (local ? @local_classes : @global_classes) << name
116
+ parts << ClassSelector.new(name, local)
117
+ elsif scanner.scan(/\(/)
118
+ parts << "("
119
+ parts.concat(parse_selector(scanner, local: local, closing: ")"))
120
+ parts << ")"
121
+ elsif scanner.scan(/,/)
122
+ raise ArgumentError, "Use a separate :global(...) or :local(...) for each selector" if scope_function
123
+
124
+ local = initial_scope
125
+ parts << ","
126
+ else
127
+ parts << scanner.getch
128
+ end
129
+ end
130
+
131
+ raise ArgumentError, "Unclosed selector function in scoped CSS" if closing
132
+
133
+ parts
134
+ end
135
+
136
+ def read_attribute(scanner)
137
+ value = +""
138
+ until scanner.eos?
139
+ if (literal = scanner.scan(PROTECTED))
140
+ value << literal
141
+ else
142
+ char = scanner.getch
143
+ value << char
144
+ break if char == "]"
145
+ end
146
+ end
147
+ value
148
+ end
149
+ end
150
+ end
151
+ end
@@ -9,6 +9,9 @@ module ViewComponent
9
9
  end
10
10
 
11
11
  def print!(component_class)
12
+ bundle = component_class.stylesheet
13
+ filename = bundle ? "#{bundle}.scoped.css" : config.stylesheet_name
14
+ @stylesheet_path = Rails.root.join(config.assets_path, filename)
12
15
  component_name = component_class.name
13
16
  component_class.instance_variable_get(:@component_id)
14
17
  styles = component_class.component_styles
@@ -29,6 +32,7 @@ module ViewComponent
29
32
 
30
33
  def update_stylesheet_with(component_name, styles)
31
34
  content = File.read(stylesheet_path)
35
+ content = content.sub(/\n\}\s*\z/, "\n") if config.components_layer
32
36
  updated_content = merge_styles_into(content, component_name, styles)
33
37
 
34
38
  write_atomically(updated_content)
@@ -91,11 +95,7 @@ module ViewComponent
91
95
  content
92
96
  end
93
97
 
94
- def stylesheet_path
95
- @stylesheet_path ||= begin
96
- Rails.root.join(config.assets_path, config.stylesheet_name)
97
- end
98
- end
98
+ attr_reader :stylesheet_path
99
99
 
100
100
  def config
101
101
  @config ||= ViewComponent::ScopedStyles.configuration
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ViewComponent
4
4
  module ScopedStyles
5
- VERSION = "0.6.1"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
@@ -3,7 +3,9 @@
3
3
  require_relative "scoped_styles/version"
4
4
  require_relative "scoped_styles/configuration"
5
5
  require_relative "scoped_styles/css_class_prefix"
6
+ require_relative "scoped_styles/css_selectors"
6
7
  require "active_support/concern"
8
+ require "active_support/deprecation"
7
9
  require "active_support/core_ext/enumerable"
8
10
  require_relative "scoped_styles/concern"
9
11
  require_relative "scoped_styles/stylist"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component-scoped_styles
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Edwards
@@ -119,6 +119,7 @@ files:
119
119
  - lib/view_component/scoped_styles/concern.rb
120
120
  - lib/view_component/scoped_styles/configuration.rb
121
121
  - lib/view_component/scoped_styles/css_class_prefix.rb
122
+ - lib/view_component/scoped_styles/css_selectors.rb
122
123
  - lib/view_component/scoped_styles/railtie.rb
123
124
  - lib/view_component/scoped_styles/stylist.rb
124
125
  - lib/view_component/scoped_styles/stylist/writer.rb