view_component-scoped_styles 0.5.0 → 0.6.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: 6eabd4a24031f8acbe3e73fba03f5bd1d8263feee45a8836e827fe4ce3e2bcf3
4
- data.tar.gz: 632ae34483c93fdadb82fac21ac8b0a409ff9ae6b07098ff080c5e32a935e8fc
3
+ metadata.gz: c1612f1fbc0f2b03fa6930fd04b2749a914795ec7c4c23dd3d9f3a6b09ac0666
4
+ data.tar.gz: 443a89edf71c93e6af8bea761c77c4ed1c9d8788005fcfe21a90cf8371bd118f
5
5
  SHA512:
6
- metadata.gz: cdc5ed3dc3056702683c153d9399f4aa7f89e82806e3be2f5d079da8d0ba2ae6aaf06be57fa54d1fbcf7461cdf2579b54b04a006d06d0827a0ef17845c7ed450
7
- data.tar.gz: 3c647e2d17bdbf084cd20abb5cf1da61cea6c097930bfb6374d4917680cbc3f71cb16db5912e9cdb91fe52a6f91a976a49b1b7b76b10dde53d7d19c125705ec3
6
+ metadata.gz: 84893c6c973b772ed5780eebb9400201ea01b1e3e9db1489dff02eb03ce7e9c5591dd6ab34cf6528c63136ddfe69a392bd6def73904057389493115f35d6c487
7
+ data.tar.gz: 69d6aad3940294c2a3a809926594a9f081115c244d6fe19ef23f3ccd8126ff9fc87d5bb81e9c50edeb046c936e61a03eb387255bbf5d014ebad42c08c8909628
data/README.md CHANGED
@@ -60,6 +60,21 @@ CSS can be written in two ways:
60
60
 
61
61
  Learn more about sidecar [here](https://viewcomponent.org/guide/generators.html#place-the-view-in-a-sidecar-directory).
62
62
 
63
+ ```bash
64
+ bin/rails generate view_component:component Example title --sidecar
65
+ ```
66
+
67
+ With automatic stylesheet generation enabled in `config/application.rb`, `config/ENVIRONMENT.rb`, or wherever you configure ViewComponent:
68
+
69
+ ```ruby
70
+ # Ensure sidebar is enabled
71
+ config.view_component.generate.sidecar = true
72
+ # Then generate stylesheets via
73
+ config.view_component.generate.stylesheet = true
74
+ ```
75
+
76
+ `view_component:component` also creates the sidecar `.css` file, adds `include ViewComponent::ScopedStyles` to the component class, and sets `class="<%= component_class %>"` on the generated ERB template. You can pass `--no-stylesheet` to skip CSS generation for a single component.
77
+
63
78
  ```bash
64
79
  bin/rails generate view_component:component Example title --sidecar
65
80
 
@@ -68,10 +83,12 @@ bin/rails generate view_component:component Example title --sidecar
68
83
  create test/components/example_component_test.rb
69
84
  invoke erb
70
85
  create app/components/example_component/example_component.html.erb
86
+ invoke stylesheet
87
+ create app/components/example_component/example_component.css
71
88
 
72
89
  ```
73
90
 
74
- Then add a matching stylesheet in the sidecar directory:
91
+ Or add a matching stylesheet manually in the sidecar directory:
75
92
 
76
93
  ```css
77
94
  /* app/components/example_component/example_component.css */
@@ -161,6 +178,32 @@ end
161
178
  </div>
162
179
  ```
163
180
 
181
+ You can also reference scoped classes from another component by passing the component class with `from:`:
182
+
183
+ ```erb
184
+ <div class="<%= component_class(from: ButtonComponent) %>">
185
+ Button root class
186
+ </div>
187
+
188
+ <div class="<%= component_class("icon", from: ButtonComponent) %>">
189
+ Button icon class
190
+ </div>
191
+ ```
192
+
193
+ Inside sidecar stylesheets or `styles` blocks, use `:component(...)` as a compile-time reference to another component's scoped classes:
194
+
195
+ ```css
196
+ .component:has(:component(ButtonComponent, icon)) {
197
+ padding-inline-end: 2rem;
198
+ }
199
+
200
+ :where(:component(CardComponent), :component(PanelComponent)) .title {
201
+ margin: 0;
202
+ }
203
+ ```
204
+
205
+ The first argument is the component constant, and the optional second argument is the class selector without a leading dot. When the class name is omitted, the referenced component's root class is used. `:component(...)` is replaced while scoped CSS is generated, so it can be used inside more complex selectors like `:where()`, `:is()`, `:not()`, and `:has()`.
206
+
164
207
  Scoped class names are prefixed by default using the class name (e.g. `.component` → `component_a1b2c3d4`). Set a global prefix in configuration, or override per component with `css_class_prefix`.
165
208
 
166
209
  The prefix is a template string. Use `{class_name}` for the CSS class being scoped and `{component_name}` for the component name (namespaces joined by `/`, with a trailing `Component` suffix removed):
@@ -1,5 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Optional ViewComponent generator settings (in config/application.rb, config/ENVIRONMENT.rb, or wherever you configure ViewComponent):
4
+ # Ensure sidebar is enabled
5
+ # config.view_component.generate.sidecar = true
6
+ # Then generate stylesheets via
7
+ # config.view_component.generate.stylesheet = true
8
+
3
9
  ViewComponent::ScopedStyles.configure do |config|
4
10
  # Where ViewComponent classes live (relative to Rails.root). Default: "app/components"
5
11
  config.components_path = <%= components_path_expression %>
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "generators/view_component/abstract_generator"
5
+
6
+ module ViewComponent
7
+ module Generators
8
+ class StylesheetGenerator < ::Rails::Generators::NamedBase
9
+ include ViewComponent::AbstractGenerator
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ class_option :sidecar, type: :boolean, default: false
13
+ class_option :skip_suffix, type: :boolean, default: false
14
+
15
+ def create_stylesheet
16
+ template "component.css", stylesheet_destination
17
+ end
18
+
19
+ def inject_scoped_styles_module
20
+ return unless File.exist?(absolute_component_rb_path)
21
+
22
+ content = File.read(absolute_component_rb_path)
23
+ return if content.include?("ViewComponent::ScopedStyles")
24
+
25
+ class_name_pattern = component_class_name.split("::").last
26
+ match = content.match(/^(\s*)class #{Regexp.escape(class_name_pattern)}\b[^\n]*\n/)
27
+ return unless match
28
+
29
+ indent = "#{match[1]} "
30
+ inject_into_file component_rb_path,
31
+ "#{indent}include ViewComponent::ScopedStyles\n",
32
+ after: match[0]
33
+ end
34
+
35
+ def update_view_template
36
+ return if options["inline"] || options["call"]
37
+
38
+ path = view_template_path
39
+ return unless File.exist?(File.join(destination_root, path))
40
+
41
+ gsub_file path, /<div/, '<div class="<%= component_class %>"', verbose: false
42
+ end
43
+
44
+ private
45
+
46
+ def absolute_component_rb_path
47
+ File.join(destination_root, component_rb_path)
48
+ end
49
+
50
+ def component_file_name
51
+ "#{file_name}#{"_component" unless options[:skip_suffix]}"
52
+ end
53
+
54
+ def component_class_name
55
+ "#{class_name}#{"Component" unless options[:skip_suffix]}"
56
+ end
57
+
58
+ def component_rb_path
59
+ File.join(component_path, class_path, "#{component_file_name}.rb")
60
+ end
61
+
62
+ def stylesheet_destination
63
+ if sidecar?
64
+ File.join(
65
+ component_path, class_path, component_file_name, "#{component_file_name}.css"
66
+ )
67
+ else
68
+ File.join(component_path, class_path, "#{component_file_name}.css")
69
+ end
70
+ end
71
+
72
+ def view_template_path
73
+ File.join(destination_directory, "#{destination_file_name}.html.erb")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,3 @@
1
+ .component {
2
+ /* TODO: add component styles */
3
+ }
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/view_component/component/component_generator"
4
+
5
+ module ViewComponent
6
+ module Generators
7
+ class ComponentGenerator
8
+ class_option :stylesheet, type: :boolean,
9
+ default: ViewComponent::Base.config.generate.stylesheet
10
+
11
+ hook_for :stylesheet, type: :boolean
12
+ end
13
+ end
14
+ end
@@ -8,6 +8,15 @@ module ViewComponent
8
8
 
9
9
  CACHED_VARIABLES = %i[@component_styles @component_id @component_class_map].freeze
10
10
  CLASS_SELECTOR_PATTERN = /\.([a-zA-Z_][\w-]*)\b/
11
+ COMPONENT_REFERENCE_PATTERN = /
12
+ :component\(
13
+ \s*
14
+ (?<component_name>[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)
15
+ \s*
16
+ (?:,\s*(?<css_class>[a-zA-Z_][\w-]*)\s*)?
17
+ \)
18
+ /x
19
+ COMPONENT_RESOLUTION_STACK_KEY = :view_component_scoped_styles_component_resolution_stack
11
20
 
12
21
  # Default root class for +component_class+ when it matches a selector in the CSS.
13
22
  COMPONENT_CSS_CLASS = "component".freeze
@@ -115,13 +124,26 @@ module ViewComponent
115
124
  end
116
125
 
117
126
  def generate_component_styles
127
+ pushed = false
128
+
129
+ if component_resolution_stack.include?(self)
130
+ names = (component_resolution_stack + [self]).map { |component| component.name || component.inspect }
131
+ raise ArgumentError, "Circular scoped style component reference: #{names.join(" -> ")}"
132
+ end
133
+
134
+ component_resolution_stack.push(self)
135
+ pushed = true
118
136
  styles_content = generate_styles_content
119
137
  css_classes = extract_css_classes(styles_content)
120
138
  primary_class = primary_css_class(css_classes)
121
139
 
122
140
  @component_id = component_id_for(primary_class, styles_content)
123
141
  @component_class_map = build_component_class_map(styles_content, css_classes, primary_class)
124
- @component_styles = replace_css_classes(styles_content, @component_class_map)
142
+ @component_styles = replace_component_references(
143
+ replace_css_classes(styles_content, @component_class_map)
144
+ )
145
+ ensure
146
+ component_resolution_stack.pop if pushed
125
147
  end
126
148
 
127
149
  def generate_styles_content
@@ -161,6 +183,17 @@ module ViewComponent
161
183
  end
162
184
  end
163
185
 
186
+ def replace_component_references(styles_content)
187
+ styles_content.gsub(COMPONENT_REFERENCE_PATTERN) do
188
+ component_name = Regexp.last_match[:component_name]
189
+ css_class = Regexp.last_match[:css_class]
190
+ component_class = resolve_component_reference(component_name)
191
+ scoped_class = scoped_component_class(component_class, css_class)
192
+
193
+ ".#{CssClassPrefix.escape_for_css_selector(scoped_class)}"
194
+ end
195
+ end
196
+
164
197
  def component_id_for(primary_class, styles_content)
165
198
  if ignored_css_class?(primary_class)
166
199
  primary_class
@@ -202,6 +235,36 @@ module ViewComponent
202
235
  end
203
236
  end
204
237
 
238
+ def resolve_component_reference(component_name)
239
+ component_name.to_s.split("::").inject(Object) do |namespace, constant_name|
240
+ namespace.const_get(constant_name)
241
+ end
242
+ rescue NameError
243
+ raise ArgumentError, "Unable to resolve scoped style component reference: #{component_name}"
244
+ end
245
+
246
+ def scoped_component_class(component_class, css_class = nil)
247
+ unless component_class.respond_to?(:component_styles)
248
+ raise ArgumentError, "#{component_class} does not include ViewComponent::ScopedStyles"
249
+ end
250
+
251
+ component_class.component_styles
252
+
253
+ if css_class
254
+ scoped = component_class.instance_variable_get(:@component_class_map)[css_class.to_s.delete_prefix(".")]
255
+
256
+ raise ArgumentError, "#{component_class.name || component_class} does not define .#{css_class}" unless scoped
257
+
258
+ scoped
259
+ else
260
+ component_class.instance_variable_get(:@component_id)
261
+ end
262
+ end
263
+
264
+ def component_resolution_stack
265
+ Thread.current[COMPONENT_RESOLUTION_STACK_KEY] ||= []
266
+ end
267
+
205
268
  def clear_component_style_cache
206
269
  CACHED_VARIABLES.each do |ivar|
207
270
  remove_instance_variable(ivar) if instance_variable_defined?(ivar)
@@ -215,28 +278,30 @@ module ViewComponent
215
278
  # appears in the CSS, otherwise the first class in the stylesheet).
216
279
  #
217
280
  # @param name [String, Symbol] CSS class without a leading dot (e.g. +"input-box"+)
218
- def component_class(name = nil)
219
- return nil unless component_has_styles? || component_has_stylesheet?
281
+ # @param from [Class] component class to read scoped CSS classes from
282
+ def component_class(name = nil, from: self.class)
283
+ return nil unless from.respond_to?(:component_styles)
284
+ return nil unless component_has_styles?(from) || component_has_stylesheet?(from)
220
285
 
221
- self.class.component_styles
286
+ from.component_styles
222
287
 
223
288
  if name
224
- class_map = self.class.instance_variable_get(:@component_class_map)
289
+ class_map = from.instance_variable_get(:@component_class_map)
225
290
  class_map[name.to_s.delete_prefix(".")]
226
291
  else
227
- self.class.instance_variable_get(:@component_id)
292
+ from.instance_variable_get(:@component_id)
228
293
  end
229
294
  end
230
295
 
231
296
  private
232
297
 
233
- def component_has_stylesheet?
234
- self.class.has_stylesheet?
298
+ def component_has_stylesheet?(component_class = self.class)
299
+ component_class.has_stylesheet?
235
300
  end
236
301
 
237
- def component_has_styles?
238
- self.class.instance_variable_defined?(:@styles_block) &&
239
- self.class.instance_variable_get(:@styles_block)
302
+ def component_has_styles?(component_class = self.class)
303
+ component_class.instance_variable_defined?(:@styles_block) &&
304
+ component_class.instance_variable_get(:@styles_block)
240
305
  end
241
306
  end
242
307
  end
@@ -6,6 +6,10 @@ require "rails/railtie"
6
6
  module ViewComponent
7
7
  module ScopedStyles
8
8
  class Railtie < Rails::Railtie
9
+ initializer "view_component.scoped_styles.component_generator_hook" do
10
+ require_relative "component_generator_hook"
11
+ end
12
+
9
13
  config.after_initialize do
10
14
  next unless Rails.env.development?
11
15
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ViewComponent
4
4
  module ScopedStyles
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
@@ -4,6 +4,7 @@ require_relative "scoped_styles/version"
4
4
  require_relative "scoped_styles/configuration"
5
5
  require_relative "scoped_styles/css_class_prefix"
6
6
  require "active_support/concern"
7
+ require "active_support/core_ext/enumerable"
7
8
  require_relative "scoped_styles/concern"
8
9
  require_relative "scoped_styles/stylist"
9
10
  require_relative "scoped_styles/railtie"
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Edwards
@@ -112,7 +112,10 @@ files:
112
112
  - Rakefile
113
113
  - lib/generators/view_component/scoped_styles/install_generator.rb
114
114
  - lib/generators/view_component/scoped_styles/templates/view_component_scoped_styles.rb.tt
115
+ - lib/generators/view_component/stylesheet/stylesheet_generator.rb
116
+ - lib/generators/view_component/stylesheet/templates/component.css.tt
115
117
  - lib/view_component/scoped_styles.rb
118
+ - lib/view_component/scoped_styles/component_generator_hook.rb
116
119
  - lib/view_component/scoped_styles/concern.rb
117
120
  - lib/view_component/scoped_styles/configuration.rb
118
121
  - lib/view_component/scoped_styles/css_class_prefix.rb
@@ -142,7 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
145
  - !ruby/object:Gem::Version
143
146
  version: '0'
144
147
  requirements: []
145
- rubygems_version: 4.0.3
148
+ rubygems_version: 4.0.10
146
149
  specification_version: 4
147
150
  summary: Scoped, colocated CSS for ViewComponent components.
148
151
  test_files: []