view_component_css_dsl 0.1.1 → 0.1.3

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: 49d5390931f1620f410e179868a1ab08cfcf4a0fa05164c8b4e7e7ba860fa1b2
4
- data.tar.gz: 1f51a210129bd2ee1cfd414697e923c60e5be7c0c8870788e193482aacfda6ee
3
+ metadata.gz: a27107bec7822ef21cc584498087a8d9cdf1ecf044bc5123754f05d5833dd869
4
+ data.tar.gz: 38f741bebdd9e6768008eaa64dfdb9b1bb9b07be4d2acdb038d3f79348a91d40
5
5
  SHA512:
6
- metadata.gz: '0086317b10dbb3b32225d9b7eb9b1695d176c40ea6adad370ca6259366edbb1952038170fa4499ecaa2ad5edfff2af7c2459214140bc2be8dd61b6441fac7e05'
7
- data.tar.gz: f2286966bae1f1c1b12b2f10e09f58d983281cbfdf0d96c863d98f28b3b7feefae85229d17a440f19d156a51ff7d0dbd2d525660186cb310d6273f15bce4d112
6
+ metadata.gz: d263fbe75515058bf95d6f3286dc9a59df3112b3c61008a7c44dc3e5ffc0c80bc23b7986801fc2abef88457aa36607689bef05643094c00d199ad28dfd94a8cc
7
+ data.tar.gz: ffe238ba9cbb6e398001a36fa533ead12f15e1244d9596481356b0d3e478ed8844b2768eadca5be96a90e4677b538e3f46919ef359d7efc6fad93aaf25d076f6
data/CHANGELOG.md CHANGED
@@ -2,15 +2,18 @@
2
2
 
3
3
 
4
4
 
5
- ## [0.1.1] - 2026-05-15
5
+ ## [0.1.3] - 2026-05-18
6
6
 
7
7
  ### Changed
8
8
 
9
- - `view_component` dependency pinned to `~> 4.0` (was `>= 4.0`) RubyGems-recommended SemVer-aware constraint
10
- - Minimum Ruby version raised to 3.2 (was 3.1), matching the floor for `view_component >= 4.0`
9
+ - `smart_merge` now delegates to the `tailwind_merge` gem. The public API is unchanged, but conflict resolution now matches upstream tailwind-merge semantics across every Tailwind utility group (previously only spacing, sizing, colors, display, justify, align, font-weight, rounded, and position were handled — shadow, ring, gap, space, divide, z, opacity, leading, tracking, transition, transform, blur, etc. now merge correctly too).
11
10
 
12
- ## [0.1.0] - 2026-05-15
11
+ ### Breaking
12
+
13
+ - `hidden` is now treated as part of the display-class conflict group. Strings like `"flex hidden"` collapse to `"hidden"` rather than keeping both. Previous behavior was non-standard — `tailwind-merge` (JS) and `tailwind_merge` (Ruby) have always treated `hidden` as a display utility. For JS-toggle visibility patterns, use the HTML5 `hidden` attribute (e.g., `attribute hidden: -> { … }` or pass `hidden: true` as an html_attr) and toggle it with `element.toggleAttribute('hidden')` instead of relying on the class merger.
14
+
15
+ ## [0.1.2] - 2026-05-15
13
16
 
14
17
  ### Added
15
18
 
16
- - Initial release. Extracted from SOFware/forge.
19
+ - data, aria, and attribute DSL declarators for first-class HTML attribute declarations (cf37050)
data/README.md CHANGED
@@ -47,6 +47,10 @@ class ButtonComponent < ViewComponent::Base
47
47
  when :danger then "bg-red-500 text-white"
48
48
  end
49
49
  end
50
+
51
+ def data_attrs
52
+ {variant: @variant, controller: "button-component"}
53
+ end
50
54
  end
51
55
  ```
52
56
 
@@ -59,6 +63,8 @@ class ButtonComponent < ApplicationComponent
59
63
  css variant: :danger, style: "bg-red-500 text-white"
60
64
  css :disabled?, style: "opacity-50"
61
65
 
66
+ data variant: :variant, controller: "button-component"
67
+
62
68
  def initialize(variant: :primary, disabled: false)
63
69
  @variant = variant
64
70
  @disabled = disabled
@@ -66,6 +72,7 @@ class ButtonComponent < ApplicationComponent
66
72
 
67
73
  private
68
74
 
75
+ attr_reader :variant
69
76
  def disabled? = @disabled
70
77
  end
71
78
  ```
@@ -73,6 +80,7 @@ end
73
80
  - Variant validation is automatic; passing `:unknown` raises an `ArgumentError`.
74
81
  - Declarations are easy to scan, easy to extend.
75
82
  - A caller's `class: "..."` is smart-merged with the component's defaults: `bg-black` from the caller wins over the component's `bg-blue-500`, but `rounded` and `px-4` stick.
83
+ - Data attributes get the same declarative treatment — see [Declaring data, aria, and HTML attributes](#declaring-data-aria-and-html-attributes) below for the full pattern.
76
84
 
77
85
  ## Philosophy
78
86
 
@@ -237,6 +245,124 @@ css -> { "pl-#{@indent * 4}" }
237
245
 
238
246
  Procs returning `nil` are dropped. Procs participate in smart_merge.
239
247
 
248
+ ## Declaring `data`, `aria`, and HTML attributes
249
+
250
+ The gem provides three sibling declarators that mirror `css`'s shape: `data`, `aria`, and `attribute`. Use them to declare attributes alongside your styles instead of overriding methods.
251
+
252
+ ```ruby
253
+ class ButtonComponent < ApplicationComponent
254
+ css "rounded px-4 py-2 bg-blue-500 text-white"
255
+
256
+ data variant: :variant, size: :size
257
+ aria label: "Submit"
258
+ attribute target: "_blank"
259
+
260
+ def initialize(variant: :primary, size: :default)
261
+ @variant = variant
262
+ @size = size
263
+ end
264
+
265
+ attr_reader :variant, :size
266
+ end
267
+ ```
268
+
269
+ All three declarators share the same patterns. The only difference is *where* the attribute lands in the rendered HTML — `data` produces `data-*`, `aria` produces `aria-*`, and `attribute` produces a top-level attribute.
270
+
271
+ ### Static values
272
+
273
+ Always emitted. Stringified at render time (booleans, integers, etc. all become strings; `nil` drops the attribute).
274
+
275
+ ```ruby
276
+ data controller: "modal"
277
+ aria label: "Close dialog"
278
+ attribute target: "_blank"
279
+ ```
280
+
281
+ ### Symbol values — call an instance method
282
+
283
+ When the value is a Symbol, the DSL calls that instance method at render time and uses the result. Standard pattern for streaming an ivar or computed value into a data attribute.
284
+
285
+ ```ruby
286
+ data variant: :variant # calls #variant; renders as data-variant="<value>"
287
+ attribute tabindex: :tab_index
288
+
289
+ def tab_index
290
+ focusable? ? 0 : -1
291
+ end
292
+ ```
293
+
294
+ If the method returns `nil`, the attribute is dropped.
295
+
296
+ ### Proc values — inline computation
297
+
298
+ For one-off computed values that don't deserve a named method:
299
+
300
+ ```ruby
301
+ aria label: -> { "#{@variant} Notification".titleize }
302
+ data turbo_permanent: -> { true if turbo_permanent? }
303
+ ```
304
+
305
+ Procs are `instance_exec`'d at render time, so they see instance state. Procs returning `nil` drop the attribute.
306
+
307
+ ### Conditional inclusion via positional predicate
308
+
309
+ Mirrors the `css :method?, style: "..."` pattern — a positional Symbol or Proc as the first argument acts as a predicate. When truthy, the declaration applies; when falsy, it's skipped entirely.
310
+
311
+ ```ruby
312
+ data :auto_dismiss?, timeout: "5000", animation: "fade"
313
+ aria :loud?, label: "Important"
314
+ attribute -> { @disabled }, disabled: true
315
+ ```
316
+
317
+ The Symbol form calls the named instance method; the Proc form is `instance_exec`'d.
318
+
319
+ ### Multiple attributes per declaration
320
+
321
+ Each declaration accepts a hash of attributes. All share the same predicate (if any).
322
+
323
+ ```ruby
324
+ data controller: "modal",
325
+ modal_dismiss_action: "click->modal#dismiss"
326
+ ```
327
+
328
+ ### Multiple declarations: how they compose
329
+
330
+ For `aria` and `attribute`, repeated keys across declarations *replace* — the last declaration wins.
331
+
332
+ For `data`, **the keys `:controller` and `:action` accumulate** (they're space-separated lists in HTML), and everything else replaces. This matches how the gem already merges component defaults with caller-passed values.
333
+
334
+ ```ruby
335
+ data :modal?, controller: "modal"
336
+ data :trap_focus?, controller: "trap-focus"
337
+ # Both predicates true → data-controller="modal trap-focus"
338
+ # Only :modal? true → data-controller="modal"
339
+ # Neither true → data-controller attribute is omitted
340
+ ```
341
+
342
+ ### Caller customization
343
+
344
+ Whatever a caller passes for `class:`, `data:`, `aria:`, or any HTML attribute layers on top of your declarations using the same rules:
345
+
346
+ - `class:` smart-merged (see Smart merge behavior below)
347
+ - `data:` controller/action keys concatenate, others replace
348
+ - `aria:` and other attrs: caller wins
349
+
350
+ ### Inheritance
351
+
352
+ Subclass declarations stack on top of parent declarations using the same rules. `data controller:` declarations in a child class concatenate with the parent's; `data role:` in a child class replaces the parent's. `aria` and `attribute` keys in a child class replace the parent's.
353
+
354
+ ```ruby
355
+ class CardComponent < ApplicationComponent
356
+ data controller: "card"
357
+ data role: "region"
358
+ end
359
+
360
+ class HighlightedCardComponent < CardComponent
361
+ data controller: "highlighted" # appends → data-controller="card highlighted"
362
+ data role: "alert" # replaces → data-role="alert"
363
+ end
364
+ ```
365
+
240
366
  ## Caller customization
241
367
 
242
368
  Callers can pass `class:` (smart-merged with the component's defaults), plus any other HTML attribute (`data:`, `id:`, `aria:`, etc.) — they all land on the top-level element without the component having to opt each one in.
@@ -284,7 +410,7 @@ Renders:
284
410
 
285
411
  ## Smart merge behavior
286
412
 
287
- Smart-merge handles Tailwind's conventions so caller and component CSS can coexist sensibly. In every row below, the **Component** column is what the component declared via `css`, and the **Caller** column is what was passed in `class:` at the call site.
413
+ Smart-merge handles Tailwind's conventions so caller and component CSS can coexist sensibly. Under the hood it delegates to the [`tailwind_merge`](https://github.com/gjtorikian/tailwind_merge) gem, which mirrors [tailwind-merge](https://github.com/dcastil/tailwind-merge) (JS) semantics. In every row below, the **Component** column is what the component declared via `css`, and the **Caller** column is what was passed in `class:` at the call site.
288
414
 
289
415
  | Component | Caller | Final classes | Why |
290
416
  | --- | --- | --- | --- |
@@ -301,6 +427,19 @@ Smart-merge handles Tailwind's conventions so caller and component CSS can coexi
301
427
 
302
428
  Modifier prefixes (`hover:`, `md:`, `dark:`, `group/`, `peer-checked:`, `aria-*`, arbitrary `[…]` values, etc.) form their own merge namespace, so `hover:bg-blue-500` never conflicts with a base `bg-white`.
303
429
 
430
+ ### JS-toggle visibility — use the `hidden` attribute, not the class
431
+
432
+ `hidden` is treated as part of the display group, so `"flex hidden"` collapses to `"hidden"` (the same as upstream tailwind-merge). If you need to toggle visibility from JavaScript while preserving a base display class, use the HTML5 `hidden` attribute via the `attribute` DSL:
433
+
434
+ ```ruby
435
+ class PaneComponent < ApplicationComponent
436
+ css "block"
437
+ attribute hidden: -> { collapsed? }
438
+ end
439
+ ```
440
+
441
+ Then toggle from JS with `element.toggleAttribute('hidden')` or `element.hidden = true/false`. The class merger stays out of the way and the element retains its `block`/`flex`/etc. layout when shown.
442
+
304
443
  ## Inheritance
305
444
 
306
445
  A child component's `css "..."` declaration is smart-merged with its parent's:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ViewComponentCssDsl
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
@@ -6,12 +6,17 @@ require "active_support/core_ext/object/blank"
6
6
  require "active_support/core_ext/hash/except"
7
7
  require "active_support/core_ext/hash/slice"
8
8
  require "active_support/core_ext/object/inclusion"
9
+ require "tailwind_merge"
9
10
 
10
11
  require_relative "view_component_css_dsl/version"
11
12
 
12
13
  module ViewComponentCssDsl
13
14
  extend ActiveSupport::Concern
14
15
 
16
+ # Single shared merger. tailwind_merge builds a conflict-group index on
17
+ # initialization, so we pay that cost once per process rather than per call.
18
+ MERGER = TailwindMerge::Merger.new
19
+
15
20
  HTML_ATTR_KEYS = Set[
16
21
  :alt, :aria, :autofocus,
17
22
  :class, :colspan, :contenteditable,
@@ -29,97 +34,6 @@ module ViewComponentCssDsl
29
34
  :tabindex, :target, :title, :type, :value
30
35
  ].freeze
31
36
 
32
- # Single combined regex for padding/margin spacing (replaces 14 separate patterns)
33
- # Captures: type (p/m), axis (x/y/t/r/b/l or nil for all), value
34
- SPACING_REGEX = /\b(p|m)(x|y|t|r|b|l)?-(\d+)\b/
35
-
36
- # Maps axis character to Set of affected sides
37
- SPACING_AXIS_MAP = {
38
- nil => Set[:t, :r, :b, :l], # p-4, m-4 = all sides
39
- "x" => Set[:l, :r],
40
- "y" => Set[:t, :b],
41
- "t" => Set[:t],
42
- "r" => Set[:r],
43
- "b" => Set[:b],
44
- "l" => Set[:l]
45
- }.freeze
46
-
47
- # Border width patterns (kept separate due to anchoring requirements)
48
- BORDER_REGEX = /^border(?:-(x|y|t|r|b|l))?(?:-\d+)?$/
49
-
50
- # Other category patterns (non-spacing, simple override by category)
51
- # IMPORTANT: Use anchored patterns (^/$) to avoid matching substrings within
52
- # compound classes (e.g., `h-8` within `min-h-8`, `flex` within `inline-flex`)
53
- CATEGORIES = {
54
- background: /^bg-/,
55
- text_color: /^text-((\w+-\d+)|white|black|transparent|current|inherit|action|success|danger|warning|brand)(\/\d+)?$/,
56
- text_size: /^text-(xs|sm|base|lg|xl|\d*xl)$/,
57
- border_color: /^border-(?!t|r|b|l|x|y|\d)(\w+-\d+|\w+)(\/\d+)?$/,
58
- width: /^w-/,
59
- height: /^h-/,
60
- min_width: /^min-w-/,
61
- min_height: /^min-h-/,
62
- max_width: /^max-w-/,
63
- max_height: /^max-h-/,
64
- # Display classes - note: `hidden` is intentionally excluded because it's
65
- # commonly used as a visibility toggle alongside other display classes
66
- # (e.g., "inline-flex hidden" where JS removes "hidden" to show element)
67
- display: /^(block|inline-block|inline-flex|inline-grid|inline|flex|grid|table-cell|table-row|table|contents|flow-root|list-item)$/,
68
- justify: /^justify-/,
69
- align: /^items-/,
70
- font_weight: /^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/,
71
- rounded: /^rounded(-none|-sm|-md|-lg|-xl|-2xl|-3xl|-full)?$/,
72
- position: /^(static|relative|absolute|fixed|sticky)$/
73
- }.freeze
74
-
75
- # Known Tailwind modifiers (prefixes like hover:, md:, first:, etc.)
76
- # Classes with different modifiers should NOT conflict with each other
77
- KNOWN_MODIFIERS = Set[
78
- # Responsive
79
- "sm", "md", "lg", "xl", "2xl",
80
- "max-sm", "max-md", "max-lg", "max-xl", "max-2xl",
81
- # Interactive state
82
- "hover", "focus", "focus-within", "focus-visible", "active", "visited", "target",
83
- # Structural
84
- "first", "last", "only", "odd", "even",
85
- "first-of-type", "last-of-type", "only-of-type", "empty",
86
- # Form state
87
- "disabled", "enabled", "checked", "indeterminate", "default",
88
- "required", "valid", "invalid", "in-range", "out-of-range",
89
- "placeholder-shown", "autofill", "read-only",
90
- # Pseudo-elements
91
- "before", "after", "first-letter", "first-line",
92
- "marker", "selection", "file", "backdrop", "placeholder",
93
- # Media/Preference
94
- "dark", "print", "portrait", "landscape",
95
- "motion-safe", "motion-reduce", "contrast-more", "contrast-less",
96
- "forced-colors",
97
- # Direction
98
- "rtl", "ltr",
99
- # Attribute
100
- "open",
101
- # Direct children
102
- "*"
103
- ].freeze
104
-
105
- # Patterns that match dynamic modifiers (with optional names/arbitrary values)
106
- # These use flexible regex to match ANY valid Tailwind modifier syntax
107
- MODIFIER_PATTERNS = [
108
- /^group(?:\/\w+)?$/, # group, group/<any-name>
109
- /^group-\w+(?:\/\w+)?$/, # group-hover, group-<state>/<any-name>
110
- /^peer(?:\/\w+)?$/, # peer, peer/<any-name>
111
- /^peer-\w+(?:\/\w+)?$/, # peer-checked, peer-<state>/<any-name>
112
- /^aria-\w+$/, # aria-checked, aria-<any-attr>
113
- /^aria-\[.+\]$/, # aria-[<arbitrary>]
114
- /^data-\[.+\]$/, # data-[<arbitrary>]
115
- /^supports-\[.+\]$/, # supports-[<arbitrary>]
116
- /^has-\[.+\]$/, # has-[<arbitrary>]
117
- /^group-has-\[.+\]$/, # group-has-[<arbitrary>]
118
- /^peer-has-\[.+\]$/, # peer-has-[<arbitrary>]
119
- /^min-\[.+\]$/, # min-[<arbitrary>]
120
- /^max-\[.+\]$/ # max-[<arbitrary>]
121
- ].freeze
122
-
123
37
  included do
124
38
  class_attribute :_css_base, instance_writer: false, default: ""
125
39
  class_attribute :_css_axis_rules, instance_writer: false, default: []
@@ -133,6 +47,10 @@ module ViewComponentCssDsl
133
47
  class_attribute :_css_cache, instance_writer: false, default: nil
134
48
  # Memoization cache for smart_merge results (axis + method + proc combinations)
135
49
  class_attribute :_css_merge_cache, instance_writer: false, default: nil
50
+ # Rules for the data/aria/attribute DSLs. Each entry: {predicate:, attrs:}
51
+ class_attribute :_data_rules, instance_writer: false, default: []
52
+ class_attribute :_aria_rules, instance_writer: false, default: []
53
+ class_attribute :_attribute_rules, instance_writer: false, default: []
136
54
  end
137
55
 
138
56
  class_methods do
@@ -198,6 +116,60 @@ module ViewComponentCssDsl
198
116
  end
199
117
  end
200
118
 
119
+ # Declares one or more `data-*` attributes on the top-level element.
120
+ #
121
+ # data controller: "modal" # static
122
+ # data variant: :variant # Symbol value -> calls instance method
123
+ # data foo: -> { computed_value } # Proc value -> instance_exec'd
124
+ # data :auto_dismiss?, timeout_value: "5000" # Symbol predicate
125
+ # data -> { complex_check? }, foo: "bar" # Proc predicate
126
+ def data(*args, **kwargs)
127
+ self._data_rules = _data_rules.dup
128
+ _data_rules << _build_attr_rule(:data, *args, **kwargs)
129
+ end
130
+
131
+ # Declares one or more `aria-*` attributes on the top-level element.
132
+ # See `data` for the full pattern.
133
+ def aria(*args, **kwargs)
134
+ self._aria_rules = _aria_rules.dup
135
+ _aria_rules << _build_attr_rule(:aria, *args, **kwargs)
136
+ end
137
+
138
+ # Declares one or more top-level HTML attributes (`target`, `role`, `tabindex`,
139
+ # etc.) on the rendered element. See `data` for the full pattern.
140
+ def attribute(*args, **kwargs)
141
+ self._attribute_rules = _attribute_rules.dup
142
+ _attribute_rules << _build_attr_rule(:attribute, *args, **kwargs)
143
+ end
144
+
145
+ private
146
+
147
+ # Builds the rule entry used by `data`, `aria`, and `attribute`.
148
+ # Returns {predicate:, attrs:} where predicate is nil, Symbol, or Proc and
149
+ # attrs is the hash of attribute key -> (literal | Symbol | Proc).
150
+ def _build_attr_rule(namespace, *args, **kwargs)
151
+ if args.size > 1
152
+ raise ArgumentError,
153
+ "#{namespace} accepts at most one positional arg (a predicate Symbol or Proc)"
154
+ end
155
+
156
+ predicate = args.first
157
+ if predicate && !predicate.is_a?(Symbol) && !predicate.is_a?(Proc)
158
+ raise ArgumentError,
159
+ "#{namespace} positional predicate must be a Symbol or Proc " \
160
+ "(got #{predicate.class})"
161
+ end
162
+
163
+ if kwargs.empty?
164
+ raise ArgumentError,
165
+ "#{namespace} requires at least one attribute kwarg"
166
+ end
167
+
168
+ {predicate:, attrs: kwargs}
169
+ end
170
+
171
+ public
172
+
201
173
  # Override `new` to auto-extract HTML attributes from kwargs into @html_attrs,
202
174
  # so components don't need to declare **html_attrs in their initialize signature.
203
175
  # Anything in HTML_ATTR_KEYS that wasn't declared as a kwarg is captured.
@@ -283,99 +255,20 @@ module ViewComponentCssDsl
283
255
  end
284
256
  end
285
257
 
286
- # Overwrites base css with custom css from the caller, but only if they actually
287
- # interfere with each other. Modifier prefixes (hover:, md:, first:, etc.) create
288
- # separate "namespaces" so they don't conflict with base classes.
258
+ # Merges Tailwind utility classes, resolving conflicts so the last-declared
259
+ # value wins. Delegates to the `tailwind_merge` gem, which mirrors
260
+ # tailwind-merge (JS) semantics and tracks Tailwind utility groups upstream.
261
+ #
289
262
  # Examples:
290
263
  # - base: "pt-2", custom: "pt-4", result => "pt-4"
291
264
  # - base: "pb-2", custom: "pt-4", result => "pb-2 pt-4"
292
265
  # - base: "pb-2", custom: "p-4", result => "p-4"
293
266
  # - base: "block", custom: "first:hidden", result => "block first:hidden"
294
267
  def smart_merge(*css_strings)
295
- categorized = {}
296
- uncategorized = []
297
- # Store spacing classes grouped by modifier prefix
298
- # {prefix => [{class: "p-4", info: {type: :padding, axes: Set[:t,:r,:b,:l]}}, ...]}
299
- spacing_by_prefix = Hash.new { |h, k| h[k] = [] }
300
-
301
- css_strings.compact.each do |str|
302
- str.to_s.split.each do |cls|
303
- prefix, base_class = extract_modifier_prefix(cls)
304
-
305
- spacing = spacing_info(base_class)
306
- if spacing
307
- # Remove any existing spacing classes that overlap on same type and axes
308
- # within the same modifier prefix
309
- spacing_by_prefix[prefix].reject! do |existing|
310
- existing[:info][:type] == spacing[:type] &&
311
- existing[:info][:axes].subset?(spacing[:axes])
312
- end
313
- spacing_by_prefix[prefix] << {class: cls, info: spacing}
314
- else
315
- category = detect_category(base_class)
316
- if category
317
- key = "#{prefix}:#{category}"
318
- categorized[key] = cls
319
- else
320
- uncategorized << cls unless uncategorized.include?(cls)
321
- end
322
- end
323
- end
324
- end
268
+ input = css_strings.flat_map { |s| s.to_s.split }.reject(&:empty?).join(" ")
269
+ return "" if input.empty?
325
270
 
326
- spacing_classes = spacing_by_prefix.values.flatten.map { |s| s[:class] }
327
- (uncategorized + spacing_classes + categorized.values).join(" ")
328
- end
329
-
330
- def spacing_info(css_class)
331
- # Check padding/margin with single regex (replaces 14 pattern checks)
332
- if (match = css_class.match(SPACING_REGEX))
333
- type = (match[1] == "p") ? :padding : :margin
334
- axes = SPACING_AXIS_MAP[match[2]]
335
- return {type:, axes:}
336
- end
337
-
338
- # Check border width (needs separate handling due to anchoring)
339
- if (match = css_class.match(BORDER_REGEX))
340
- axes = SPACING_AXIS_MAP[match[1]]
341
- return {type: :border, axes:}
342
- end
343
-
344
- nil
345
- end
346
-
347
- def detect_category(css_class)
348
- CATEGORIES.find { |_name, pattern| css_class.match?(pattern) }&.first
349
- end
350
-
351
- # Extracts the modifier prefix from a Tailwind class
352
- # e.g., "md:hover:bg-blue-500" → ["md:hover", "bg-blue-500"]
353
- # e.g., "bg-white" → ["", "bg-white"]
354
- def extract_modifier_prefix(css_class)
355
- # Fast path: most classes don't have modifiers
356
- return ["", css_class] unless css_class.include?(":")
357
-
358
- parts = css_class.split(":")
359
- return ["", css_class] if parts.size == 1
360
-
361
- # Find where modifiers end and the actual class begins
362
- modifier_parts = []
363
- parts.each_with_index do |part, i|
364
- if known_modifier?(part) && i < parts.size - 1
365
- modifier_parts << part
366
- else
367
- base_class = parts[i..].join(":")
368
- return [modifier_parts.join(":"), base_class]
369
- end
370
- end
371
-
372
- # Fallback (shouldn't reach here)
373
- ["", css_class]
374
- end
375
-
376
- def known_modifier?(str)
377
- return true if KNOWN_MODIFIERS.include?(str)
378
- MODIFIER_PATTERNS.any? { |pattern| str.match?(pattern) }
271
+ MERGER.merge(input)
379
272
  end
380
273
  end
381
274
 
@@ -401,7 +294,10 @@ module ViewComponentCssDsl
401
294
  def html_attrs
402
295
  return {} unless @html_attrs
403
296
 
404
- result = @html_attrs.except(:aria, :class, :data)
297
+ # Start with DSL-declared top-level attrs; caller's html_attrs layer on top
298
+ # (caller wins on collision, mirroring the css behavior).
299
+ dsl_attrs = resolved_attr_rules(:attribute)
300
+ result = dsl_attrs.merge(@html_attrs.except(:aria, :class, :data))
405
301
 
406
302
  # Only include aria/data if they have content, otherwise they'd override
407
303
  # inline attrs in templates like: tag.div data: {foo: "bar"}, **html_attrs
@@ -462,16 +358,11 @@ module ViewComponentCssDsl
462
358
  # - In contrast, data-label from the caller overwrites the default
463
359
  #
464
360
  def final_data_attrs
465
- incoming_data = @html_attrs.fetch(:data, {})
466
- incoming_data.each_with_object(data_attrs) do |(key, value), final_data|
467
- final_value = if key.in?(DATA_MERGE_KEYS)
468
- [data_attrs[key], value].compact.join(" ")
469
- else
470
- value
471
- end
472
-
473
- final_data[key] = final_value
474
- end
361
+ # Merge in order: DSL declarations -> method override -> caller's :data.
362
+ # Each layer uses DATA_MERGE_KEYS semantics (controller/action concatenate,
363
+ # everything else replaces).
364
+ combined = merge_data_layer(resolved_attr_rules(:data), data_attrs)
365
+ merge_data_layer(combined, @html_attrs.fetch(:data, {}))
475
366
  end
476
367
 
477
368
  # Overwrite in subclass to define default aria-attrs
@@ -479,14 +370,75 @@ module ViewComponentCssDsl
479
370
  {}
480
371
  end
481
372
 
482
- # Using merge allows for default value #aria_attrs, but also for dev to override
483
- # that value per-instance as needed
373
+ # Merge in order: DSL declarations -> method override -> caller's :aria.
374
+ # Hash#merge throughout caller wins on collision (no additive semantics).
484
375
  def final_aria_attrs
485
- aria_attrs.merge(@html_attrs.fetch(:aria, {}))
376
+ resolved_attr_rules(:aria).merge(aria_attrs).merge(@html_attrs.fetch(:aria, {}))
486
377
  end
487
378
 
488
379
  private
489
380
 
381
+ # Walks the DSL rules for the given namespace (:data, :aria, :attribute),
382
+ # evaluates each predicate, resolves each value, and returns a hash. For
383
+ # the :data namespace, DATA_MERGE_KEYS keys accumulate space-separated when
384
+ # the same key appears in multiple included rules.
385
+ def resolved_attr_rules(namespace)
386
+ rules = case namespace
387
+ when :data then self.class._data_rules
388
+ when :aria then self.class._aria_rules
389
+ when :attribute then self.class._attribute_rules
390
+ end
391
+
392
+ rules.each_with_object({}) do |rule, result|
393
+ next unless predicate_met?(rule[:predicate])
394
+
395
+ rule[:attrs].each do |key, value|
396
+ resolved = resolve_attr_value(value)
397
+ next if resolved.nil?
398
+
399
+ result[key] = if namespace == :data && DATA_MERGE_KEYS.include?(key) && result.key?(key)
400
+ "#{result[key]} #{resolved}"
401
+ else
402
+ resolved
403
+ end
404
+ end
405
+ end
406
+ end
407
+
408
+ # Layers `addition` on top of `base` using DATA_MERGE_KEYS semantics: keys
409
+ # in DATA_MERGE_KEYS concatenate space-separated, every other key replaces.
410
+ def merge_data_layer(base, addition)
411
+ addition.each_with_object(base.dup) do |(key, value), result|
412
+ result[key] = if DATA_MERGE_KEYS.include?(key)
413
+ [result[key], value].compact.join(" ")
414
+ else
415
+ value
416
+ end
417
+ end
418
+ end
419
+
420
+ # Resolves a predicate. nil predicate -> always true. Symbol -> call instance
421
+ # method. Proc -> instance_exec.
422
+ def predicate_met?(predicate)
423
+ case predicate
424
+ when nil then true
425
+ when Symbol then send(predicate)
426
+ when Proc then instance_exec(&predicate)
427
+ end
428
+ end
429
+
430
+ # Resolves a value for a DSL-declared attribute. Symbols become method calls
431
+ # on the instance; Procs are instance_exec'd; literals pass through. Result
432
+ # is stringified unless nil (which drops the attribute).
433
+ def resolve_attr_value(value)
434
+ resolved = case value
435
+ when Symbol then send(value)
436
+ when Proc then instance_exec(&value)
437
+ else value
438
+ end
439
+ resolved&.to_s
440
+ end
441
+
490
442
  def build_classes
491
443
  validate_axes!
492
444
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component_css_dsl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Lange
@@ -29,6 +29,20 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: tailwind_merge
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
32
46
  - !ruby/object:Gem::Dependency
33
47
  name: view_component
34
48
  requirement: !ruby/object:Gem::Requirement