view_component_css_dsl 0.1.2 → 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: eba29c79b834ffc0651fc3ff6b244e860de04218b6b29ca31df90634211fc6a6
4
- data.tar.gz: fb7183fa91dc0bb773d61dc0953df0cf17fed601d39609cc1b2e2d44da725f51
3
+ metadata.gz: a27107bec7822ef21cc584498087a8d9cdf1ecf044bc5123754f05d5833dd869
4
+ data.tar.gz: 38f741bebdd9e6768008eaa64dfdb9b1bb9b07be4d2acdb038d3f79348a91d40
5
5
  SHA512:
6
- metadata.gz: d28bfc76e89df1e4899fa396f0d45be296bbef32bed288d4893f908ad9038f28bc3f3e444675acf1408e01859f2cd6b007cb4fc346bad8389377765768952ff9
7
- data.tar.gz: 9353657fc812e95881ec400152852886d05f2292f978d590dc0475abb64cb87dfe61ba38c1cde972d8c2c6f22eb74d0e88bd2f2c632455d1caa5d368f6d230e4
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.2] - 2026-05-15
5
+ ## [0.1.3] - 2026-05-18
6
6
 
7
- ### Added
7
+ ### Changed
8
8
 
9
- - data, aria, and attribute DSL declarators for first-class HTML attribute declarations (cf37050)
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).
10
10
 
11
- ## [0.1.1] - 2026-05-15
11
+ ### Breaking
12
12
 
13
- ### Changed
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
14
 
15
- - `view_component` dependency pinned to `~> 4.0` (was `>= 4.0`) — RubyGems-recommended SemVer-aware constraint
16
- - Minimum Ruby version raised to 3.2 (was 3.1), matching the floor for `view_component >= 4.0`
15
+ ## [0.1.2] - 2026-05-15
16
+
17
+ ### Added
18
+
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
 
@@ -402,7 +410,7 @@ Renders:
402
410
 
403
411
  ## Smart merge behavior
404
412
 
405
- 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.
406
414
 
407
415
  | Component | Caller | Final classes | Why |
408
416
  | --- | --- | --- | --- |
@@ -419,6 +427,19 @@ Smart-merge handles Tailwind's conventions so caller and component CSS can coexi
419
427
 
420
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`.
421
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
+
422
443
  ## Inheritance
423
444
 
424
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.2"
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: []
@@ -341,99 +255,20 @@ module ViewComponentCssDsl
341
255
  end
342
256
  end
343
257
 
344
- # Overwrites base css with custom css from the caller, but only if they actually
345
- # interfere with each other. Modifier prefixes (hover:, md:, first:, etc.) create
346
- # 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
+ #
347
262
  # Examples:
348
263
  # - base: "pt-2", custom: "pt-4", result => "pt-4"
349
264
  # - base: "pb-2", custom: "pt-4", result => "pb-2 pt-4"
350
265
  # - base: "pb-2", custom: "p-4", result => "p-4"
351
266
  # - base: "block", custom: "first:hidden", result => "block first:hidden"
352
267
  def smart_merge(*css_strings)
353
- categorized = {}
354
- uncategorized = []
355
- # Store spacing classes grouped by modifier prefix
356
- # {prefix => [{class: "p-4", info: {type: :padding, axes: Set[:t,:r,:b,:l]}}, ...]}
357
- spacing_by_prefix = Hash.new { |h, k| h[k] = [] }
358
-
359
- css_strings.compact.each do |str|
360
- str.to_s.split.each do |cls|
361
- prefix, base_class = extract_modifier_prefix(cls)
362
-
363
- spacing = spacing_info(base_class)
364
- if spacing
365
- # Remove any existing spacing classes that overlap on same type and axes
366
- # within the same modifier prefix
367
- spacing_by_prefix[prefix].reject! do |existing|
368
- existing[:info][:type] == spacing[:type] &&
369
- existing[:info][:axes].subset?(spacing[:axes])
370
- end
371
- spacing_by_prefix[prefix] << {class: cls, info: spacing}
372
- else
373
- category = detect_category(base_class)
374
- if category
375
- key = "#{prefix}:#{category}"
376
- categorized[key] = cls
377
- else
378
- uncategorized << cls unless uncategorized.include?(cls)
379
- end
380
- end
381
- end
382
- end
383
-
384
- spacing_classes = spacing_by_prefix.values.flatten.map { |s| s[:class] }
385
- (uncategorized + spacing_classes + categorized.values).join(" ")
386
- end
387
-
388
- def spacing_info(css_class)
389
- # Check padding/margin with single regex (replaces 14 pattern checks)
390
- if (match = css_class.match(SPACING_REGEX))
391
- type = (match[1] == "p") ? :padding : :margin
392
- axes = SPACING_AXIS_MAP[match[2]]
393
- return {type:, axes:}
394
- end
395
-
396
- # Check border width (needs separate handling due to anchoring)
397
- if (match = css_class.match(BORDER_REGEX))
398
- axes = SPACING_AXIS_MAP[match[1]]
399
- return {type: :border, axes:}
400
- end
401
-
402
- nil
403
- end
404
-
405
- def detect_category(css_class)
406
- CATEGORIES.find { |_name, pattern| css_class.match?(pattern) }&.first
407
- end
408
-
409
- # Extracts the modifier prefix from a Tailwind class
410
- # e.g., "md:hover:bg-blue-500" → ["md:hover", "bg-blue-500"]
411
- # e.g., "bg-white" → ["", "bg-white"]
412
- def extract_modifier_prefix(css_class)
413
- # Fast path: most classes don't have modifiers
414
- return ["", css_class] unless css_class.include?(":")
415
-
416
- parts = css_class.split(":")
417
- return ["", css_class] if parts.size == 1
418
-
419
- # Find where modifiers end and the actual class begins
420
- modifier_parts = []
421
- parts.each_with_index do |part, i|
422
- if known_modifier?(part) && i < parts.size - 1
423
- modifier_parts << part
424
- else
425
- base_class = parts[i..].join(":")
426
- return [modifier_parts.join(":"), base_class]
427
- end
428
- end
429
-
430
- # Fallback (shouldn't reach here)
431
- ["", css_class]
432
- end
268
+ input = css_strings.flat_map { |s| s.to_s.split }.reject(&:empty?).join(" ")
269
+ return "" if input.empty?
433
270
 
434
- def known_modifier?(str)
435
- return true if KNOWN_MODIFIERS.include?(str)
436
- MODIFIER_PATTERNS.any? { |pattern| str.match?(pattern) }
271
+ MERGER.merge(input)
437
272
  end
438
273
  end
439
274
 
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.2
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