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 +4 -4
- data/CHANGELOG.md +8 -5
- data/README.md +140 -1
- data/lib/view_component_css_dsl/version.rb +1 -1
- data/lib/view_component_css_dsl.rb +143 -191
- 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
|
-
- `
|
|
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
|
-
|
|
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
|
-
-
|
|
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:
|
|
@@ -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
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
#
|
|
483
|
-
#
|
|
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.
|
|
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
|