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 +4 -4
- data/CHANGELOG.md +10 -7
- data/README.md +22 -1
- data/lib/view_component_css_dsl/version.rb +1 -1
- data/lib/view_component_css_dsl.rb +12 -177
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a27107bec7822ef21cc584498087a8d9cdf1ecf044bc5123754f05d5833dd869
|
|
4
|
+
data.tar.gz: 38f741bebdd9e6768008eaa64dfdb9b1bb9b07be4d2acdb038d3f79348a91d40
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
5
|
+
## [0.1.3] - 2026-05-18
|
|
6
6
|
|
|
7
|
-
###
|
|
7
|
+
### Changed
|
|
8
8
|
|
|
9
|
-
-
|
|
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
|
-
|
|
11
|
+
### Breaking
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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:
|
|
@@ -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
|
-
#
|
|
345
|
-
#
|
|
346
|
-
#
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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.
|
|
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
|