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 +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +332 -0
- data/lib/view_component_css_dsl/version.rb +5 -0
- data/lib/view_component_css_dsl.rb +545 -0
- metadata +117 -0
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
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
|
+
[](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,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: []
|