view_component_css_dsl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9fcd1b87aa8e32ecd595601ffbce3015c1cd7d1119a97967fda706f859499654
4
+ data.tar.gz: 3b473073f9fca60d277fa0dd9700cd66b2da76a2f72222830f3a3d76bc294ce7
5
+ SHA512:
6
+ metadata.gz: f4c117c6970000983d5acbdbb2d75254ac9875b5f0faeb64e80e78e88dd8644ee5dc3a01c24dfbac1257405cac55aa92c5975c1ac639cf28b8f2632e42002eec
7
+ data.tar.gz: a57068184216a9c7f3d0554efa9658805749d35aa57f8d1592a1a7c3f26bb5a2b3bc0333d68e073cb4281e619cff6067c84db55118196ee227b32bdbddfbcc98
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0]
6
+ - Initial release. Extracted from SOFware/forge.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeff Lange
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,332 @@
1
+ # view_component_css_dsl
2
+
3
+ [![CI](https://github.com/SOFware/view_component_css_dsl/actions/workflows/ci.yml/badge.svg)](https://github.com/SOFware/view_component_css_dsl/actions/workflows/ci.yml)
4
+
5
+ A declarative DSL for styling [ViewComponent](https://viewcomponent.org/) components with [Tailwind CSS](https://tailwindcss.com/).
6
+
7
+ ```ruby
8
+ class ButtonComponent < ApplicationComponent
9
+ css "inline-flex rounded px-4 py-2"
10
+ css variant: :primary, style: "bg-blue-500 text-white"
11
+ css variant: :danger, style: "bg-red-500 text-white"
12
+ css :disabled?, style: "opacity-50"
13
+ end
14
+ ```
15
+
16
+ Replaces hand-rolled styling boilerplate with declarative one-liners for base styles, variants, and conditionals. Callers override per-instance via `class:` — smart-merge handles the rest.
17
+
18
+ ## Why
19
+
20
+ Without this DSL, a ViewComponent with a few variants and a disabled state usually looks something like:
21
+
22
+ ```ruby
23
+ class ButtonComponent < ViewComponent::Base
24
+ VARIANTS = %i[primary danger].freeze
25
+
26
+ def initialize(variant: :primary, disabled: false, extra_class: nil)
27
+ raise ArgumentError, "invalid variant" unless VARIANTS.include?(variant)
28
+ @variant = variant
29
+ @disabled = disabled
30
+ @extra_class = extra_class
31
+ end
32
+
33
+ private
34
+
35
+ def css_class
36
+ [
37
+ "inline-flex rounded px-4 py-2",
38
+ variant_class,
39
+ ("opacity-50" if @disabled),
40
+ @extra_class
41
+ ].compact.join(" ")
42
+ end
43
+
44
+ def variant_class
45
+ case @variant
46
+ when :primary then "bg-blue-500 text-white"
47
+ when :danger then "bg-red-500 text-white"
48
+ end
49
+ end
50
+ end
51
+ ```
52
+
53
+ With the DSL:
54
+
55
+ ```ruby
56
+ class ButtonComponent < ApplicationComponent
57
+ css "inline-flex rounded px-4 py-2"
58
+ css variant: :primary, style: "bg-blue-500 text-white"
59
+ css variant: :danger, style: "bg-red-500 text-white"
60
+ css :disabled?, style: "opacity-50"
61
+
62
+ def initialize(variant: :primary, disabled: false)
63
+ @variant = variant
64
+ @disabled = disabled
65
+ end
66
+
67
+ private
68
+
69
+ def disabled? = @disabled
70
+ end
71
+ ```
72
+
73
+ - Variant validation is automatic; passing `:unknown` raises an `ArgumentError`.
74
+ - Declarations are easy to scan, easy to extend.
75
+ - 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.
76
+
77
+ ## Philosophy
78
+
79
+ A handful of opinions are baked into this DSL. It still works if you ignore them, but it's a lot nicer if you don't.
80
+
81
+ ### Styling lives with the component
82
+
83
+ Not in external stylesheets. Open the component file and you see exactly what it looks like. No grepping for selectors. No cascade surprises.
84
+
85
+ ### Significant styling lives on the top-level element
86
+
87
+ A component renders one semantic block; that block is where its appearance lives. The DSL's `css` declarations describe that block.
88
+
89
+ ### Caller customization targets the top-level element
90
+
91
+ When a caller passes `class: "..."`, the DSL smart-merges those classes onto the top-level element. Predictable surface, predictable override.
92
+
93
+ ### Sub-element styling = sub-component
94
+
95
+ When a piece of your component needs its own styling decisions, promote it to its own ViewComponent (typically as a slot). Pass the shared semantic prop down; each component owns its own style table:
96
+
97
+ ```ruby
98
+ class CardComponent < ApplicationComponent
99
+ css "rounded border p-4"
100
+ css type: :success, style: "border-green-200 bg-green-50 text-green-900"
101
+ css type: :danger, style: "border-red-200 bg-red-50 text-red-900"
102
+
103
+ renders_one :card_header, ->(**html_attrs, &block) {
104
+ Card::HeaderComponent.new(type:, **html_attrs, &block)
105
+ }
106
+
107
+ def initialize(type:)
108
+ @type = type
109
+ end
110
+
111
+ private
112
+
113
+ attr_reader :type
114
+ end
115
+
116
+ class Card::HeaderComponent < ApplicationComponent
117
+ css "font-medium"
118
+ css type: :success, style: "text-sm"
119
+ css type: :danger, style: "text-lg font-bold"
120
+
121
+ def initialize(type:)
122
+ @type = type
123
+ end
124
+ end
125
+ ```
126
+
127
+ The card renders the header as a slot, passing `type:` through. Without the DSL, this is typically a `case` statement or `class_names` block in both components — duplicated logic, more places for the style decision to drift. With it, each component reacts declaratively to the same shared prop.
128
+
129
+ If you find yourself reaching inside a component to customize a sub-element, especially with dynamic styling, the sub-element wants to be its own component.
130
+
131
+ ## Requirements
132
+
133
+ - Ruby 3.2+ (matches the floor for `view_component >= 4.0`)
134
+ - [`view_component`](https://github.com/ViewComponent/view_component) `>= 4.0`
135
+ - [Tailwind CSS](https://tailwindcss.com/) `>= 3.0` (the merge logic targets Tailwind's class-name syntax; v4 works — the syntax is compatible)
136
+
137
+ ## Installation
138
+
139
+ ```sh
140
+ bundle add view_component_css_dsl
141
+ ```
142
+
143
+ ## Setup
144
+
145
+ Include the concern in your base class, and inherit your components from it.
146
+
147
+ `html_attrs` is automatically passed to all components; no declaration needed.
148
+
149
+ The one piece of boilerplate: you must splat `**html_attrs` onto the top-level element of each component template.
150
+
151
+ Main setup:
152
+ ```ruby
153
+ # app/components/application_component.rb
154
+ class ApplicationComponent < ViewComponent::Base
155
+ include ViewComponentCssDsl
156
+ end
157
+ ```
158
+
159
+ Component inherits from ApplicationComponent, gaining access to CssDsl
160
+ ```ruby
161
+ # app/components/button_component.rb
162
+ class ButtonComponent < ApplicationComponent
163
+ css "rounded px-4 py-2 bg-blue-500 text-white"
164
+
165
+ css variant: :success, style: "text-green-600"
166
+ css variant: :danger, style: "text-lg font-bold text-red-600"
167
+
168
+ def initialize(variant: :primary)
169
+ @variant = variant
170
+ end
171
+ end
172
+ ```
173
+
174
+ Splat `**html_attrs` onto the top-level element.
175
+ ```erb
176
+ <%# app/components/button_component.html.erb %>
177
+ <%= tag.button **html_attrs do %>
178
+ <%= content %>
179
+ <% end %>
180
+ ```
181
+
182
+ Two conventions to follow:
183
+
184
+ 1. **`include ViewComponentCssDsl`** in your base component class. To opt out for one component, inherit from `ViewComponent::Base` directly.
185
+ 2. **Splat `**html_attrs`** onto the top-level element. This is what makes caller-passed attributes (`class:`, `data:`, `id:`, `aria:`, etc.) reach the DOM. A future version may automate this away.
186
+
187
+ ## The four `css` patterns
188
+
189
+ ### Base CSS
190
+
191
+ Always applied. Inherited and smart-merged into child components.
192
+
193
+ ```ruby
194
+ css "rounded border shadow p-4 bg-white"
195
+ ```
196
+
197
+ ### Axis-based variants
198
+
199
+ Applied when the named instance variable matches. The DSL reads `@<axis>` from the instance.
200
+
201
+ ```ruby
202
+ css variant: :primary, style: "bg-blue-500 text-white"
203
+ css variant: :danger, style: "bg-red-500 text-white"
204
+
205
+ css size: :sm, style: "px-2 py-1 text-sm"
206
+ css size: :lg, style: "px-6 py-3 text-lg"
207
+
208
+ # Multi-axis rule — applied only when ALL axes match
209
+ css variant: :primary, size: :lg, style: "font-bold ring-2"
210
+ ```
211
+
212
+ Passing an axis value with no matching rule raises `ArgumentError`:
213
+
214
+ ```ruby
215
+ MyComponent.new(variant: :unknown).css
216
+ # => ArgumentError: Unknown variant :unknown for MyComponent.
217
+ # Valid values: :primary, :danger
218
+ ```
219
+
220
+ ### Method-based conditionals
221
+
222
+ Applied when the method returns truthy on the instance.
223
+
224
+ ```ruby
225
+ css :disabled?, style: "opacity-50 cursor-not-allowed"
226
+ css :active?, style: "ring-2 ring-blue-500"
227
+ ```
228
+
229
+ ### Proc-based dynamic CSS
230
+
231
+ Evaluated at render time in the instance's context. Use when the class can't be known statically.
232
+
233
+ ```ruby
234
+ css "base"
235
+ css -> { "pl-#{@indent * 4}" }
236
+ ```
237
+
238
+ Procs returning `nil` are dropped. Procs participate in smart_merge.
239
+
240
+ ## Caller customization
241
+
242
+ 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.
243
+
244
+ ### Vanilla call
245
+
246
+ ```ruby
247
+ class ButtonComponent < ApplicationComponent
248
+ css "rounded px-4 py-2 bg-blue-500 text-white"
249
+ end
250
+
251
+ render ButtonComponent.new
252
+ ```
253
+
254
+ Renders:
255
+
256
+ ```html
257
+ <button class="rounded px-4 py-2 bg-blue-500 text-white"></button>
258
+ ```
259
+
260
+ ### Call with overrides
261
+
262
+ ```ruby
263
+ render ButtonComponent.new(
264
+ class: "mt-4 bg-red-500",
265
+ data: {id: "submit-btn"},
266
+ aria: {label: "Submit form"}
267
+ )
268
+ ```
269
+
270
+ Renders:
271
+
272
+ ```html
273
+ <button
274
+ class="rounded px-4 py-2 mt-4 bg-red-500 text-white"
275
+ data-id="submit-btn"
276
+ aria-label="Submit form">
277
+ </button>
278
+ ```
279
+
280
+ - `bg-red-500` from the caller replaced `bg-blue-500` from the component (same category).
281
+ - `mt-4` was added (no margin in the base).
282
+ - `rounded`, `px-4`, `py-2`, `text-white` retained from the base.
283
+ - `data-id` and `aria-label` flow through to the DOM untouched.
284
+
285
+ ## Smart merge behavior
286
+
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.
288
+
289
+ | Component | Caller | Final classes | Why |
290
+ | --- | --- | --- | --- |
291
+ | `bg-white` | `bg-blue-500` | `bg-blue-500` | Same category (background) — caller wins |
292
+ | `p-4` | `p-8` | `p-8` | All-padding overrides all-padding |
293
+ | `px-4` | `py-2` | `px-4 py-2` | Different spacing axes — both kept |
294
+ | `p-4` | `pb-6` | `p-4 pb-6` | Specific side extends the all-side base |
295
+ | `pl-2` | `px-5` | `px-5` | Broader axis (`x`) absorbs the narrower (`l`) |
296
+ | `border-t` | `border-t-2` | `border-t-2` | Same side, more specific width — caller wins |
297
+ | `border-2` | `border-red-600` | `border-2 border-red-600` | Width and color are independent |
298
+ | `bg-white` | `hover:bg-blue-500` | `bg-white hover:bg-blue-500` | Modifier prefix is its own namespace |
299
+ | `hover:bg-blue-500` | `hover:bg-red-500` | `hover:bg-red-500` | Caller wins within the modifier namespace |
300
+ | `bg-white` | `data-[open]:bg-gray-100` | `bg-white data-[open]:bg-gray-100` | Arbitrary modifier is its own namespace |
301
+
302
+ 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
+
304
+ ## Inheritance
305
+
306
+ A child component's `css "..."` declaration is smart-merged with its parent's:
307
+
308
+ ```ruby
309
+ class CardComponent < ApplicationComponent
310
+ css "rounded shadow p-4 bg-white"
311
+ end
312
+
313
+ class HighlightedCardComponent < CardComponent
314
+ css "bg-yellow-50 ring-2 ring-yellow-200"
315
+ # Final base CSS:
316
+ # "rounded shadow p-4 bg-yellow-50 ring-2 ring-yellow-200"
317
+ end
318
+ ```
319
+
320
+ Axis, method, and proc rules are appended, not overridden.
321
+
322
+ ## Development
323
+
324
+ ```sh
325
+ bundle install
326
+ bundle exec rspec
327
+ bundle exec standardrb
328
+ ```
329
+
330
+ ## License
331
+
332
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponentCssDsl
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,545 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+ require "active_support/core_ext/object/blank"
6
+ require "active_support/core_ext/hash/except"
7
+ require "active_support/core_ext/hash/slice"
8
+ require "active_support/core_ext/object/inclusion"
9
+
10
+ require_relative "view_component_css_dsl/version"
11
+
12
+ module ViewComponentCssDsl
13
+ extend ActiveSupport::Concern
14
+
15
+ # HTML attributes auto-extracted from kwargs at construction time. Anything in
16
+ # this set is captured into @html_attrs instead of being passed to initialize,
17
+ # so callers can pass `class:`, `data:`, `aria:`, etc. without the component
18
+ # declaring them. To opt out, accept a kwarg with the same name in initialize
19
+ # (e.g. `def initialize(class:)`) or use a keyrest name other than html_attrs.
20
+ HTML_ATTR_KEYS = Set[
21
+ :alt, :aria, :autofocus,
22
+ :class, :colspan, :contenteditable,
23
+ :data, :dir, :disabled, :download, :draggable,
24
+ :enterkeyhint,
25
+ :formaction,
26
+ :headers, :hidden, :href,
27
+ :id, :inputmode,
28
+ :lang, :loading, :low,
29
+ :media,
30
+ :onclick, :open, :optimum,
31
+ :popover, :popovertarget, :popovertargetaction, :preload,
32
+ :readonly, :rel, :role, :rowspan,
33
+ :spellcheck, :src, :srcset, :style,
34
+ :tabindex, :target, :title, :type, :value
35
+ ].freeze
36
+
37
+ # Single combined regex for padding/margin spacing (replaces 14 separate patterns)
38
+ # Captures: type (p/m), axis (x/y/t/r/b/l or nil for all), value
39
+ SPACING_REGEX = /\b(p|m)(x|y|t|r|b|l)?-(\d+)\b/
40
+
41
+ # Maps axis character to Set of affected sides
42
+ SPACING_AXIS_MAP = {
43
+ nil => Set[:t, :r, :b, :l], # p-4, m-4 = all sides
44
+ "x" => Set[:l, :r],
45
+ "y" => Set[:t, :b],
46
+ "t" => Set[:t],
47
+ "r" => Set[:r],
48
+ "b" => Set[:b],
49
+ "l" => Set[:l]
50
+ }.freeze
51
+
52
+ # Border width patterns (kept separate due to anchoring requirements)
53
+ BORDER_REGEX = /^border(?:-(x|y|t|r|b|l))?(?:-\d+)?$/
54
+
55
+ # Other category patterns (non-spacing, simple override by category)
56
+ # IMPORTANT: Use anchored patterns (^/$) to avoid matching substrings within
57
+ # compound classes (e.g., `h-8` within `min-h-8`, `flex` within `inline-flex`)
58
+ CATEGORIES = {
59
+ background: /^bg-/,
60
+ text_color: /^text-((\w+-\d+)|white|black|transparent|current|inherit|action|success|danger|warning|brand)(\/\d+)?$/,
61
+ text_size: /^text-(xs|sm|base|lg|xl|\d*xl)$/,
62
+ border_color: /^border-(?!t|r|b|l|x|y|\d)(\w+-\d+|\w+)(\/\d+)?$/,
63
+ width: /^w-/,
64
+ height: /^h-/,
65
+ min_width: /^min-w-/,
66
+ min_height: /^min-h-/,
67
+ max_width: /^max-w-/,
68
+ max_height: /^max-h-/,
69
+ # Display classes - note: `hidden` is intentionally excluded because it's
70
+ # commonly used as a visibility toggle alongside other display classes
71
+ # (e.g., "inline-flex hidden" where JS removes "hidden" to show element)
72
+ display: /^(block|inline-block|inline-flex|inline-grid|inline|flex|grid|table-cell|table-row|table|contents|flow-root|list-item)$/,
73
+ justify: /^justify-/,
74
+ align: /^items-/,
75
+ font_weight: /^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/,
76
+ rounded: /^rounded(-none|-sm|-md|-lg|-xl|-2xl|-3xl|-full)?$/,
77
+ position: /^(static|relative|absolute|fixed|sticky)$/
78
+ }.freeze
79
+
80
+ # Known Tailwind modifiers (prefixes like hover:, md:, first:, etc.)
81
+ # Classes with different modifiers should NOT conflict with each other
82
+ KNOWN_MODIFIERS = Set[
83
+ # Responsive
84
+ "sm", "md", "lg", "xl", "2xl",
85
+ "max-sm", "max-md", "max-lg", "max-xl", "max-2xl",
86
+ # Interactive state
87
+ "hover", "focus", "focus-within", "focus-visible", "active", "visited", "target",
88
+ # Structural
89
+ "first", "last", "only", "odd", "even",
90
+ "first-of-type", "last-of-type", "only-of-type", "empty",
91
+ # Form state
92
+ "disabled", "enabled", "checked", "indeterminate", "default",
93
+ "required", "valid", "invalid", "in-range", "out-of-range",
94
+ "placeholder-shown", "autofill", "read-only",
95
+ # Pseudo-elements
96
+ "before", "after", "first-letter", "first-line",
97
+ "marker", "selection", "file", "backdrop", "placeholder",
98
+ # Media/Preference
99
+ "dark", "print", "portrait", "landscape",
100
+ "motion-safe", "motion-reduce", "contrast-more", "contrast-less",
101
+ "forced-colors",
102
+ # Direction
103
+ "rtl", "ltr",
104
+ # Attribute
105
+ "open",
106
+ # Direct children
107
+ "*"
108
+ ].freeze
109
+
110
+ # Patterns that match dynamic modifiers (with optional names/arbitrary values)
111
+ # These use flexible regex to match ANY valid Tailwind modifier syntax
112
+ MODIFIER_PATTERNS = [
113
+ /^group(?:\/\w+)?$/, # group, group/<any-name>
114
+ /^group-\w+(?:\/\w+)?$/, # group-hover, group-<state>/<any-name>
115
+ /^peer(?:\/\w+)?$/, # peer, peer/<any-name>
116
+ /^peer-\w+(?:\/\w+)?$/, # peer-checked, peer-<state>/<any-name>
117
+ /^aria-\w+$/, # aria-checked, aria-<any-attr>
118
+ /^aria-\[.+\]$/, # aria-[<arbitrary>]
119
+ /^data-\[.+\]$/, # data-[<arbitrary>]
120
+ /^supports-\[.+\]$/, # supports-[<arbitrary>]
121
+ /^has-\[.+\]$/, # has-[<arbitrary>]
122
+ /^group-has-\[.+\]$/, # group-has-[<arbitrary>]
123
+ /^peer-has-\[.+\]$/, # peer-has-[<arbitrary>]
124
+ /^min-\[.+\]$/, # min-[<arbitrary>]
125
+ /^max-\[.+\]$/ # max-[<arbitrary>]
126
+ ].freeze
127
+
128
+ included do
129
+ class_attribute :_css_base, instance_writer: false, default: ""
130
+ class_attribute :_css_axis_rules, instance_writer: false, default: []
131
+ class_attribute :_css_method_rules, instance_writer: false, default: []
132
+ class_attribute :_css_proc_rules, instance_writer: false, default: []
133
+ # Track which axis names are used in rules (for cache key generation)
134
+ class_attribute :_css_axis_names, instance_writer: false, default: Set.new
135
+ # Precomputed map of axis -> valid values for validation (built at class definition time)
136
+ class_attribute :_css_defined_axes, instance_writer: false, default: {}
137
+ # Lazy cache for precomputed base + axis CSS combinations
138
+ class_attribute :_css_cache, instance_writer: false, default: nil
139
+ # Memoization cache for smart_merge results (axis + method + proc combinations)
140
+ class_attribute :_css_merge_cache, instance_writer: false, default: nil
141
+ end
142
+
143
+ class_methods do
144
+ # Unified css class method supporting multiple patterns:
145
+ # - css "string" -> base CSS, always applied
146
+ # - css :method?, style: "string" -> applied when method? returns truthy
147
+ # - css axis: :value, style: "string" -> applied when @axis == :value
148
+ # - css axis: :val, other: :val, style: "string" -> applied when ALL axes match
149
+ # - css -> { dynamic_css } -> proc evaluated at render time
150
+ def css(*args, **options)
151
+ case args.first
152
+ when Proc
153
+ # Proc-based: css -> { "pl-#{@indent * 4}" }
154
+ self._css_proc_rules = _css_proc_rules.dup
155
+ _css_proc_rules << args.first
156
+ self._css_merge_cache = nil
157
+ when Symbol
158
+ # Method-based: css :disabled?, style: "opacity-50"
159
+ method_name = args.first
160
+ styles = options[:style]
161
+ raise ArgumentError, "css :#{method_name} requires style: keyword" unless styles
162
+
163
+ self._css_method_rules = _css_method_rules.dup
164
+ _css_method_rules << {method: method_name, styles: styles}
165
+ self._css_merge_cache = nil
166
+ when String
167
+ if options.empty?
168
+ # Base CSS: css "rounded p-4"
169
+ parent_base_css = superclass.respond_to?(:_css_base) ? superclass._css_base : ""
170
+ self._css_base = if parent_base_css.present?
171
+ smart_merge(parent_base_css, args.first)
172
+ else
173
+ args.first
174
+ end
175
+ # Invalidate caches when base changes
176
+ self._css_cache = nil
177
+ self._css_merge_cache = nil
178
+ else
179
+ raise ArgumentError, "css with String and options not supported. " \
180
+ "Use: css variant: :name, style: \"classes\""
181
+ end
182
+ when nil
183
+ if (styles = options[:style])
184
+ # Axis-based: css variant: :primary, style: "bg-blue-500"
185
+ axes = options.except(:style)
186
+ self._css_axis_rules = _css_axis_rules.dup
187
+ _css_axis_rules << {axes:, styles:}
188
+ # Track axis names for cache key generation
189
+ self._css_axis_names = _css_axis_names.dup.merge(axes.keys)
190
+ # Precompute valid values per axis for validation
191
+ self._css_defined_axes = _css_defined_axes.transform_values(&:dup)
192
+ axes.each do |axis, value|
193
+ (_css_defined_axes[axis] ||= Set.new) << value
194
+ end
195
+ # Invalidate caches when rules change
196
+ self._css_cache = nil
197
+ self._css_merge_cache = nil
198
+ else
199
+ raise ArgumentError, "css requires style: when using axis conditions"
200
+ end
201
+ else
202
+ raise ArgumentError, "Unknown css argument type: #{args.first.class}"
203
+ end
204
+ end
205
+
206
+ # Override `new` to auto-extract HTML attributes from kwargs into @html_attrs,
207
+ # so components don't need to declare **html_attrs in their initialize signature.
208
+ # Anything in HTML_ATTR_KEYS that wasn't declared as a kwarg is captured.
209
+ def new(*args, **kwargs, &block)
210
+ info = initialize_params_info
211
+ html_attrs = {}
212
+ if info[:uses_html_attrs_keyrest]
213
+ extractable = HTML_ATTR_KEYS.intersection(kwargs.keys) - info[:declared_kwargs]
214
+ html_attrs = kwargs.extract!(*extractable)
215
+ end
216
+
217
+ instance = allocate
218
+ instance.instance_variable_set(:@html_attrs, html_attrs)
219
+ instance.send(:initialize, *args, **kwargs, &block)
220
+
221
+ # Merge with any @html_attrs the component set inside initialize (older
222
+ # components that still declare **html_attrs). Caller-provided values win.
223
+ existing = instance.instance_variable_get(:@html_attrs) || {}
224
+ instance.instance_variable_set(:@html_attrs, existing.merge(html_attrs))
225
+ instance
226
+ end
227
+
228
+ # Analyzes the initialize signature once and caches the result. Auto-extraction
229
+ # happens unless the component declares a non-html_attrs keyrest (like **options),
230
+ # in which case the component wants to receive everything itself.
231
+ def initialize_params_info
232
+ @initialize_params_info ||= begin
233
+ declared_kwargs = Set.new
234
+ keyrest_name = nil
235
+ instance_method(:initialize).parameters.each do |type, name|
236
+ case type
237
+ when :key, :keyreq then declared_kwargs << name
238
+ when :keyrest then keyrest_name = name
239
+ end
240
+ end
241
+ uses_html_attrs_keyrest = keyrest_name.nil? || keyrest_name == :html_attrs
242
+ {declared_kwargs:, uses_html_attrs_keyrest:}
243
+ end
244
+ end
245
+
246
+ # Overwrites base css with custom css from the caller, but only if they actually
247
+ # interfere with each other. Modifier prefixes (hover:, md:, first:, etc.) create
248
+ # separate "namespaces" so they don't conflict with base classes.
249
+ # Examples:
250
+ # - base: "pt-2", custom: "pt-4", result => "pt-4"
251
+ # - base: "pb-2", custom: "pt-4", result => "pb-2 pt-4"
252
+ # - base: "pb-2", custom: "p-4", result => "p-4"
253
+ # - base: "block", custom: "first:hidden", result => "block first:hidden"
254
+ def smart_merge(*css_strings)
255
+ categorized = {}
256
+ uncategorized = []
257
+ # Store spacing classes grouped by modifier prefix
258
+ # {prefix => [{class: "p-4", info: {type: :padding, axes: Set[:t,:r,:b,:l]}}, ...]}
259
+ spacing_by_prefix = Hash.new { |h, k| h[k] = [] }
260
+
261
+ css_strings.compact.each do |str|
262
+ str.to_s.split.each do |cls|
263
+ prefix, base_class = extract_modifier_prefix(cls)
264
+
265
+ spacing = spacing_info(base_class)
266
+ if spacing
267
+ # Remove any existing spacing classes that overlap on same type and axes
268
+ # within the same modifier prefix
269
+ spacing_by_prefix[prefix].reject! do |existing|
270
+ existing[:info][:type] == spacing[:type] &&
271
+ existing[:info][:axes].subset?(spacing[:axes])
272
+ end
273
+ spacing_by_prefix[prefix] << {class: cls, info: spacing}
274
+ else
275
+ category = detect_category(base_class)
276
+ if category
277
+ key = "#{prefix}:#{category}"
278
+ categorized[key] = cls
279
+ else
280
+ uncategorized << cls unless uncategorized.include?(cls)
281
+ end
282
+ end
283
+ end
284
+ end
285
+
286
+ spacing_classes = spacing_by_prefix.values.flatten.map { |s| s[:class] }
287
+ (uncategorized + spacing_classes + categorized.values).join(" ")
288
+ end
289
+
290
+ def spacing_info(css_class)
291
+ # Check padding/margin with single regex (replaces 14 pattern checks)
292
+ if (match = css_class.match(SPACING_REGEX))
293
+ type = (match[1] == "p") ? :padding : :margin
294
+ axes = SPACING_AXIS_MAP[match[2]]
295
+ return {type:, axes:}
296
+ end
297
+
298
+ # Check border width (needs separate handling due to anchoring)
299
+ if (match = css_class.match(BORDER_REGEX))
300
+ axes = SPACING_AXIS_MAP[match[1]]
301
+ return {type: :border, axes:}
302
+ end
303
+
304
+ nil
305
+ end
306
+
307
+ def detect_category(css_class)
308
+ CATEGORIES.find { |_name, pattern| css_class.match?(pattern) }&.first
309
+ end
310
+
311
+ # Extracts the modifier prefix from a Tailwind class
312
+ # e.g., "md:hover:bg-blue-500" → ["md:hover", "bg-blue-500"]
313
+ # e.g., "bg-white" → ["", "bg-white"]
314
+ def extract_modifier_prefix(css_class)
315
+ # Fast path: most classes don't have modifiers
316
+ return ["", css_class] unless css_class.include?(":")
317
+
318
+ parts = css_class.split(":")
319
+ return ["", css_class] if parts.size == 1
320
+
321
+ # Find where modifiers end and the actual class begins
322
+ modifier_parts = []
323
+ parts.each_with_index do |part, i|
324
+ if known_modifier?(part) && i < parts.size - 1
325
+ modifier_parts << part
326
+ else
327
+ base_class = parts[i..].join(":")
328
+ return [modifier_parts.join(":"), base_class]
329
+ end
330
+ end
331
+
332
+ # Fallback (shouldn't reach here)
333
+ ["", css_class]
334
+ end
335
+
336
+ def known_modifier?(str)
337
+ return true if KNOWN_MODIFIERS.include?(str)
338
+ MODIFIER_PATTERNS.any? { |pattern| str.match?(pattern) }
339
+ end
340
+ end
341
+
342
+ # Instance method: generates final CSS string
343
+ def css
344
+ build_classes
345
+ end
346
+
347
+ # Returns caller's custom classes from html_attrs
348
+ def custom_css
349
+ return "" unless @html_attrs
350
+
351
+ @html_attrs.fetch(:class, "")
352
+ end
353
+
354
+ # Returns the hash to splat onto the top-level element of a component template:
355
+ #
356
+ # <%= tag.div **html_attrs do %> ... <% end %>
357
+ #
358
+ # Includes the smart-merged `:class`, merged `:data` and `:aria` (from any
359
+ # component-defined defaults + caller overrides), and every other caller-passed
360
+ # HTML attribute (`id:`, `role:`, etc.) forwarded to the rendered element.
361
+ def html_attrs
362
+ return {} unless @html_attrs
363
+
364
+ result = @html_attrs.except(:aria, :class, :data)
365
+
366
+ # Only include aria/data if they have content, otherwise they'd override
367
+ # inline attrs in templates like: tag.div data: {foo: "bar"}, **html_attrs
368
+ aria = final_aria_attrs
369
+ data = final_data_attrs
370
+ result[:aria] = aria if aria.present?
371
+ result[:data] = data if data.present?
372
+
373
+ css_value = css
374
+ result[:class] = css_value if css_value.present?
375
+ result
376
+ end
377
+
378
+ # Overwrite in component subclass to set default data-attrs. They will be merged
379
+ # into html_attrs.
380
+ #
381
+ # Example:
382
+ #
383
+ # def data_attrs
384
+ # {
385
+ # controller: "some-stimulus-controller",
386
+ # action: "click->some-stimulus-controller#someAction",
387
+ # active: active?
388
+ # }
389
+ # end
390
+ #
391
+ def data_attrs
392
+ {}
393
+ end
394
+
395
+ DATA_MERGE_KEYS = %i[controller action].freeze
396
+
397
+ # Loop through data-attrs and merge values from DATA_MERGE_KEYS. Overwrite any
398
+ # others.
399
+ #
400
+ # Ensures the caller doesn't wipe out e.g. data-controller or data-action values
401
+ # defined by the dev in #data_attrs
402
+ #
403
+ # Example:
404
+ #
405
+ # Assuming MyComponent with #data_attrs defined as:
406
+ #
407
+ # def data_attrs
408
+ # {
409
+ # controller: "foo",
410
+ # label: "Hello world"
411
+ # }
412
+ # end
413
+ #
414
+ # MyComponent.new(data: {controller: "bar", label: "Goodbye"})
415
+ #
416
+ # Will output the following data-attrs:
417
+ #
418
+ # <div data-controller="foo bar" data-label="Goodbye">
419
+ #
420
+ # Notice:
421
+ # - data-controller from the caller is set alongside "foo" instead of overwriting
422
+ # - In contrast, data-label from the caller overwrites the default
423
+ #
424
+ def final_data_attrs
425
+ incoming_data = @html_attrs.fetch(:data, {})
426
+ incoming_data.each_with_object(data_attrs) do |(key, value), final_data|
427
+ final_value = if key.in?(DATA_MERGE_KEYS)
428
+ [data_attrs[key], value].compact.join(" ")
429
+ else
430
+ value
431
+ end
432
+
433
+ final_data[key] = final_value
434
+ end
435
+ end
436
+
437
+ # Overwrite in subclass to define default aria-attrs
438
+ def aria_attrs
439
+ {}
440
+ end
441
+
442
+ # Using merge allows for default value #aria_attrs, but also for dev to override
443
+ # that value per-instance as needed
444
+ def final_aria_attrs
445
+ aria_attrs.merge(@html_attrs.fetch(:aria, {}))
446
+ end
447
+
448
+ private
449
+
450
+ def build_classes
451
+ validate_axes!
452
+
453
+ # Get cached base + axis CSS (computed once per axis combination)
454
+ base_with_axes = cached_base_axis_css
455
+
456
+ # Check for dynamic elements that require runtime smart_merge
457
+ method_css = collect_matching_method_css
458
+ proc_css = collect_proc_css
459
+ custom = custom_css
460
+
461
+ # Fast path: no dynamic elements, return cached result directly
462
+ if method_css.blank? && proc_css.blank? && custom.blank?
463
+ return base_with_axes
464
+ end
465
+
466
+ # Memoize smart_merge results when no custom CSS (bounded cache)
467
+ # Custom CSS from callers would create unbounded cache entries
468
+ if custom.blank?
469
+ cache = self.class._css_merge_cache ||= {}
470
+ cache_key = [current_axis_key, method_css, proc_css].join("|")
471
+ return cache[cache_key] ||= self.class.smart_merge(base_with_axes, method_css, proc_css)
472
+ end
473
+
474
+ # Custom CSS present - can't memoize, run full smart_merge
475
+ self.class.smart_merge(base_with_axes, method_css, proc_css, custom)
476
+ end
477
+
478
+ # Returns cached CSS for base + current axis values
479
+ # Lazy-computes and caches on first access for each axis combination
480
+ def cached_base_axis_css
481
+ cache = self.class._css_cache ||= {}
482
+ axis_key = current_axis_key
483
+
484
+ cache[axis_key] ||= compute_base_axis_css
485
+ end
486
+
487
+ # Builds a cache key from current axis instance variable values
488
+ def current_axis_key
489
+ self.class._css_axis_names.map { |name| instance_variable_get(:"@#{name}") }
490
+ end
491
+
492
+ # Computes smart_merge of base + matching axis rules (no caching)
493
+ def compute_base_axis_css
494
+ base = self.class._css_base
495
+ axis_css = collect_matching_axis_css
496
+ return base if axis_css.blank?
497
+
498
+ self.class.smart_merge(base, axis_css)
499
+ end
500
+
501
+ def collect_matching_axis_css
502
+ self.class._css_axis_rules.filter_map do |rule|
503
+ matches = rule[:axes].all? do |axis, expected_value|
504
+ actual = instance_variable_get(:"@#{axis}")
505
+ actual == expected_value
506
+ end
507
+ rule[:styles] if matches
508
+ end.join(" ")
509
+ end
510
+
511
+ def validate_axes!
512
+ defined_axes = self.class._css_defined_axes
513
+ return if defined_axes.empty?
514
+
515
+ # Check each axis has a matching rule for its current value
516
+ defined_axes.each do |axis, valid_values|
517
+ current = instance_variable_get(:"@#{axis}")
518
+ next if current.nil? # Axis not set, skip validation
519
+
520
+ unless valid_values.include?(current)
521
+ valid_list = valid_values.map { |v| ":#{v}" }.join(", ")
522
+ raise ArgumentError,
523
+ "Unknown #{axis} :#{current} for #{self.class.name}. Valid values: #{valid_list}"
524
+ end
525
+ end
526
+ end
527
+
528
+ def collect_matching_method_css
529
+ self.class._css_method_rules.filter_map do |rule|
530
+ method_name = rule[:method]
531
+ unless respond_to?(method_name, true)
532
+ raise NoMethodError,
533
+ "ViewComponentCssDsl method rule references undefined method `#{method_name}` in #{self.class.name}"
534
+ end
535
+ result = send(method_name)
536
+ rule[:styles] if result
537
+ end.join(" ")
538
+ end
539
+
540
+ def collect_proc_css
541
+ self.class._css_proc_rules.filter_map do |proc|
542
+ instance_exec(&proc)
543
+ end.join(" ")
544
+ end
545
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: view_component_css_dsl
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeff Lange
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: view_component
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '4.0'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '4.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: standard
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.0'
75
+ description: |
76
+ A small concern you mix into your base ViewComponent class to declare
77
+ base styles, variants, and conditional CSS classes declaratively.
78
+ Smart-merges Tailwind utility classes (spacing, modifier prefixes,
79
+ arbitrary values) so callers can safely override per-instance styles.
80
+ email:
81
+ - jeff.lange@sofwarellc.com
82
+ executables: []
83
+ extensions: []
84
+ extra_rdoc_files: []
85
+ files:
86
+ - CHANGELOG.md
87
+ - LICENSE.txt
88
+ - README.md
89
+ - lib/view_component_css_dsl.rb
90
+ - lib/view_component_css_dsl/version.rb
91
+ homepage: https://github.com/SOFware/view_component_css_dsl
92
+ licenses:
93
+ - MIT
94
+ metadata:
95
+ source_code_uri: https://github.com/SOFware/view_component_css_dsl
96
+ changelog_uri: https://github.com/SOFware/view_component_css_dsl/blob/main/CHANGELOG.md
97
+ rubygems_mfa_required: 'true'
98
+ post_install_message:
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '3.2'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.0.3.1
114
+ signing_key:
115
+ specification_version: 4
116
+ summary: Declarative CSS class DSL for ViewComponent + Tailwind
117
+ test_files: []