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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d357785ffdda8c49dadf2b3220b8359ed4255ab79a95e4397787d8b9f8693909
4
- data.tar.gz: 3c3c1b9f81125e785106fb069a2a5da4bb282fdea1de8edf2eb1429f8d44ff1e
3
+ metadata.gz: 95eb998447cdebbc43f709828a8dd56adce85b0196feaf3c21e66871065c6455
4
+ data.tar.gz: 4eaf7e232a663048fdc285cafb9074817484cde5f91b720ac2d263214cdcac60
5
5
  SHA512:
6
- metadata.gz: 60936a499d423937b8f4ab088619500362f2c693f8d91632f6e7bd239db8e0a5aef2f8c9ec31155bce37f0d3a6749eb09eebacd0774af77a7411de4798fea943
7
- data.tar.gz: '0641594aaa67ad7fab30707d306c9eef87dd78c03d452a018e159c6c5b60c06a2f8ef767fa779f59a09c26001e706c20b6a2d4342fffd9d2735c81b89d99f2cf'
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
@@ -4,4 +4,5 @@ module ClassList
4
4
  Error = Class.new(StandardError)
5
5
  InvalidInputError = Class.new(Error)
6
6
  InvalidOperationError = Class.new(Error)
7
+ InvalidVariantError = Class.new(Error)
7
8
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClassList
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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
@@ -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.1.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