view_component-scoped_styles 0.6.1 → 0.7.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: 76cfb539bacc159bd6913d32e02fd65f4bdf694e94956fdb9187b68b951badbe
4
+ data.tar.gz: 6cd273fa833779ed196785c3318ffb79c3c55929c9637aa067d6d2e97a1bbb14
5
5
  SHA512:
6
- metadata.gz: fae7281bb414c6329bd19c5a58a4c0b23e0ac2eb11f24262ac34e6bbe278b7542de58b186f8838030eb3db9b439b0d618a4db12f4691cbe62ffe6b3daefe25f0
7
- data.tar.gz: 0ff154a44d59306b1e9c793656b45696fd91be6715036ee3eebd62c1bb3f8c1fb988130f4877afe72c9630783a70dd8d55cc8b7d2e27f522817746d9e520316c
6
+ metadata.gz: b7ebb5bd1bbba3ad26b14119f80d54623a0aae174956c178600c130106d23517053ab232b91ba4f516cebca07fb7c08581a34ad4cab83a6cce00c611957110a3
7
+ data.tar.gz: 0caa033226552ccf3ea15d7a6d8af1240449d37a68d1c18f2cb549882c46ee42145cf0f41434e28af4fe68fe242337c3f4b75af27ee93a917412f337e1c32a98
data/README.md CHANGED
@@ -15,7 +15,7 @@ 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
20
  - [Configuration](#configuration)
21
21
  - [Related projects](#related-projects)
@@ -248,33 +248,73 @@ end
248
248
 
249
249
  **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
250
 
251
- ### Ignoring classes
251
+ ### Global classes
252
252
 
253
- Ignored classes are left unchanged in generated CSS:
253
+ Use CSS Modules-style `:global(...)` to leave class selectors unchanged in a sidecar stylesheet or `styles` block:
254
254
 
255
- ```ruby
256
- class ExampleComponent < ViewComponent::Base
257
- include ViewComponent::ScopedStyles
255
+ ```css
256
+ .component:global(.is-open) {
257
+ display: block;
258
+ }
258
259
 
259
- ignored_css_classes "is-open", "active"
260
+ :global(.active) .label {
261
+ font-weight: bold;
262
+ }
263
+ ```
260
264
 
261
- styles do
262
- <<~CSS
263
- .component { ... }
264
- .is-open { ... } # stays .is-open in components.scoped.css
265
- CSS
266
- end
267
- end
265
+ 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:
266
+
267
+ ```css
268
+ .component:has(:global(.external:not(.disabled))) {
269
+ border-color: green;
270
+ }
271
+ ```
272
+
273
+ 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.
274
+
275
+ ```css
276
+ .component :global .external .icon :local(.label) {
277
+ color: red;
278
+ }
268
279
  ```
269
- In your view, you can either reference the class directly:
280
+
281
+ For selector lists, write `:global(.first), :global(.second)`. Lists inside a single `:global(...)` are not supported.
282
+
283
+ 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.
284
+
285
+ In your view, reference a global class directly:
286
+
270
287
  ```erb
271
288
  <div class="<%= component_class %> is-open">
272
289
  ```
273
- or via the `component_class` helper:
290
+
291
+ Or use the helper for a class that appears only in global scope:
292
+
274
293
  ```erb
275
294
  <div class="<%= component_class %> <%= component_class("is-open") %>">
276
295
  ```
277
296
 
297
+ #### Migrating from ignored_css_classes
298
+
299
+ `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.
300
+
301
+ Remove the Ruby declaration and wrap each occurrence of those classes in `:global(...)`:
302
+
303
+ ```ruby
304
+ # Remove this from your component:
305
+ ignored_css_classes "is-open", "active"
306
+ ```
307
+
308
+ ```css
309
+ /* Before */
310
+ .component.is-open .active { color: red; }
311
+
312
+ /* After */
313
+ .component:global(.is-open) :global(.active) { color: red; }
314
+ ```
315
+
316
+ Regenerate the bundled stylesheet after migrating. Scoped names are content-derived, so changing the CSS changes their hashes.
317
+
278
318
  ### Using the scoped CSS
279
319
 
280
320
  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)).
@@ -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*
@@ -52,7 +52,12 @@ module ViewComponent
52
52
  # Clears cached generated styles when the list changes.
53
53
  #
54
54
  # @param classes [String, Symbol] selector names without a leading dot
55
+ # @deprecated Wrap selectors in +:global(...)+ in the stylesheet instead.
55
56
  def ignored_css_classes(*classes)
57
+ DEPRECATOR.warn(
58
+ "ignored_css_classes is deprecated. Use :global(...) in your CSS instead, " \
59
+ "for example :global(.active)."
60
+ )
56
61
  if classes.any?
57
62
  names = classes.flatten.map { |css_class| css_class.to_s.delete_prefix(".") }
58
63
  const_set(:IGNORED_CSS_CLASSES, names.freeze)
@@ -134,13 +139,15 @@ module ViewComponent
134
139
  component_resolution_stack.push(self)
135
140
  pushed = true
136
141
  styles_content = generate_styles_content
137
- css_classes = extract_css_classes(styles_content)
138
- primary_class = primary_css_class(css_classes)
142
+ selectors = CssSelectors.new(styles_content)
143
+ css_classes = selectors.local_classes
144
+ primary_class = primary_css_class(css_classes + selectors.global_classes)
139
145
 
140
- @component_id = component_id_for(primary_class, styles_content)
141
146
  @component_class_map = build_component_class_map(styles_content, css_classes, primary_class)
147
+ selectors.global_classes.each { |name| @component_class_map[name] ||= name }
148
+ @component_id = @component_class_map[primary_class]
142
149
  @component_styles = replace_component_references(
143
- replace_css_classes(styles_content, @component_class_map)
150
+ selectors.render(@component_class_map)
144
151
  )
145
152
  ensure
146
153
  component_resolution_stack.pop if pushed
@@ -150,10 +157,6 @@ module ViewComponent
150
157
  @styles_block ? @styles_block.call : File.read(stylesheet_path)
151
158
  end
152
159
 
153
- def extract_css_classes(styles_content)
154
- styles_content.scan(CLASS_SELECTOR_PATTERN).flatten.uniq
155
- end
156
-
157
160
  def primary_css_class(css_classes)
158
161
  configured = self::COMPONENT_CSS_CLASS.delete_prefix(".")
159
162
  css_classes.include?(configured) ? configured : css_classes.first
@@ -169,20 +172,6 @@ module ViewComponent
169
172
  end
170
173
  end
171
174
 
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
175
  def replace_component_references(styles_content)
187
176
  styles_content.gsub(COMPONENT_REFERENCE_PATTERN) do
188
177
  component_name = Regexp.last_match[:component_name]
@@ -194,14 +183,6 @@ module ViewComponent
194
183
  end
195
184
  end
196
185
 
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
186
  def ignored_css_class?(css_class)
206
187
  self::IGNORED_CSS_CLASSES.include?(css_class)
207
188
  end
@@ -275,7 +256,8 @@ module ViewComponent
275
256
  # Scoped CSS class for a selector (e.g. +"c-99d08d5a"+).
276
257
  #
277
258
  # 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).
259
+ # appears in the CSS, otherwise the first local class, or the first global
260
+ # class when the stylesheet has no local classes).
279
261
  #
280
262
  # @param name [String, Symbol] CSS class without a leading dot (e.g. +"input-box"+)
281
263
  # @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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ViewComponent
4
4
  module ScopedStyles
5
- VERSION = "0.6.1"
5
+ VERSION = "0.7.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.7.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