class_list_op 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +168 -0
- data/lib/class_list/attribute_merger.rb +39 -0
- data/lib/class_list/errors.rb +1 -0
- data/lib/class_list/variants.rb +109 -0
- data/lib/class_list/version.rb +1 -1
- data/lib/class_list_op.rb +12 -0
- data/sig/class_list_op.rbs +15 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 95eb998447cdebbc43f709828a8dd56adce85b0196feaf3c21e66871065c6455
|
|
4
|
+
data.tar.gz: 4eaf7e232a663048fdc285cafb9074817484cde5f91b720ac2d263214cdcac60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 994795cc106ffd72a6c934d0cb4b71fd211f34a485755c1dcffb180c668668fca3e3200ac2821e55c67a1b9ce309719aeaf83f70b0fa7132aa73de5a51d051d7
|
|
7
|
+
data.tar.gz: faf7a8bead2bafedc1aef0836196f1142a2ac2f2e6e588724b3d28ac18c705d1234b2bd8cb4e252b46ec4501628aa6a02f8f49b72feb564ff3cc1d6f833543ec
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-04-05
|
|
4
|
+
|
|
5
|
+
- Add `ClassList::Variants` for Tailwind-safe variant assembly from plain hashes
|
|
6
|
+
- Add `ClassList.resolve_attributes` as the preferred attribute operations API
|
|
7
|
+
- Keep `ClassList.merge_attributes` as a compatibility alias
|
|
8
|
+
|
|
3
9
|
## [0.1.0] - 2026-04-05
|
|
4
10
|
|
|
5
11
|
- Initial release
|
data/README.md
CHANGED
|
@@ -56,6 +56,35 @@ list.to_s
|
|
|
56
56
|
|
|
57
57
|
updated.tokens
|
|
58
58
|
# => ["flex", "mb-4"]
|
|
59
|
+
|
|
60
|
+
defaults = { class: "flex gap-4", id: "main" }
|
|
61
|
+
overrides = { class: { add: "mb-4", remove: "gap-4" } }
|
|
62
|
+
|
|
63
|
+
ClassList.resolve_attributes(defaults, overrides)
|
|
64
|
+
# => { class: "flex mb-4", id: "main" }
|
|
65
|
+
|
|
66
|
+
button = ClassList.variants(
|
|
67
|
+
base: {
|
|
68
|
+
container: "font-medium whitespace-nowrap inline-flex items-center"
|
|
69
|
+
},
|
|
70
|
+
defaults: {
|
|
71
|
+
size: :md,
|
|
72
|
+
tone: :default
|
|
73
|
+
},
|
|
74
|
+
dimensions: {
|
|
75
|
+
size: {
|
|
76
|
+
xs: { container: "px-1.5 py-1 rounded-md text-xs" },
|
|
77
|
+
md: { container: "px-3 py-2 rounded-lg text-sm" }
|
|
78
|
+
},
|
|
79
|
+
tone: {
|
|
80
|
+
default: { container: "text-white bg-blue-600 hover:bg-blue-700" },
|
|
81
|
+
red: { container: "text-white bg-red-600 hover:bg-red-700" }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
button.attributes(:container, tone: :red, class: { add: "w-full" })
|
|
87
|
+
# => { class: "font-medium whitespace-nowrap inline-flex items-center px-3 py-2 rounded-lg text-sm text-white bg-red-600 hover:bg-red-700 w-full" }
|
|
59
88
|
```
|
|
60
89
|
|
|
61
90
|
### Supported inputs
|
|
@@ -101,6 +130,145 @@ ClassList.resolve(defaults, remove: "md:space-x-4", add: "md:space-x-6")
|
|
|
101
130
|
# => "cols w-full md:flex md:flex-row md:space-x-6"
|
|
102
131
|
```
|
|
103
132
|
|
|
133
|
+
### Attribute adapter
|
|
134
|
+
|
|
135
|
+
`ClassList.resolve_attributes` is a thin adapter for component-style attribute hashes.
|
|
136
|
+
|
|
137
|
+
- non-`class` attributes use normal hash merge semantics
|
|
138
|
+
- `class: "..."` still fully overrides defaults
|
|
139
|
+
- `class: { add:, remove:, replace: }` applies class operations against the default class value
|
|
140
|
+
|
|
141
|
+
`ClassList.merge_attributes` remains available as a compatibility alias.
|
|
142
|
+
|
|
143
|
+
Arbre-style example:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
class Cols < BaseComponent
|
|
147
|
+
builder_method :cols
|
|
148
|
+
|
|
149
|
+
def build(attributes = {})
|
|
150
|
+
attributes = { breakpoint: :md }.merge(attributes)
|
|
151
|
+
|
|
152
|
+
direction = attributes.delete(:direction) || :row
|
|
153
|
+
space = attributes.delete(:space) || 4
|
|
154
|
+
breakpoint = attributes.delete(:breakpoint)
|
|
155
|
+
|
|
156
|
+
defaults = { class: cols_classes(direction:, space:, breakpoint:) }
|
|
157
|
+
|
|
158
|
+
super(ClassList.resolve_attributes(defaults, attributes))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def columns
|
|
162
|
+
children.grep(Col)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def cols_classes(direction:, space:, breakpoint:)
|
|
168
|
+
[
|
|
169
|
+
"cols w-full",
|
|
170
|
+
"#{breakpoint}:flex",
|
|
171
|
+
direction_variant(direction, space:, breakpoint:)
|
|
172
|
+
]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def direction_variant(direction, space:, breakpoint:)
|
|
176
|
+
space_axis = direction.to_s.include?("row") ? "x" : "y"
|
|
177
|
+
|
|
178
|
+
[
|
|
179
|
+
"#{breakpoint}:flex-#{direction}",
|
|
180
|
+
"#{breakpoint}:space-#{space_axis}-#{space}"
|
|
181
|
+
]
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
cols class: { add: "mb-4", remove: "md:space-x-4" } do
|
|
186
|
+
# ...
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Variants
|
|
191
|
+
|
|
192
|
+
`ClassList::Variants` automates the repetitive `base + selected variants + class operations` assembly without introducing a DSL.
|
|
193
|
+
|
|
194
|
+
- config is just a Ruby hash and can come from YAML
|
|
195
|
+
- all Tailwind classes stay as literal strings for extractor safety
|
|
196
|
+
- dimensions are selected by key and merged in order
|
|
197
|
+
- any non-dimension options are treated as final attribute overrides
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
button = ClassList.variants(
|
|
201
|
+
base: {
|
|
202
|
+
container: "font-medium whitespace-nowrap text-center inline-flex items-center cursor-pointer",
|
|
203
|
+
icon: "shrink-0"
|
|
204
|
+
},
|
|
205
|
+
defaults: {
|
|
206
|
+
size: :md,
|
|
207
|
+
tone: :default
|
|
208
|
+
},
|
|
209
|
+
dimensions: {
|
|
210
|
+
size: {
|
|
211
|
+
xs: {
|
|
212
|
+
container: "px-1.5 py-1 rounded-md text-xs",
|
|
213
|
+
icon: "size-3"
|
|
214
|
+
},
|
|
215
|
+
md: {
|
|
216
|
+
container: "px-3 py-2 rounded-lg text-sm",
|
|
217
|
+
icon: "size-4"
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
tone: {
|
|
221
|
+
default: {
|
|
222
|
+
container: "text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-800"
|
|
223
|
+
},
|
|
224
|
+
red: {
|
|
225
|
+
container: "text-white bg-red-600 hover:bg-red-700 focus:ring-red-800"
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
button.attributes(:container, size: :xs, tone: :red)
|
|
232
|
+
# => { class: "font-medium whitespace-nowrap text-center inline-flex items-center cursor-pointer px-1.5 py-1 rounded-md text-xs text-white bg-red-600 hover:bg-red-700 focus:ring-red-800" }
|
|
233
|
+
|
|
234
|
+
button.attributes(:container, tone: :red, class: { add: "w-full" })
|
|
235
|
+
# => { class: "... w-full" }
|
|
236
|
+
|
|
237
|
+
button.attributes(:icon, size: :xs)
|
|
238
|
+
# => { class: "shrink-0 size-3" }
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`Col` can follow the same pattern:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
class Col < BaseComponent
|
|
245
|
+
builder_method :col
|
|
246
|
+
|
|
247
|
+
def build(size_or_options = "1/2", options = {})
|
|
248
|
+
options = { breakpoint: :md }.merge(options)
|
|
249
|
+
breakpoint = options.delete(:breakpoint)
|
|
250
|
+
|
|
251
|
+
defaults = { class: col_classes(size_or_options, breakpoint:) }
|
|
252
|
+
|
|
253
|
+
super(ClassList.merge_attributes(defaults, options))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
def col_classes(size_or_options, breakpoint:)
|
|
259
|
+
case size_or_options
|
|
260
|
+
when Hash
|
|
261
|
+
size_or_options.map { |bp, size| "#{bp}:w-#{size}" }
|
|
262
|
+
else
|
|
263
|
+
["#{breakpoint}:w-#{size_or_options}"]
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
col "1/2", class: { add: "mb-4" }
|
|
269
|
+
col({ md: "1/2", xl: "1/3" }, class: { add: "self-start" })
|
|
270
|
+
```
|
|
271
|
+
|
|
104
272
|
## Development
|
|
105
273
|
|
|
106
274
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClassList
|
|
4
|
+
module AttributeMerger
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(base = nil, overrides = nil, class_key: :class)
|
|
8
|
+
base_attributes = normalize_attributes(base)
|
|
9
|
+
override_attributes = normalize_attributes(overrides)
|
|
10
|
+
|
|
11
|
+
return base_attributes.merge(override_attributes) unless override_attributes.key?(class_key)
|
|
12
|
+
|
|
13
|
+
merged_attributes = base_attributes.merge(override_attributes)
|
|
14
|
+
merged_attributes[class_key] = resolve_class_value(base_attributes[class_key], override_attributes[class_key])
|
|
15
|
+
merged_attributes
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def normalize_attributes(value)
|
|
19
|
+
case value
|
|
20
|
+
when nil
|
|
21
|
+
{}
|
|
22
|
+
when Hash
|
|
23
|
+
value.dup
|
|
24
|
+
else
|
|
25
|
+
raise InvalidInputError, "unsupported attribute input: #{value.class}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve_class_value(base_value, override_value)
|
|
30
|
+
return override_value unless override_value.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
operations = override_value.each_with_object({}) do |(key, value), result|
|
|
33
|
+
result[key.to_sym] = value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
ClassList.resolve(base_value, **operations)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/class_list/errors.rb
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClassList
|
|
4
|
+
class Variants
|
|
5
|
+
CLASS_KEY = :class
|
|
6
|
+
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@config = normalize_config(config)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def tokens(slot = :container, **options)
|
|
12
|
+
ClassList.normalize(attributes(slot, **options)[CLASS_KEY])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resolve(slot = :container, **options)
|
|
16
|
+
attributes(slot, **options)[CLASS_KEY].to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def attributes(slot = :container, **options)
|
|
20
|
+
slot_name = slot.to_sym
|
|
21
|
+
dimension_names = config.fetch(:dimensions, {}).keys
|
|
22
|
+
dimension_options = {}
|
|
23
|
+
overrides = {}
|
|
24
|
+
|
|
25
|
+
options.each do |key, value|
|
|
26
|
+
normalized_key = key.to_sym
|
|
27
|
+
|
|
28
|
+
if dimension_names.include?(normalized_key)
|
|
29
|
+
dimension_options[normalized_key] = value
|
|
30
|
+
else
|
|
31
|
+
overrides[normalized_key] = value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
selections = config.fetch(:defaults, {}).merge(dimension_options)
|
|
36
|
+
|
|
37
|
+
base_attributes = attributes_for_slot(config.fetch(:base, {}), slot_name)
|
|
38
|
+
variant_attributes = selections.reduce(base_attributes) do |result, (dimension_name, option_name)|
|
|
39
|
+
selected = config.fetch(:dimensions, {}).fetch(dimension_name).fetch(option_name.to_sym) do
|
|
40
|
+
raise InvalidVariantError, "unknown option #{option_name.inspect} for dimension #{dimension_name.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
merge_variant_attributes(result, attributes_for_slot(selected, slot_name))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
ClassList.resolve_attributes(variant_attributes, normalize_attributes(overrides))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def slots(**options)
|
|
50
|
+
slot_names = [
|
|
51
|
+
config.fetch(:base, {}).keys,
|
|
52
|
+
config.fetch(:dimensions, {}).values.flat_map(&:values).flat_map(&:keys)
|
|
53
|
+
].flatten.compact.uniq
|
|
54
|
+
|
|
55
|
+
slot_names.each_with_object({}) do |slot_name, result|
|
|
56
|
+
result[slot_name] = attributes(slot_name, **options)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :config
|
|
63
|
+
|
|
64
|
+
def normalize_config(value)
|
|
65
|
+
unless value.is_a?(Hash)
|
|
66
|
+
raise InvalidInputError, "unsupported variants config: #{value.class}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
deep_symbolize(value)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deep_symbolize(value)
|
|
73
|
+
case value
|
|
74
|
+
when Hash
|
|
75
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
76
|
+
result[key.to_sym] = deep_symbolize(nested_value)
|
|
77
|
+
end
|
|
78
|
+
when Array
|
|
79
|
+
value.map { |item| deep_symbolize(item) }
|
|
80
|
+
else
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def attributes_for_slot(source, slot_name)
|
|
86
|
+
normalize_attributes(source[slot_name])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_attributes(value)
|
|
90
|
+
case value
|
|
91
|
+
when nil
|
|
92
|
+
{}
|
|
93
|
+
when String, Symbol, Array
|
|
94
|
+
{ CLASS_KEY => value }
|
|
95
|
+
when Hash
|
|
96
|
+
value.dup
|
|
97
|
+
else
|
|
98
|
+
raise InvalidInputError, "unsupported variant attributes: #{value.class}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def merge_variant_attributes(base_attributes, layer_attributes)
|
|
103
|
+
merged = base_attributes.merge(layer_attributes)
|
|
104
|
+
|
|
105
|
+
merged[CLASS_KEY] = ClassList.resolve(base_attributes[CLASS_KEY], add: layer_attributes[CLASS_KEY])
|
|
106
|
+
merged
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/class_list/version.rb
CHANGED
data/lib/class_list_op.rb
CHANGED
|
@@ -5,6 +5,8 @@ require_relative "class_list/errors"
|
|
|
5
5
|
require_relative "class_list/tokenizer"
|
|
6
6
|
require_relative "class_list/resolver"
|
|
7
7
|
require_relative "class_list/list"
|
|
8
|
+
require_relative "class_list/attribute_merger"
|
|
9
|
+
require_relative "class_list/variants"
|
|
8
10
|
|
|
9
11
|
module ClassList
|
|
10
12
|
class << self
|
|
@@ -27,5 +29,15 @@ module ClassList
|
|
|
27
29
|
def list(base = nil)
|
|
28
30
|
List.new(base)
|
|
29
31
|
end
|
|
32
|
+
|
|
33
|
+
def resolve_attributes(base = nil, overrides = nil, class_key: :class)
|
|
34
|
+
AttributeMerger.call(base, overrides, class_key: class_key)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
alias_method :merge_attributes, :resolve_attributes
|
|
38
|
+
|
|
39
|
+
def variants(config)
|
|
40
|
+
Variants.new(config)
|
|
41
|
+
end
|
|
30
42
|
end
|
|
31
43
|
end
|
data/sig/class_list_op.rbs
CHANGED
|
@@ -6,6 +6,9 @@ module ClassList
|
|
|
6
6
|
def self.resolve: (?untyped base, **untyped operations) -> String
|
|
7
7
|
def self.call: (?untyped base, **untyped operations) -> String
|
|
8
8
|
def self.list: (?untyped base) -> List
|
|
9
|
+
def self.resolve_attributes: (?Hash[untyped, untyped] base, ?Hash[untyped, untyped] overrides, ?class_key: Symbol) -> Hash[untyped, untyped]
|
|
10
|
+
def self.merge_attributes: (?Hash[untyped, untyped] base, ?Hash[untyped, untyped] overrides, ?class_key: Symbol) -> Hash[untyped, untyped]
|
|
11
|
+
def self.variants: (Hash[untyped, untyped] config) -> Variants
|
|
9
12
|
end
|
|
10
13
|
|
|
11
14
|
module ClassList::Tokenizer
|
|
@@ -17,6 +20,18 @@ module ClassList::Resolver
|
|
|
17
20
|
def self.call: (?untyped base, **untyped operations) -> Array[String]
|
|
18
21
|
end
|
|
19
22
|
|
|
23
|
+
module ClassList::AttributeMerger
|
|
24
|
+
def self.call: (?Hash[untyped, untyped] base, ?Hash[untyped, untyped] overrides, ?class_key: Symbol) -> Hash[untyped, untyped]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class ClassList::Variants
|
|
28
|
+
def initialize: (Hash[untyped, untyped] config) -> void
|
|
29
|
+
def tokens: (?Symbol slot, **untyped options) -> Array[String]
|
|
30
|
+
def resolve: (?Symbol slot, **untyped options) -> String
|
|
31
|
+
def attributes: (?Symbol slot, **untyped options) -> Hash[untyped, untyped]
|
|
32
|
+
def slots: (**untyped options) -> Hash[Symbol, Hash[untyped, untyped]]
|
|
33
|
+
end
|
|
34
|
+
|
|
20
35
|
class ClassList::List
|
|
21
36
|
@tokens: Array[String]
|
|
22
37
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: class_list_op
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alexander
|
|
@@ -22,10 +22,12 @@ files:
|
|
|
22
22
|
- LICENSE.txt
|
|
23
23
|
- README.md
|
|
24
24
|
- Rakefile
|
|
25
|
+
- lib/class_list/attribute_merger.rb
|
|
25
26
|
- lib/class_list/errors.rb
|
|
26
27
|
- lib/class_list/list.rb
|
|
27
28
|
- lib/class_list/resolver.rb
|
|
28
29
|
- lib/class_list/tokenizer.rb
|
|
30
|
+
- lib/class_list/variants.rb
|
|
29
31
|
- lib/class_list/version.rb
|
|
30
32
|
- lib/class_list_op.rb
|
|
31
33
|
- sig/class_list_op.rbs
|