cva-ruby 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4322ef5c0b4f017974740f1bf2225df026a5a25d3454faa6932c123360027581
4
+ data.tar.gz: 4e63179ef8681baa49d0706bc940bcbf68f11a7c8081d4d7c8cf4b90fa2c15f8
5
+ SHA512:
6
+ metadata.gz: c406ea44133b09a583787b2394839e54fcae64bcd5ca8ea7872d82ddde757f882abe1e6989d55947c46caee27dd6941bfe6f2722204f378ea59b2ee014ce3266
7
+ data.tar.gz: 65f610c2141e1218e682430499f6d069cb32257cdb538a2033a66a79265159d165bf73265be0277a6e786613ef77262d642e0581535a77e980ef5aa2c48fff4f
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-10-28
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Andrii Malyshko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # cva (Class Variance Authority)
2
+
3
+ This is a Ruby port of [cva.style](https://github.com/joe-bell/cva) JS library.
4
+ It is a tool for conditionally concatenating CSS classes.
5
+
6
+ The CVA (Class Variance Authority) pattern is based on the idea that there's
7
+ a limited number of states (variants) your CSS component can hold. Each state
8
+ is described with a CSS class or a set of classes. You then provide a set of
9
+ variants as an input, and get back a final list of CSS classes.
10
+
11
+ The library aims to be both CSS framework and view renderer agnostic. You can
12
+ use it with atomic CSS (Tailwind), entity-based CSS (BEM), in component based
13
+ rendering (ViewComponent, Phlex) or plain Rails/Hanami etc views.
14
+
15
+ It should not be confused with the general purpose CSS concatenators like
16
+ [clsx](https://github.com/svyatov/clsx-ruby) or Rails `class_names`. Those
17
+ accept raw CSS classes as an input, whereas this library requires you to work
18
+ with a limited number of design tokens (variants).
19
+
20
+ ## Status
21
+
22
+ Production ready.
23
+
24
+ ## Installation
25
+
26
+ Install the gem and add to the application's Gemfile by executing:
27
+
28
+ ```bash
29
+ bundle add cva-ruby
30
+ ```
31
+
32
+ If bundler is not being used to manage dependencies, install the gem by executing:
33
+
34
+ ```bash
35
+ gem install cva-ruby
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ### ViewComponent
41
+
42
+ ```ruby
43
+ # app/components/button_component.rb
44
+
45
+ class ButtonComponent < ViewComponent::Base
46
+ # the helper generates `cva_class_name` method
47
+ include Cva::ModelHelper.new(
48
+ base: "button",
49
+ variants: {
50
+ intent: {
51
+ primary: "button--primary",
52
+ secondary: "button--secondary"
53
+ }
54
+ }
55
+ )
56
+
57
+ # calling `cva_class_name(intent: :primary)` returns "button button--primary"
58
+ def initialize(title:, intent:)
59
+ @title = title
60
+ @class_name = cva_class_name(intent:)
61
+ end
62
+ end
63
+ ```
64
+
65
+ ```erb
66
+ <% # app/components/button_component.html.erb %>
67
+
68
+ <button title="<%= @title %>" class="<%= @class_name %>">
69
+ <%= content %>
70
+ </button>
71
+ ```
72
+
73
+ ```erb
74
+ <% # app/views/posts/index.html.erb %>
75
+
76
+ <%= render(ButtonComponent.new(title: "my title", intent: :primary)) do %>
77
+ Hello, Button!
78
+ <% end %>
79
+ ```
80
+
81
+ Rendering the view gets you:
82
+
83
+ ```html
84
+ <button title="my title" class="button button--primary">
85
+ Hello, Button!
86
+ </button>
87
+ ```
88
+
89
+ ### Plain views
90
+
91
+ ```ruby
92
+ # config/initializers/cva.rb
93
+
94
+ class AppCva
95
+ # default `cva_class_name` method name is overridden
96
+ include Cva::ModelHelper.new(
97
+ method_name: "button",
98
+ base: "button",
99
+ variants: {
100
+ intent: {
101
+ primary: "button--primary",
102
+ secondary: "button--secondary"
103
+ }
104
+ }
105
+ )
106
+
107
+ # you can include as many as you need as long as method names do not collide
108
+ include Cva::ModelHelper.new(
109
+ method_name: "tooltip",
110
+ base: "tooltip",
111
+ variants: {
112
+ position: {
113
+ top: "tooltip--top",
114
+ left: "tooltip--left"
115
+ }
116
+ }
117
+ )
118
+ end
119
+ ```
120
+
121
+ ```ruby
122
+ # app/helpers/application_helper.rb
123
+
124
+ module ApplicationHelper
125
+ # the method name can be anything really
126
+ def cva
127
+ @cva ||= AppCva.new
128
+ end
129
+ end
130
+ ```
131
+
132
+ ```erb
133
+ <% # app/views/application/_button.html.erb %>
134
+
135
+ <button class="<%= cva.button(intent: :primary) %>">
136
+ Oh hello!
137
+ </button>
138
+ ```
139
+
140
+ ### With Tailwind
141
+
142
+ When using Tailwind CSS, you will need a custom class merger that skips
143
+ conficting classes. Pick one of the existing libraries:
144
+
145
+ - https://github.com/abuisman/tail_merge
146
+ - https://github.com/gjtorikian/tailwind_merge
147
+
148
+ An example integration:
149
+
150
+ ```ruby
151
+ # config/initializers/cva.rb
152
+
153
+ require "tail_merge"
154
+
155
+ class CvaTailwindMerger
156
+ def call(classes)
157
+ TailMerge.merge(classes)
158
+ end
159
+ end
160
+
161
+ Cva::Config.merger_class = CvaTailwindMerger
162
+ ```
163
+
164
+ ## Documentation
165
+
166
+ Start with [Component](https://rubydoc.info/gems/cva-ruby/Cva/Component), then move
167
+ to [ModelHelper](https://rubydoc.info/gems/cva-ruby/Cva/ModelHelper).
168
+
169
+ ## Similar works
170
+
171
+ - https://github.com/defvova/cva_rails
172
+ - https://github.com/palkan/view_component-contrib#style-variants
173
+
174
+ Compared to them, this library has less moving parts and no dependencies
175
+ (still, you will need a custom merger for usage with Tailwind).
176
+
177
+ The functionality is pretty much the same among all of the libraries.
178
+
179
+ ## Development
180
+
181
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
182
+ `bundle exec rake test` to run the tests. You can also run `bin/console` for an
183
+ interactive prompt that will allow you to experiment.
184
+
185
+ See `bundle exec rake -T` for the list of all available commands.
186
+
187
+ ## Issues
188
+
189
+ Use [Github issues](https://github.com/nashbridges/cva-ruby/issues) to file a ticket.
190
+
191
+ ## Contributing
192
+
193
+ This project is open source and close contribution. I don't have mental
194
+ capacity to accept features I don't need.
195
+
196
+ You are still allowed to reuse and/or modify the code as you wish.
197
+
198
+ ## License
199
+
200
+ The gem is available as open source under the terms of the [MIT
201
+ License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
11
+
12
+ desc "Continuously rerun tests when source/test files change"
13
+ task "test:watch" do
14
+ sh "bin/retest --notify"
15
+ end
16
+
17
+ desc "Start YARD server at localhost:8808"
18
+ task "doc:server" do
19
+ sh "bin/yard server --reload"
20
+ end
data/lib/cva/cache.rb ADDED
@@ -0,0 +1,44 @@
1
+ module Cva
2
+ # A default implementation of cache for {Component#render}. Based on Hash.
3
+ #
4
+ # @note Each {Component} instance gets its own {Cache} instance, i.e. the
5
+ # cache is not shared.
6
+ #
7
+ # Component#render is quite fast. The caching is performed only to avoid
8
+ # useless work: {Component}'s state is immutable, and there's not many
9
+ # permutations you can get from it.
10
+ #
11
+ # When the default implementation is used (which is in-memory one), make sure
12
+ # a {Component} instance is long-lived, so that it can benefit from caching.
13
+ # With {ModelHelper} this is true by default, no extra action is needed.
14
+ #
15
+ # In case you might want to override caching, e.g. to skip it during testing,
16
+ # the best way to do it is via global config:
17
+ #
18
+ # ```
19
+ # # config/initializers/cva.rb
20
+ #
21
+ # class CvaNoCache
22
+ # def fetch(value)
23
+ # value
24
+ # end
25
+ # end
26
+ #
27
+ # Cva::Config.cache_class = CvaNoCache
28
+ # ```
29
+ class Cache
30
+ def initialize
31
+ @storage = {}
32
+ end
33
+
34
+ # @yield executes the block when cache is missed
35
+ # @yieldreturn value that should be cached
36
+ def fetch(value)
37
+ @storage.fetch(value) do
38
+ result = yield
39
+ @storage[value] = result
40
+ result
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,614 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Cva
6
+ # The class builds a string list of CSS classes for a CSS component. First,
7
+ # you initialize the instance with all the states (aka variants) that the
8
+ # component can be in, along with the corresponding classes. Then you pass
9
+ # exact variants into {#render}, which gets you back a string of concatenated
10
+ # classes matching the state.
11
+ #
12
+ # The class is not responsible for rendering component's markup. You handle
13
+ # the latter in a template and/or view component.
14
+ #
15
+ # The class is immutable. {#render} does not perform side effects, except
16
+ # for looking into cache (see {Cache}).
17
+ #
18
+ # @example
19
+ # button = Cva::Component.new(
20
+ # base: "button",
21
+ # variants: {
22
+ # size: {
23
+ # small: "button--small",
24
+ # medium: "button--medium",
25
+ # large: "button--large",
26
+ # }
27
+ # }
28
+ # )
29
+ #
30
+ # button.render(size: :large) # => "button button--large"
31
+ #
32
+ class Component
33
+ # @!visibility private
34
+ VARIANTS_VALIDATION_MESSAGE = <<~'EOF'
35
+ :variants should be a Hash in a form of
36
+ {
37
+ variant1: {
38
+ condition1: 'condition-1-class', # a string
39
+ condition2: ['condition-2-class', 'another-class'], # an array of strings is also fine
40
+ condition3: ->(base) { "#{base}_class" } # or a proc-like object accepting base class and returning a string or an array of strings
41
+ condition4: nil, # `nil` means a no-op condition
42
+ },
43
+ variant2: {...},
44
+ }
45
+ EOF
46
+
47
+ # @!visibility private
48
+ BASE_VALIDATION_MESSAGE = <<~EOF
49
+ base should be a string, or an array of strings, or nil
50
+ EOF
51
+
52
+ # @!visibility private
53
+ BASE_IS_REQUIRED_FOR_PROC_VALIDATION_MESSAGE = <<~EOF
54
+ base is required when condition is proc-like, either explicitly, or implicitly
55
+ (via defaults in :base config)
56
+ EOF
57
+
58
+ # @!visibility private
59
+ SINGLE_ARGUMENT_PROC_VALIDATION_MESSAGE = <<~EOF
60
+ if condition is proc-like, it has to accept a single string argument (the base class will be passed during evaluation)
61
+ EOF
62
+
63
+ # @!visibility private
64
+ DEFAULT_VARIANTS_VALIDATION_MESSAGE = <<~EOF
65
+ :default_variants should be a Hash in a form of
66
+ {
67
+ variant1: :condition1_for_variant1,
68
+ variant2: :condition3_for_variant2,
69
+ }
70
+ where both variants and their conditions should be already known (defined by you
71
+ in :variants config)
72
+ EOF
73
+
74
+ # @!visibility private
75
+ COMPOUND_VARIANTS_VALIDATION_MESSAGE = <<~EOF
76
+ :compound_variants should be an array where each element is a Hash in form of
77
+ {
78
+ variant1: :condition1_for_variant1, # AND condition1
79
+ variant2: [:condition2_for_variant2, :condition3_for_variant2], # AND (condition2 OR condition3)
80
+ class: 'compound-variant-class' # can also be an array of strings, or proc returning string or array of strings
81
+ }
82
+ where both variants and their conditions should be already known (defined by you
83
+ in :variants config).
84
+ EOF
85
+
86
+ # @!visibility private
87
+ RENDER_VARIANTS_VALIDATION_MESSAGE = <<~EOF
88
+ keyword arguments should be in a form of
89
+ variant1: :condition1_for_variant1,
90
+ variant2: :condition3_for_variant2,
91
+ where both variants and their conditions should be already known (defined by you
92
+ in :variants config).
93
+ Input should provide all the variants, either explicitly, or implicitly (via defaults
94
+ in :default_variants config).
95
+ EOF
96
+
97
+ # A map of variants, default_variants, compound_variants and base
98
+ attr_reader :config
99
+
100
+ # A cache instance to hold results of {#render} invocation. See {Cache}
101
+ # @return [#fetch]
102
+ attr_reader :cache
103
+
104
+ # A CSS class merger instance. See {Merger}
105
+ # @return [#call]
106
+ attr_reader :merger
107
+
108
+ # @param variants [{VariantKey => {OptionKey => String, Array<String>,
109
+ # #call, nil}}] a map of variants and their options to CSS classes. \
110
+ # A variant combines a group of options, so the map is nested. Think of
111
+ # a variant as a dimension of states in the CSS component. For a button
112
+ # component it can be "size" (small, medium, large) or "visual"
113
+ # (outlined, solid). Variant and option keys can be anything, but usually
114
+ # symbols are used.
115
+ # ```
116
+ # {
117
+ # visual: {
118
+ # outlined: "button--outlined",
119
+ # solid: "button--solid",
120
+ # }
121
+ # }
122
+ # ```
123
+ # When a variant option resolves to multiple classes, you can specify
124
+ # them as an array of strings. It has the same effect as you would pass
125
+ # the classes as a long string list, but allows for a nice formatting:
126
+ # ```
127
+ # {
128
+ # visual: {
129
+ # outlined: [
130
+ # "border-1",
131
+ # "border-color-blue",
132
+ # "border-radius-2",
133
+ # ]
134
+ # }
135
+ # }
136
+ # ```
137
+ # or even nicer:
138
+ # ```
139
+ # {
140
+ # visual: {
141
+ # outlined: %w[
142
+ # border-1
143
+ # border-color-blue
144
+ # border-radius-2
145
+ # ]
146
+ # }
147
+ # }
148
+ # ```
149
+ # Passing `nil` as a class value means this variant option is an empty
150
+ # one. This is commonly used with boolean options when only `true`
151
+ # corresponds to something meaningful (notice the usage of `=>`, so that
152
+ # the keys are booleans, and not symbols):
153
+ # ```
154
+ # {
155
+ # badge: {
156
+ # true => "button--badge",
157
+ # false => nil, # no class is added to the output for this option
158
+ # }
159
+ # }
160
+ # ```
161
+ # If you have more than 2 options with one being empty, a common approach
162
+ # is to name it as "unset":
163
+ # ```
164
+ # {
165
+ # visual: {
166
+ # unset: nil,
167
+ # outlined: "button--outlined",
168
+ # solid: "button--solid",
169
+ # }
170
+ # }
171
+ # ```
172
+ # You can pass lambda (or generally any object that responds to #call) as
173
+ # a class name. When resolved, the lambda receives base class as an input
174
+ # and should return a class or an array of classes. This is only useful in
175
+ # cases when you override base class in #render.
176
+ # ```
177
+ # {
178
+ # visual: {
179
+ # outlined: ->(base) { "#{base}--outlined" },
180
+ # solid: ->(base) { "#{base}--solid" },
181
+ # }
182
+ # }
183
+ # ```
184
+ #
185
+ # @param base [String, Array<String>] a CSS class, or an array of classes
186
+ # that should be included into every output. You can override it in #render.
187
+ #
188
+ # @param default_variants [{VariantKey => OptionKey}] a map of variants and
189
+ # their options that should be used by default in every output. You can
190
+ # override it in #render.
191
+ # ```
192
+ # {
193
+ # visual: :solid,
194
+ # size: :medium,
195
+ # badge: false,
196
+ # }
197
+ # ```
198
+ #
199
+ # @param compound_variants [Array<{VariantKey, :class => OptionKey,
200
+ # Array<OptionKey>, String, Array<String>, #call}>] a list of CSS classes
201
+ # that should be applied only for a specific combination of variants.\
202
+ # The class is passed under the `:class` key, and it allows the same format as
203
+ # in `:variants` (a string, an array of string, or an object that responds
204
+ # to #call). \
205
+ # Here the class is added to the output when both `:size` is `:large` and
206
+ # `:badge` is `true`:
207
+ # ```
208
+ # [{
209
+ # size: :large, # if size is `:small` or `:medium`, this compound variant is not applied
210
+ # badge: true, # or if badge is `false`, this compound variant is off too
211
+ # class: "button--large-with-badge",
212
+ # }]
213
+ # ```
214
+ # You can pass an array of option keys, that means any of the options is fine:
215
+ # ```
216
+ # [{
217
+ # size: [:small, :medium],
218
+ # visual: :solid,
219
+ # class: "button--small-solid-or-medium-solid",
220
+ # }]
221
+ # ```
222
+ #
223
+ # @param cache [#fetch] (see {#cache})
224
+ #
225
+ # @param merger [#call] (see {#merger})
226
+ #
227
+ # @raise [ArgumentError] if input does not match the schema
228
+ def initialize(
229
+ variants:,
230
+ base: nil,
231
+ default_variants: nil,
232
+ compound_variants: nil,
233
+ cache: Config.cache_class.new,
234
+ merger: Config.merger_class.new
235
+ )
236
+ @config = {}
237
+ config[:variants] = validate_and_coerce_config_variants!(variants)
238
+ config[:base] = validate_and_coerce_config_base!(base)
239
+ config[:default_variants] = validate_and_coerce_config_default_variants!(default_variants)
240
+ config[:compound_variants] = validate_and_coerce_config_compound_variants!(compound_variants)
241
+ @cache = cache
242
+ @merger = merger
243
+ end
244
+
245
+ # @param base [String, Array<String>] overrides (or sets) base CSS class or classes
246
+ #
247
+ # @param variants [{VariantKey => OptionKey}] currently chosen variants. Overrides
248
+ # `:default_variants` if those were set in {#initialize}
249
+ #
250
+ # @return [String] space-delimitered list of CSS classes.
251
+ #
252
+ # @raise [ArgumentError] if base and/or variants do not match the schema, or some
253
+ # variant keys are missing
254
+ #
255
+ # @example Simple variants
256
+ # button = Cva::Component.new(
257
+ # variants: {
258
+ # size: {
259
+ # small: "button--small",
260
+ # medium: "button--medium",
261
+ # large: "button--large",
262
+ # },
263
+ # visual: {
264
+ # outlined: "button--outlined",
265
+ # solid: "button--solid",
266
+ # }
267
+ # }
268
+ # )
269
+ #
270
+ # button.render(size: :small, visual: :solid) # => "button--small button--solid"
271
+ # button.render(visual: :solid, size: :small) # => "button--small button--solid" <-- order is preserved
272
+ # button.render(size: :medium) # => ERROR! `:visual` variant is missing
273
+ # button.render(size: :medium, visual: :text) # => ERROR! `:visual` variant option was not declared in config
274
+ #
275
+ # @example Base
276
+ # button = Cva::Component.new(
277
+ # base: "button",
278
+ # variants: {
279
+ # visual: {
280
+ # outlined: "button--outlined",
281
+ # solid: "button--solid",
282
+ # }
283
+ # }
284
+ # )
285
+ #
286
+ # button.render(visual: :outlined) # => "button button--outlined"
287
+ # button.render(visual: :solid) # => "button button--solid"
288
+ #
289
+ # @example Array of CSS classes
290
+ # button = Cva::Component.new(
291
+ # variants: {
292
+ # visual: {
293
+ # outlined: [
294
+ # "border-1",
295
+ # "border-color-blue",
296
+ # "border-radius-2",
297
+ # ]
298
+ # }
299
+ # }
300
+ # )
301
+ #
302
+ # button.render(visual: :outlined) # => "border-1 border-color-blue border-radius-2"
303
+ #
304
+ # @example `nil` as CSS class
305
+ # button = Cva::Component.new(
306
+ # variants: {
307
+ # visual: {
308
+ # outlined: "button--outlined",
309
+ # solid: "button--solid",
310
+ # },
311
+ # badge: {
312
+ # true => "button--badge",
313
+ # false => nil,
314
+ # }
315
+ # }
316
+ # )
317
+ #
318
+ # button.render(visual: outlined, badge: true) # => "button--outlined button--badge"
319
+ # button.render(visual: outlined, badge: false) # => "button--outlined"
320
+ #
321
+ # @example Default variants
322
+ # button = Cva::Component.new(
323
+ # variants: {
324
+ # size: {
325
+ # small: "button--small",
326
+ # medium: "button--medium",
327
+ # large: "button--large",
328
+ # },
329
+ # visual: {
330
+ # outlined: "button--outlined",
331
+ # solid: "button--solid",
332
+ # }
333
+ # },
334
+ # default_variants: {
335
+ # size: :medium,
336
+ # visual: :solid,
337
+ # }
338
+ # )
339
+ #
340
+ # button.render # => "button--medium button--solid"
341
+ # button.render(size: :large) # => "button--large button--solid"
342
+ # button.render(size: :small, visual: :outlined) # => "button--small button--outlined"
343
+ #
344
+ # @example Skip variant with the "unset" pattern
345
+ # button = Cva::Component.new(
346
+ # variants: {
347
+ # size: {
348
+ # small: "button--small",
349
+ # medium: "button--medium",
350
+ # large: "button--large",
351
+ # },
352
+ # visual: {
353
+ # unset: nil,
354
+ # outlined: "button--outlined",
355
+ # solid: "button--solid",
356
+ # }
357
+ # },
358
+ # default_variants: {
359
+ # size: :medium,
360
+ # visual: :solid,
361
+ # }
362
+ # )
363
+ #
364
+ # button.render # => "button--medium button--solid"
365
+ # button.render(visual: :unset) # => "button--medium"
366
+ #
367
+ # @example Compound variants
368
+ # button = Cva::Component.new(
369
+ # variants: {
370
+ # size: {
371
+ # small: "button--small",
372
+ # medium: "button--medium",
373
+ # large: "button--large",
374
+ # },
375
+ # visual: {
376
+ # outlined: "button--outlined",
377
+ # solid: "button--solid",
378
+ # }
379
+ # },
380
+ # compound_variants: [{
381
+ # size: :small,
382
+ # visual: :solid,
383
+ # class: "button--small-and-solid",
384
+ # }]
385
+ # )
386
+ #
387
+ # button.render(size: :small, visual: :solid) # => "button--small button--solid button--small-and-solid"
388
+ # button.render(size: :medium, visual: :solid) # => "button--medium button--solid"
389
+ # button.render(size: :small, visual: :outlined) # => "button--small button--outlined"
390
+ #
391
+ # @example Compound variants with variant options array
392
+ # button = Cva::Component.new(
393
+ # variants: {
394
+ # size: {
395
+ # small: "button--small",
396
+ # medium: "button--medium",
397
+ # large: "button--large",
398
+ # },
399
+ # visual: {
400
+ # outlined: "button--outlined",
401
+ # solid: "button--solid",
402
+ # }
403
+ # },
404
+ # compound_variants: [{
405
+ # size: [:small, :medium],
406
+ # visual: :solid,
407
+ # class: "button--small-or-medium-size-and-solid",
408
+ # }]
409
+ # )
410
+ #
411
+ # button.render(size: :small, visual: :solid) # => "button--small button--solid button--small-or-medium-size-and-solid"
412
+ # button.render(size: :medium, visual: :solid) # => "button--medium button--solid button--small-or-medium-size-and-solid"
413
+ # button.render(size: :small, visual: :outlined) # => "button--small button--outlined"
414
+ #
415
+ # @example lamdba as CSS class + base override
416
+ # button = Cva::Component.new(
417
+ # base: "button",
418
+ # variants: {
419
+ # visual: {
420
+ # outlined: ->(base) { "#{base}--outlined" },
421
+ # solid: ->(base) { "#{base}--solid" },
422
+ # }
423
+ # }
424
+ # )
425
+ #
426
+ # button.render(visual: outlined) # => "button button--outlined"
427
+ # button.render("superbutton", visual: outlined) # => "superbutton superbutton--outlined"
428
+ def render(base = nil, **variants)
429
+ cache.fetch([base, variants]) do
430
+ final_base = validate_and_coerce_render_base!(base)
431
+ input_variants = validate_and_coerce_render_variants!(variants)
432
+
433
+ class_names = [
434
+ final_base
435
+ ]
436
+
437
+ config[:variants].each_pair do |config_variant, config_conditions|
438
+ condition = input_variants.fetch(config_variant)
439
+ class_name = config_conditions.fetch(condition)
440
+ class_names << resolve_potentially_callable_class_name!(class_name, base: final_base)
441
+ end
442
+
443
+ config[:compound_variants].each do |compound_variant|
444
+ compound_variant => {class: class_name, **variant_conditions}
445
+ if variant_conditions.all? { |variant, conditions| conditions.include?(input_variants.fetch(variant)) }
446
+ class_names << resolve_potentially_callable_class_name!(class_name, base: final_base)
447
+ end
448
+ end
449
+
450
+ merger.call(class_names.flatten)
451
+ end
452
+ end
453
+
454
+ private def validate_non_empty_hash!(hash)
455
+ case hash
456
+ in {**rest} if rest.size > 0
457
+ hash
458
+ end
459
+ end
460
+
461
+ private def validate_and_coerce_class_name!(a_class, allow_empty: true)
462
+ case a_class
463
+ in String
464
+ Array(a_class)
465
+ in [*strings] if strings.all?(String) && (allow_empty || strings.size > 0)
466
+ a_class
467
+ end
468
+ end
469
+
470
+ private def validate_and_coerce_potentially_callable_class_name!(a_class, allow_empty: true)
471
+ if a_class.respond_to?(:call)
472
+ begin
473
+ result = a_class.call("TEST PROBE")
474
+ rescue ArgumentError
475
+ raise ArgumentError, SINGLE_ARGUMENT_PROC_VALIDATION_MESSAGE
476
+ end
477
+
478
+ validate_and_coerce_class_name!(result, allow_empty:)
479
+ a_class
480
+ else
481
+ validate_and_coerce_class_name!(a_class, allow_empty:)
482
+ end
483
+ end
484
+
485
+ private def validate_variants_conditions!(variants_conditions)
486
+ if !variants_conditions.each_pair.all? { |variant, condition| config[:variants].dig(variant, condition) }
487
+ raise NoMatchingPatternError
488
+ end
489
+ end
490
+
491
+ private def validate_and_coerce_config_variants!(input)
492
+ validate_non_empty_hash!(input)
493
+
494
+ input.transform_values do |conditions|
495
+ validate_non_empty_hash!(conditions)
496
+
497
+ conditions.transform_values do |value|
498
+ if value.nil?
499
+ []
500
+ else
501
+ validate_and_coerce_potentially_callable_class_name!(value)
502
+ end
503
+ end
504
+ end
505
+ rescue NoMatchingPatternError
506
+ raise ArgumentError, VARIANTS_VALIDATION_MESSAGE
507
+ end
508
+
509
+ private def validate_and_coerce_config_base!(input)
510
+ if input.nil?
511
+ return []
512
+ end
513
+
514
+ validate_and_coerce_class_name!(input)
515
+ rescue NoMatchingPatternError
516
+ raise ArgumentError, BASE_VALIDATION_MESSAGE
517
+ end
518
+
519
+ private def validate_and_coerce_config_default_variants!(input)
520
+ if input.nil?
521
+ return {}
522
+ end
523
+
524
+ input => Hash
525
+ validate_variants_conditions!(input)
526
+
527
+ input
528
+ rescue NoMatchingPatternError
529
+ raise ArgumentError, DEFAULT_VARIANTS_VALIDATION_MESSAGE
530
+ end
531
+
532
+ private def validate_and_coerce_config_variants_conditions!(variants_conditions)
533
+ if variants_conditions.size == 0
534
+ raise NoMatchingPatternError
535
+ end
536
+
537
+ coerced_variant_condition_pairs = variants_conditions.map do |variant, condition|
538
+ coerced_condition = Array(condition)
539
+
540
+ if coerced_condition.size == 0
541
+ raise NoMatchingPatternError
542
+ end
543
+
544
+ if !coerced_condition.all? { |condition| config[:variants].dig(variant, condition) }
545
+ raise NoMatchingPatternError
546
+ end
547
+
548
+ [variant, coerced_condition]
549
+ end
550
+
551
+ coerced_variant_condition_pairs.flatten(0).to_h
552
+ end
553
+
554
+ private def validate_and_coerce_config_compound_variants!(input)
555
+ if input.nil?
556
+ return []
557
+ end
558
+
559
+ input => Array
560
+ input.map do |compound_variant|
561
+ compound_variant => {class: class_name, **variant_conditions}
562
+
563
+ final_variants_conditions = validate_and_coerce_config_variants_conditions!(variant_conditions)
564
+ final_class = validate_and_coerce_potentially_callable_class_name!(class_name, allow_empty: false)
565
+
566
+ {class: final_class, **final_variants_conditions}
567
+ end
568
+ rescue NoMatchingPatternError
569
+ raise ArgumentError, COMPOUND_VARIANTS_VALIDATION_MESSAGE
570
+ end
571
+
572
+ private def validate_and_coerce_render_base!(base)
573
+ if base.nil?
574
+ return config[:base]
575
+ end
576
+
577
+ validate_and_coerce_class_name!(base)
578
+ rescue NoMatchingPatternError
579
+ raise ArgumentError, BASE_VALIDATION_MESSAGE
580
+ end
581
+
582
+ private def validate_and_coerce_render_variants!(input)
583
+ validate_variants_conditions!(input)
584
+
585
+ full_variants_conditions = {
586
+ **config[:default_variants],
587
+ **input
588
+ }
589
+
590
+ if full_variants_conditions.keys.sort != config[:variants].keys.sort
591
+ raise NoMatchingPatternError
592
+ end
593
+
594
+ full_variants_conditions
595
+ rescue NoMatchingPatternError
596
+ raise ArgumentError, RENDER_VARIANTS_VALIDATION_MESSAGE
597
+ end
598
+
599
+ private def resolve_potentially_callable_class_name!(class_name, base:)
600
+ if class_name.respond_to?(:call)
601
+ if base.size == 0
602
+ raise ArgumentError, BASE_IS_REQUIRED_FOR_PROC_VALIDATION_MESSAGE
603
+ end
604
+
605
+ base.map do |each_base|
606
+ resolved_class_name = class_name.call(each_base)
607
+ resolved_class_name
608
+ end
609
+ else
610
+ class_name
611
+ end
612
+ end
613
+ end
614
+ end
data/lib/cva/config.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "merger"
4
+ require_relative "cache"
5
+
6
+ module Cva
7
+ # Allows to define {Component#initialize} dependencies in a global way.
8
+ module Config
9
+ class << self
10
+ # instance of this class will be injected as `:merger`. Defaults to {Merger}.
11
+ attr_accessor :merger_class
12
+
13
+ # instance of this class will be injected as `:cache`. Defaults to {Cache}.
14
+ attr_accessor :cache_class
15
+ end
16
+
17
+ self.merger_class = Merger
18
+ self.cache_class = Cache
19
+ end
20
+ end
data/lib/cva/merger.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cva
4
+ # A default implementation of CSS classes merger for {Component#render}. The logic is simple:
5
+ # input elements are joined with space as is.
6
+ #
7
+ # When using the library with Tailwind, you'll need a merger with deduplicating behavior.
8
+ # You may want to wrap one of the existing libraries:
9
+ #
10
+ # - https://github.com/abuisman/tail_merge
11
+ # - https://github.com/gjtorikian/tailwind_merge
12
+ #
13
+ # An example integration:
14
+ #
15
+ # ```
16
+ # # config/initializers/cva.rb
17
+ #
18
+ # require "tail_merge"
19
+ #
20
+ # class CvaTailwindMerger
21
+ # def call(classes)
22
+ # TailMerge.merge(classes)
23
+ # end
24
+ # end
25
+ #
26
+ # Cva::Config.merger_class = CvaTailwindMerger
27
+ # ```
28
+ class Merger
29
+ # @param classes [Array<String>] a list of CSS classes resolved in {Component#render}
30
+ # @return [String] final string of CSS classes
31
+ def call(classes)
32
+ classes.join(" ")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "component"
4
+
5
+ module Cva
6
+ # A syntax-sugar module. Removes boilerplate of instantiating and rendering
7
+ # the {Component} within a class, e.g. a ViewComponent.
8
+ #
9
+ # Instead of resorting to global variables (which is fine in case you are
10
+ # careful with namespacing):
11
+ #
12
+ # ```
13
+ # class ButtonComponent < ViewComponent::Base
14
+ # BUTTON = Cva::Component.new(
15
+ # base: "button",
16
+ # variants: {
17
+ # intent: {
18
+ # primary: "button--primary",
19
+ # secondary: "button--secondary"
20
+ # }
21
+ # }
22
+ # )
23
+ #
24
+ # def initialize(title:, intent:)
25
+ # @title = title
26
+ # @class_name = BUTTON.render(intent:)
27
+ # end
28
+ # end
29
+ # ```
30
+ #
31
+ # the same can be written as:
32
+ #
33
+ # ```
34
+ # class ButtonComponent < ViewComponent::Base
35
+ # # The helper generates `cva_class_name` method, which delegates to
36
+ # # Component#render under the hood
37
+ # include Cva::ModelHelper.new(
38
+ # base: "button",
39
+ # variants: {
40
+ # intent: {
41
+ # primary: "button--primary",
42
+ # secondary: "button--secondary"
43
+ # }
44
+ # }
45
+ # )
46
+ #
47
+ # def initialize(title:, intent:)
48
+ # @title = title
49
+ # @class_name = cva_class_name(intent:)
50
+ # end
51
+ # end
52
+ # ```
53
+ #
54
+ # All the {ModelHelper#initialize} params are passed straight to {Component#initialize},
55
+ # except for `method_name`, which allows to override the name of the generated method:
56
+ #
57
+ # ```
58
+ # class ButtonComponent < ViewComponent::Base
59
+ # include Cva::ModelHelper.new(
60
+ # method_name: "button_class",
61
+ # base: "button",
62
+ # variants: {
63
+ # intent: {
64
+ # primary: "button--primary",
65
+ # secondary: "button--secondary"
66
+ # }
67
+ # }
68
+ # )
69
+ #
70
+ # def initialize(title:, intent:)
71
+ # @title = title
72
+ # @class_name = button_class(intent:)
73
+ # end
74
+ # end
75
+ # ```
76
+ class ModelHelper < Module
77
+ def initialize(method_name: "cva_class_name", **component_options)
78
+ # Prepare a closure.
79
+ # This `component` instance is shared by every class instance that includes
80
+ # this module. It is exactly what we want in regards to performance:
81
+ # calls to `render` are typical regardless of a particular instance,
82
+ # so component's cache will be used in 99% cases.
83
+ component = Component.new(**component_options)
84
+
85
+ define_method(method_name) do |*args, **kwargs|
86
+ component.render(*args, **kwargs)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cva
4
+ VERSION = "0.1.0"
5
+ end
data/lib/cva-ruby.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cva/version"
4
+ require_relative "cva/component"
5
+ require_relative "cva/model_helper"
data/lib/cva.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cva-ruby"
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cva-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrii Malyshko
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ This is a Ruby port of cva.style JS library. The CVA (Class Variance Authority)
14
+ pattern is based on the idea that there's a limited number of states
15
+ (variants) your CSS component can hold. Each state is described with a CSS
16
+ class or a set of classes. You then provide a set of variants as an input,
17
+ and get back a final list of CSS classes.
18
+ email:
19
+ - pleasesendmeaspam@gmail.com
20
+ executables: []
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - CHANGELOG.md
25
+ - LICENSE.txt
26
+ - README.md
27
+ - Rakefile
28
+ - lib/cva-ruby.rb
29
+ - lib/cva.rb
30
+ - lib/cva/cache.rb
31
+ - lib/cva/component.rb
32
+ - lib/cva/config.rb
33
+ - lib/cva/merger.rb
34
+ - lib/cva/model_helper.rb
35
+ - lib/cva/version.rb
36
+ homepage: https://github.com/nashbridges/cva-ruby
37
+ licenses:
38
+ - MIT
39
+ metadata:
40
+ homepage_uri: https://github.com/nashbridges/cva-ruby
41
+ source_code_uri: https://github.com/nashbridges/cva-ruby
42
+ changelog_uri: https://github.com/nashbridges/cva-ruby/blob/main/CHANGELOG.md
43
+ rubygems_mfa_required: 'true'
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: 3.2.0
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 4.0.16
59
+ specification_version: 4
60
+ summary: A tool for conditionally concatenating CSS classes
61
+ test_files: []