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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/Rakefile +20 -0
- data/lib/cva/cache.rb +44 -0
- data/lib/cva/component.rb +614 -0
- data/lib/cva/config.rb +20 -0
- data/lib/cva/merger.rb +35 -0
- data/lib/cva/model_helper.rb +90 -0
- data/lib/cva/version.rb +5 -0
- data/lib/cva-ruby.rb +5 -0
- data/lib/cva.rb +3 -0
- metadata +61 -0
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
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
|
data/lib/cva/version.rb
ADDED
data/lib/cva-ruby.rb
ADDED
data/lib/cva.rb
ADDED
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: []
|