view_component-contrib 0.1.6 → 0.2.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: cebcea7442b79ea935dd0039c361e813b597c8f63a15a9cd90407635a6a96856
4
- data.tar.gz: 98e1aeecc1354a1e619bf75c765aa64f0051ac5ff4d7d0c81fb42425e7da7ae5
3
+ metadata.gz: 814046bee78131bac6659eaa62160b6dfef169d48d1c5850c03dbe6a00a2ad0f
4
+ data.tar.gz: ef1ec7cfb3f71bf4db995be3a564dcfc0ff04e72d3c4e116515d216de18b38e8
5
5
  SHA512:
6
- metadata.gz: 427691d12a38bfcab3c0861506aade1ada63422badb1b207e5fff49cd72bc66c8a6aadfd220b51385ef5abc2166061cd8333afbf14e915058e98bf2899438ad5
7
- data.tar.gz: 5eb44287d55acb724d458d6eca667296787215cafced750c9071834ee2203b89327d93ff983b1b2e9879b961e13eb8b5d4f2af237c690a4c3f6bc6cbdaa6654c
6
+ metadata.gz: 9a5d578dfc44e7318da343a0aefe65946cce2399675d92646fd33db8e7494040d16433a3c409a08185d04c249fbc538e494175dce593a6bde19e27d254926d42
7
+ data.tar.gz: d01e0bf7f11d866aaab42dafb040e7ee45e4257524888b7bb6768b399d2db03c971867e6b14d839c1a66f7e37d9fe71982e287c8280b3a16811891bc61ee4723
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 0.2.0 (2023-11-07)
6
+
7
+ - Introduce style variants. ([@palkan][])
8
+
9
+ - **Require Ruby 2.7+**. ([@palkan][])
10
+
11
+ - Add system tests to generator. ([@palkan][])
12
+
13
+ - Drop Webpack-related stuff from the generator. ([@palkan][])
14
+
5
15
  ## 0.1.6 (2023-11-07)
6
16
 
7
17
  - Support preview classes named `<component|partial>_preview.rb`. ([@palkan][])
data/README.md CHANGED
@@ -32,7 +32,6 @@ The command above:
32
32
  - Configure `view_component` paths.
33
33
  - Adds `ApplicationViewComponent` and `ApplicationViewComponentPreview` classes.
34
34
  - Configures testing framework (RSpec or Minitest).
35
- - Adds required JS/CSS configuration.
36
35
  - **Adds a custom generator to create components**.
37
36
 
38
37
  The custom generator would allow you to create all the required component files in a single command:
@@ -94,6 +93,8 @@ ActiveSupport.on_load(:view_component) do
94
93
  end
95
94
  ```
96
95
 
96
+ You can still continue using preview clases with the `_preview.rb` suffix, they would work as before.
97
+
97
98
  #### Reducing previews boilerplate
98
99
 
99
100
  In most cases, previews contain only the `default` example and a very simple template (`= render Component.new(**options)`).
@@ -180,9 +181,109 @@ end
180
181
 
181
182
  If you need more control over your template, you can add a custom `preview.html.*` template (which will be used for all examples in this preview), or even create an example-specific `previews/example.html.*` (e.g. `previews/mobile.html.erb`).
182
183
 
184
+ ## Style variants
185
+
186
+ Since v0.2.0, we provide a custom extentions to manage CSS classes and their combinations—**Style Variants**. This is especially useful for project using CSS frameworks such as TailwindCSS.
187
+
188
+ The idea is to define variants schema in the component class and use it to compile the resulting list of CSS classes. (Inspired by [Tailwind Variants](https://www.tailwind-variants.org) and [CVA variants](https://cva.style/docs/getting-started/variants)).
189
+
190
+ Consider an example:
191
+
192
+ ```ruby
193
+ class ButtonComponent < ViewComponent::Base
194
+ include ViewComponentContrib::StyleVariants
195
+
196
+ style do
197
+ base {
198
+ %w[
199
+ font-medium bg-blue-500 text-white rounded-full
200
+ ]
201
+ }
202
+ variants {
203
+ color {
204
+ primary { %w[bg-blue-500 text-white] }
205
+ secondary { %w[bg-purple-500 text-white] }
206
+ }
207
+ size {
208
+ sm { "text-sm" }
209
+ md { "text-base" }
210
+ lg { "px-4 py-3 text-lg" }
211
+ }
212
+ }
213
+ defaults { {size: :md, color: :primary} }
214
+ end
215
+
216
+ attr_reader :size, :color
217
+
218
+ def initialize(size: nil, color: nil)
219
+ @size = size
220
+ @color = color
221
+ end
222
+ end
223
+ ```
224
+
225
+ Now, in the template, you can use the `#style` method and pass the variants to it:
226
+
227
+ ```erb
228
+ <button class="<%= style(size:, color:) %>">Click me</button>
229
+ ```
230
+
231
+ Passing `size: :lg` and `color: :secondary` would result in the following HTML:
232
+
233
+ ```html
234
+ <button class="font-medium bg-purple-500 text-white rounded-full px-4 py-3 text-lg">Click me</button>
235
+ ```
236
+
237
+ **NOTE:** If you pass `nil`, the default value would be used.
238
+
239
+ You can define multiple style sets in a single component:
240
+
241
+ ```ruby
242
+ class ButtonComponent < ViewComponent::Base
243
+ include ViewComponentContrib::StyleVariants
244
+
245
+ # default component styles
246
+ style do
247
+ # ...
248
+ end
249
+
250
+ style :image do
251
+ variants {
252
+ orient {
253
+ portrait { "w-32 h-32" }
254
+ landscape { "w-64 h-32" }
255
+ }
256
+ }
257
+ end
258
+ end
259
+ ```
260
+
261
+ And in the template:
262
+
263
+ ```erb
264
+ <div>
265
+ <button class="<%= style(size:, theme:) %>">Click me</button>
266
+ <img src="..." class="<%= style(:image, orient: :portrait) %>">
267
+ </div>
268
+ ```
269
+
270
+ Finally, you can inject into the class list compilation process to add your own logic:
271
+
272
+ ```ruby
273
+ class ButtonComponent < ViewComponent::Base
274
+ include ViewComponentContrib::StyleVariants
275
+
276
+ # You can provide either a proc or any other callable object
277
+ style_config.postprocess_with do |classes|
278
+ # classes is an array of CSS classes
279
+ TailwindMerge.call(classes).join(" ")
280
+ end
281
+ end
282
+ ```
283
+
183
284
  ## Organizing assets (JS, CSS)
184
285
 
185
- **NOTE*: This section assumes the usage of Webpack, Vite or other _frontend_ builder (e.g., not Sprockets).
286
+ **NOTE**: This section assumes the usage of Vite or Webpack. See [this discussion](https://github.com/palkan/view_component-contrib/discussions/14) for other options.
186
287
 
187
288
  We store JS and CSS files in the same sidecar folder:
188
289
 
@@ -203,6 +304,18 @@ import "./index.css"
203
304
 
204
305
  In the root of the `components` folder we have the `index.js` file, which loads all the components:
205
306
 
307
+ - With Vite:
308
+
309
+ ```js
310
+ // With Vite
311
+ import.meta.glob("./**/index.js").forEach((path) => {
312
+ const mod = await import(path);
313
+ mod.default();
314
+ });
315
+ ```
316
+
317
+ - With Webpack:
318
+
206
319
  ```js
207
320
  // components/index.js
208
321
  const context = require.context(".", true, /index.js$/)
@@ -211,12 +324,11 @@ context.keys().forEach(context);
211
324
 
212
325
  ### Using with StimulusJS
213
326
 
214
- You can define Stimulus controllers right in the `index.js` file using the following approach:
327
+ You can define Stimulus controllers right in the component folder in the `controller.js` file:
215
328
 
216
329
  ```js
217
- import "./index.css"
218
330
  // We reserve Controller for the export name
219
- import { Controller as BaseController } from "stimulus";
331
+ import { Controller as BaseController } from "@hotwired/stimulus";
220
332
 
221
333
  export class Controller extends BaseController {
222
334
  connect() {
@@ -225,20 +337,60 @@ export class Controller extends BaseController {
225
337
  }
226
338
  ```
227
339
 
228
- Then, we need to update the `components/index.js` to automatically register controllers:
340
+ Then, in your Stimulus entrypoint, you can load and register your component controllers as follows:
341
+
342
+ - With Vite:
229
343
 
230
344
  ```js
231
- // We recommend putting Stimulus application instance into its own
232
- // module, so you can use it for non-component controllers
345
+ import { Application } from "@hotwired/stimulus";
233
346
 
234
- // init/stimulus.js
347
+ const application = Application.start();
348
+
349
+ // Configure Stimulus development experience
350
+ application.debug = false;
351
+ window.Stimulus = application;
352
+
353
+ // Generic controllers
354
+ const genericControllers = import.meta.globEager(
355
+ "../controllers/**/*_controller.js"
356
+ );
357
+
358
+ for (let path in genericControllers) {
359
+ let module = genericControllers[path];
360
+ let name = path
361
+ .match(/controllers\/(.+)_controller\.js$/)[1]
362
+ .replaceAll("/", "-")
363
+ .replaceAll("_", "-");
364
+
365
+ application.register(name, module.default);
366
+ }
367
+
368
+ // Controllers from components
369
+ const controllers = import.meta.globEager(
370
+ "./../../app/frontend/components/**/controller.js"
371
+ );
372
+
373
+ for (let path in controllers) {
374
+ let module = controllers[path];
375
+ let name = path
376
+ .match(/app\/frontend\/components\/(.+)\/controller\.js$/)[1]
377
+ .replaceAll("/", "-")
378
+ .replaceAll("_", "-");
379
+ application.register(name, module.default);
380
+ }
381
+
382
+ export default application;
383
+ ```
384
+
385
+ - With Webpack:
386
+
387
+ ```js
235
388
  import { Application } from "stimulus";
236
389
  export const application = Application.start();
237
390
 
238
- // components/index.js
239
- import { application } from "../init/stimulus";
391
+ // ... other controllers
240
392
 
241
- const context = require.context(".", true, /index.js$/)
393
+ const context = require.context("./../../app/frontend/components/", true, /controllers.js$/)
242
394
  context.keys().forEach((path) => {
243
395
  const mod = context(path);
244
396
 
@@ -248,9 +400,10 @@ context.keys().forEach((path) => {
248
400
  // Convert path into a controller identifier:
249
401
  // example/index.js -> example
250
402
  // nav/user_info/index.js -> nav--user-info
251
- const identifier = path.replace(/^\.\//, '')
252
- .replace(/\/index\.js$/, '')
253
- .replace(/\//g, '--');
403
+ const identifier = path
404
+ .match(/app\/frontend\/components\/(.+)\/controller\.js$/)[1]
405
+ .replaceAll("/", "-")
406
+ .replaceAll("_", "-");
254
407
 
255
408
  application.register(identifier, mod.Controller);
256
409
  });
@@ -265,6 +418,8 @@ class ApplicationViewComponent
265
418
  def identifier
266
419
  @identifier ||= self.class.name.sub("::Component", "").underscore.split("/").join("--")
267
420
  end
421
+
422
+ alias_method :controller_name, :identifier
268
423
  end
269
424
  ```
270
425
 
@@ -272,7 +427,7 @@ And now in your template:
272
427
 
273
428
  ```erb
274
429
  <!-- component.html -->
275
- <div data-controller="<%= identifier %>">
430
+ <div data-controller="<%= controller_name %>">
276
431
  </div>
277
432
  ```
278
433
 
@@ -495,11 +650,6 @@ And the template looks like this now:
495
650
 
496
651
  You can use the `#wrapped` method on any component inherited from `ApplicationViewComponent` to wrap it automatically:
497
652
 
498
- ## ToDo list
499
-
500
- - Better preview tools (w/o JS deps 😉).
501
- - Hotwire-related extensions.
502
-
503
653
  ## License
504
654
 
505
655
  The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponentContrib
4
+ # Organize style in variants that can be combined.
5
+ # Inspired by https://www.tailwind-variants.org and https://cva.style/docs/getting-started/variants
6
+ #
7
+ # Example:
8
+ #
9
+ # class ButtonComponent < ViewComponent::Base
10
+ # include ViewComponentContrib::StyleVariants
11
+ #
12
+ # erb_template <<~ERB
13
+ # <button class="<%= style(size: 'sm', color: 'secondary') %>">Click me</button>
14
+ # ERB
15
+ #
16
+ # style do
17
+ # base {
18
+ # %w(
19
+ # font-medium bg-blue-500 text-white rounded-full
20
+ # )
21
+ # }
22
+ # variants {
23
+ # color {
24
+ # primary { %w(bg-blue-500 text-white) }
25
+ # secondary { %w(bg-purple-500 text-white) }
26
+ # }
27
+ # size {
28
+ # sm { "text-sm" }
29
+ # md { "text-base" }
30
+ # lg { "px-4 py-3 text-lg" }
31
+ # }
32
+ # }
33
+ # defaults { {size: :md, color: :primary} }
34
+ # end
35
+ #
36
+ # attr_reader :size, :color
37
+ #
38
+ # def initialize(size: :md, color: :primary)
39
+ # @size = size
40
+ # @color = color
41
+ # end
42
+ # end
43
+ #
44
+ module StyleVariants
45
+ class VariantBuilder
46
+ attr_reader :unwrap_blocks
47
+
48
+ def initialize(unwrap_blocks = true)
49
+ @unwrap_blocks = unwrap_blocks
50
+ @variants = {}
51
+ end
52
+
53
+ def build(&block)
54
+ instance_eval(&block)
55
+ @variants
56
+ end
57
+
58
+ def respond_to_missing?(name, include_private = false)
59
+ true
60
+ end
61
+
62
+ def method_missing(name, &block)
63
+ return super unless block_given?
64
+
65
+ @variants[name] = if unwrap_blocks
66
+ VariantBuilder.new(false).build(&block)
67
+ else
68
+ block
69
+ end
70
+ end
71
+ end
72
+
73
+ class StyleSet
74
+ def initialize(&init_block)
75
+ @base_block = nil
76
+ @defaults = {}
77
+ @variants = {}
78
+
79
+ instance_eval(&init_block) if init_block
80
+ end
81
+
82
+ def base(&block)
83
+ @base_block = block
84
+ end
85
+
86
+ def defaults(&block)
87
+ @defaults = block.call.freeze
88
+ end
89
+
90
+ def variants(&block)
91
+ @variants = VariantBuilder.new(true).build(&block)
92
+ end
93
+
94
+ def compile(**variants)
95
+ acc = Array(@base_block&.call || [])
96
+
97
+ @defaults.merge(variants.compact).each do |variant, value|
98
+ variant = @variants.dig(variant, value) || next
99
+ styles = variant.is_a?(::Proc) ? variant.call : variant
100
+ acc.concat(Array(styles))
101
+ end
102
+
103
+ acc
104
+ end
105
+ end
106
+
107
+ class StyleConfig # :nodoc:
108
+ DEFAULT_POST_PROCESSOR = ->(compiled) { compiled.join(" ") }
109
+
110
+ attr_reader :postprocessor
111
+
112
+ def initialize
113
+ @styles = {}
114
+ @postprocessor = DEFAULT_POST_PROCESSOR
115
+ end
116
+
117
+ def define(name, &block)
118
+ styles[name] = StyleSet.new(&block)
119
+ end
120
+
121
+ def compile(name, **variants)
122
+ styles[name]&.compile(**variants).then do |compiled|
123
+ next unless compiled
124
+
125
+ postprocess(compiled)
126
+ end
127
+ end
128
+
129
+ # Allow defining a custom postprocessor
130
+ def postprocess_with(callable = nil, &block)
131
+ @postprocessor = callable || block
132
+ end
133
+
134
+ private
135
+
136
+ attr_reader :styles
137
+
138
+ def postprocess(compiled) = postprocessor.call(compiled)
139
+ end
140
+
141
+ def self.included(base)
142
+ base.extend ClassMethods
143
+ end
144
+
145
+ module ClassMethods
146
+ # Returns the name of the default style set based on the class name:
147
+ # MyComponent::Component => my_component
148
+ # Namespaced::MyComponent => my_component
149
+ def default_style_name
150
+ @default_style_name ||= name.demodulize.sub(/(::Component|Component)$/, "").underscore.presence || "component"
151
+ end
152
+
153
+ def style(name = default_style_name, &block)
154
+ style_config.define(name.to_sym, &block)
155
+ end
156
+
157
+ def style_config
158
+ @style_config ||=
159
+ if superclass.respond_to?(:style_config)
160
+ superclass.style_config.dup
161
+ else
162
+ StyleConfig.new
163
+ end
164
+ end
165
+ end
166
+
167
+ def style(name = self.class.default_style_name, **variants)
168
+ self.class.style_config.compile(name.to_sym, **variants)
169
+ end
170
+ end
171
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ViewComponentContrib # :nodoc:all
4
- VERSION = "0.1.6"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -11,6 +11,7 @@ module ViewComponentContrib
11
11
  autoload :TranslationHelper, "view_component_contrib/translation_helper"
12
12
  autoload :WrapperComponent, "view_component_contrib/wrapper_component"
13
13
  autoload :WrappedHelper, "view_component_contrib/wrapped_helper"
14
+ autoload :StyleVariants, "view_component_contrib/style_variants"
14
15
 
15
16
  autoload :Base, "view_component_contrib/base"
16
17
  autoload :Preview, "view_component_contrib/preview"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component-contrib
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladimir Dementyev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-11-07 00:00:00.000000000 Z
11
+ date: 2023-11-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: view_component
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: 0.12.0
33
+ version: 0.15.0
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: 0.12.0
40
+ version: 0.15.0
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: bundler
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -156,6 +156,7 @@ files:
156
156
  - lib/view_component_contrib/preview/default_template.rb
157
157
  - lib/view_component_contrib/preview/sidecarable.rb
158
158
  - lib/view_component_contrib/railtie.rb
159
+ - lib/view_component_contrib/style_variants.rb
159
160
  - lib/view_component_contrib/translation_helper.rb
160
161
  - lib/view_component_contrib/version.rb
161
162
  - lib/view_component_contrib/wrapped_helper.rb
@@ -177,7 +178,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
177
178
  requirements:
178
179
  - - ">="
179
180
  - !ruby/object:Gem::Version
180
- version: '2.6'
181
+ version: '2.7'
181
182
  required_rubygems_version: !ruby/object:Gem::Requirement
182
183
  requirements:
183
184
  - - ">="