view_component-contrib 0.1.6 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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
  - - ">="