view_component_css_dsl 0.1.1 → 0.1.2
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 +6 -6
- data/README.md +118 -0
- data/lib/view_component_css_dsl/version.rb +1 -1
- data/lib/view_component_css_dsl.rb +131 -14
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eba29c79b834ffc0651fc3ff6b244e860de04218b6b29ca31df90634211fc6a6
|
|
4
|
+
data.tar.gz: fb7183fa91dc0bb773d61dc0953df0cf17fed601d39609cc1b2e2d44da725f51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d28bfc76e89df1e4899fa396f0d45be296bbef32bed288d4893f908ad9038f28bc3f3e444675acf1408e01859f2cd6b007cb4fc346bad8389377765768952ff9
|
|
7
|
+
data.tar.gz: 9353657fc812e95881ec400152852886d05f2292f978d590dc0475abb64cb87dfe61ba38c1cde972d8c2c6f22eb74d0e88bd2f2c632455d1caa5d368f6d230e4
|
data/CHANGELOG.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
## [0.1.2] - 2026-05-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- data, aria, and attribute DSL declarators for first-class HTML attribute declarations (cf37050)
|
|
10
|
+
|
|
5
11
|
## [0.1.1] - 2026-05-15
|
|
6
12
|
|
|
7
13
|
### Changed
|
|
8
14
|
|
|
9
15
|
- `view_component` dependency pinned to `~> 4.0` (was `>= 4.0`) — RubyGems-recommended SemVer-aware constraint
|
|
10
16
|
- Minimum Ruby version raised to 3.2 (was 3.1), matching the floor for `view_component >= 4.0`
|
|
11
|
-
|
|
12
|
-
## [0.1.0] - 2026-05-15
|
|
13
|
-
|
|
14
|
-
### Added
|
|
15
|
-
|
|
16
|
-
- Initial release. Extracted from SOFware/forge.
|
data/README.md
CHANGED
|
@@ -237,6 +237,124 @@ css -> { "pl-#{@indent * 4}" }
|
|
|
237
237
|
|
|
238
238
|
Procs returning `nil` are dropped. Procs participate in smart_merge.
|
|
239
239
|
|
|
240
|
+
## Declaring `data`, `aria`, and HTML attributes
|
|
241
|
+
|
|
242
|
+
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.
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
class ButtonComponent < ApplicationComponent
|
|
246
|
+
css "rounded px-4 py-2 bg-blue-500 text-white"
|
|
247
|
+
|
|
248
|
+
data variant: :variant, size: :size
|
|
249
|
+
aria label: "Submit"
|
|
250
|
+
attribute target: "_blank"
|
|
251
|
+
|
|
252
|
+
def initialize(variant: :primary, size: :default)
|
|
253
|
+
@variant = variant
|
|
254
|
+
@size = size
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
attr_reader :variant, :size
|
|
258
|
+
end
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
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.
|
|
262
|
+
|
|
263
|
+
### Static values
|
|
264
|
+
|
|
265
|
+
Always emitted. Stringified at render time (booleans, integers, etc. all become strings; `nil` drops the attribute).
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
data controller: "modal"
|
|
269
|
+
aria label: "Close dialog"
|
|
270
|
+
attribute target: "_blank"
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Symbol values — call an instance method
|
|
274
|
+
|
|
275
|
+
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.
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
data variant: :variant # calls #variant; renders as data-variant="<value>"
|
|
279
|
+
attribute tabindex: :tab_index
|
|
280
|
+
|
|
281
|
+
def tab_index
|
|
282
|
+
focusable? ? 0 : -1
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
If the method returns `nil`, the attribute is dropped.
|
|
287
|
+
|
|
288
|
+
### Proc values — inline computation
|
|
289
|
+
|
|
290
|
+
For one-off computed values that don't deserve a named method:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
aria label: -> { "#{@variant} Notification".titleize }
|
|
294
|
+
data turbo_permanent: -> { true if turbo_permanent? }
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Procs are `instance_exec`'d at render time, so they see instance state. Procs returning `nil` drop the attribute.
|
|
298
|
+
|
|
299
|
+
### Conditional inclusion via positional predicate
|
|
300
|
+
|
|
301
|
+
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.
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
data :auto_dismiss?, timeout: "5000", animation: "fade"
|
|
305
|
+
aria :loud?, label: "Important"
|
|
306
|
+
attribute -> { @disabled }, disabled: true
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
The Symbol form calls the named instance method; the Proc form is `instance_exec`'d.
|
|
310
|
+
|
|
311
|
+
### Multiple attributes per declaration
|
|
312
|
+
|
|
313
|
+
Each declaration accepts a hash of attributes. All share the same predicate (if any).
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
data controller: "modal",
|
|
317
|
+
modal_dismiss_action: "click->modal#dismiss"
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Multiple declarations: how they compose
|
|
321
|
+
|
|
322
|
+
For `aria` and `attribute`, repeated keys across declarations *replace* — the last declaration wins.
|
|
323
|
+
|
|
324
|
+
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.
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
data :modal?, controller: "modal"
|
|
328
|
+
data :trap_focus?, controller: "trap-focus"
|
|
329
|
+
# Both predicates true → data-controller="modal trap-focus"
|
|
330
|
+
# Only :modal? true → data-controller="modal"
|
|
331
|
+
# Neither true → data-controller attribute is omitted
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Caller customization
|
|
335
|
+
|
|
336
|
+
Whatever a caller passes for `class:`, `data:`, `aria:`, or any HTML attribute layers on top of your declarations using the same rules:
|
|
337
|
+
|
|
338
|
+
- `class:` smart-merged (see Smart merge behavior below)
|
|
339
|
+
- `data:` controller/action keys concatenate, others replace
|
|
340
|
+
- `aria:` and other attrs: caller wins
|
|
341
|
+
|
|
342
|
+
### Inheritance
|
|
343
|
+
|
|
344
|
+
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.
|
|
345
|
+
|
|
346
|
+
```ruby
|
|
347
|
+
class CardComponent < ApplicationComponent
|
|
348
|
+
data controller: "card"
|
|
349
|
+
data role: "region"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
class HighlightedCardComponent < CardComponent
|
|
353
|
+
data controller: "highlighted" # appends → data-controller="card highlighted"
|
|
354
|
+
data role: "alert" # replaces → data-role="alert"
|
|
355
|
+
end
|
|
356
|
+
```
|
|
357
|
+
|
|
240
358
|
## Caller customization
|
|
241
359
|
|
|
242
360
|
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.
|
|
@@ -133,6 +133,10 @@ module ViewComponentCssDsl
|
|
|
133
133
|
class_attribute :_css_cache, instance_writer: false, default: nil
|
|
134
134
|
# Memoization cache for smart_merge results (axis + method + proc combinations)
|
|
135
135
|
class_attribute :_css_merge_cache, instance_writer: false, default: nil
|
|
136
|
+
# Rules for the data/aria/attribute DSLs. Each entry: {predicate:, attrs:}
|
|
137
|
+
class_attribute :_data_rules, instance_writer: false, default: []
|
|
138
|
+
class_attribute :_aria_rules, instance_writer: false, default: []
|
|
139
|
+
class_attribute :_attribute_rules, instance_writer: false, default: []
|
|
136
140
|
end
|
|
137
141
|
|
|
138
142
|
class_methods do
|
|
@@ -198,6 +202,60 @@ module ViewComponentCssDsl
|
|
|
198
202
|
end
|
|
199
203
|
end
|
|
200
204
|
|
|
205
|
+
# Declares one or more `data-*` attributes on the top-level element.
|
|
206
|
+
#
|
|
207
|
+
# data controller: "modal" # static
|
|
208
|
+
# data variant: :variant # Symbol value -> calls instance method
|
|
209
|
+
# data foo: -> { computed_value } # Proc value -> instance_exec'd
|
|
210
|
+
# data :auto_dismiss?, timeout_value: "5000" # Symbol predicate
|
|
211
|
+
# data -> { complex_check? }, foo: "bar" # Proc predicate
|
|
212
|
+
def data(*args, **kwargs)
|
|
213
|
+
self._data_rules = _data_rules.dup
|
|
214
|
+
_data_rules << _build_attr_rule(:data, *args, **kwargs)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Declares one or more `aria-*` attributes on the top-level element.
|
|
218
|
+
# See `data` for the full pattern.
|
|
219
|
+
def aria(*args, **kwargs)
|
|
220
|
+
self._aria_rules = _aria_rules.dup
|
|
221
|
+
_aria_rules << _build_attr_rule(:aria, *args, **kwargs)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Declares one or more top-level HTML attributes (`target`, `role`, `tabindex`,
|
|
225
|
+
# etc.) on the rendered element. See `data` for the full pattern.
|
|
226
|
+
def attribute(*args, **kwargs)
|
|
227
|
+
self._attribute_rules = _attribute_rules.dup
|
|
228
|
+
_attribute_rules << _build_attr_rule(:attribute, *args, **kwargs)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
# Builds the rule entry used by `data`, `aria`, and `attribute`.
|
|
234
|
+
# Returns {predicate:, attrs:} where predicate is nil, Symbol, or Proc and
|
|
235
|
+
# attrs is the hash of attribute key -> (literal | Symbol | Proc).
|
|
236
|
+
def _build_attr_rule(namespace, *args, **kwargs)
|
|
237
|
+
if args.size > 1
|
|
238
|
+
raise ArgumentError,
|
|
239
|
+
"#{namespace} accepts at most one positional arg (a predicate Symbol or Proc)"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
predicate = args.first
|
|
243
|
+
if predicate && !predicate.is_a?(Symbol) && !predicate.is_a?(Proc)
|
|
244
|
+
raise ArgumentError,
|
|
245
|
+
"#{namespace} positional predicate must be a Symbol or Proc " \
|
|
246
|
+
"(got #{predicate.class})"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if kwargs.empty?
|
|
250
|
+
raise ArgumentError,
|
|
251
|
+
"#{namespace} requires at least one attribute kwarg"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
{predicate:, attrs: kwargs}
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
public
|
|
258
|
+
|
|
201
259
|
# Override `new` to auto-extract HTML attributes from kwargs into @html_attrs,
|
|
202
260
|
# so components don't need to declare **html_attrs in their initialize signature.
|
|
203
261
|
# Anything in HTML_ATTR_KEYS that wasn't declared as a kwarg is captured.
|
|
@@ -401,7 +459,10 @@ module ViewComponentCssDsl
|
|
|
401
459
|
def html_attrs
|
|
402
460
|
return {} unless @html_attrs
|
|
403
461
|
|
|
404
|
-
|
|
462
|
+
# Start with DSL-declared top-level attrs; caller's html_attrs layer on top
|
|
463
|
+
# (caller wins on collision, mirroring the css behavior).
|
|
464
|
+
dsl_attrs = resolved_attr_rules(:attribute)
|
|
465
|
+
result = dsl_attrs.merge(@html_attrs.except(:aria, :class, :data))
|
|
405
466
|
|
|
406
467
|
# Only include aria/data if they have content, otherwise they'd override
|
|
407
468
|
# inline attrs in templates like: tag.div data: {foo: "bar"}, **html_attrs
|
|
@@ -462,16 +523,11 @@ module ViewComponentCssDsl
|
|
|
462
523
|
# - In contrast, data-label from the caller overwrites the default
|
|
463
524
|
#
|
|
464
525
|
def final_data_attrs
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
value
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
final_data[key] = final_value
|
|
474
|
-
end
|
|
526
|
+
# Merge in order: DSL declarations -> method override -> caller's :data.
|
|
527
|
+
# Each layer uses DATA_MERGE_KEYS semantics (controller/action concatenate,
|
|
528
|
+
# everything else replaces).
|
|
529
|
+
combined = merge_data_layer(resolved_attr_rules(:data), data_attrs)
|
|
530
|
+
merge_data_layer(combined, @html_attrs.fetch(:data, {}))
|
|
475
531
|
end
|
|
476
532
|
|
|
477
533
|
# Overwrite in subclass to define default aria-attrs
|
|
@@ -479,14 +535,75 @@ module ViewComponentCssDsl
|
|
|
479
535
|
{}
|
|
480
536
|
end
|
|
481
537
|
|
|
482
|
-
#
|
|
483
|
-
#
|
|
538
|
+
# Merge in order: DSL declarations -> method override -> caller's :aria.
|
|
539
|
+
# Hash#merge throughout — caller wins on collision (no additive semantics).
|
|
484
540
|
def final_aria_attrs
|
|
485
|
-
aria_attrs.merge(@html_attrs.fetch(:aria, {}))
|
|
541
|
+
resolved_attr_rules(:aria).merge(aria_attrs).merge(@html_attrs.fetch(:aria, {}))
|
|
486
542
|
end
|
|
487
543
|
|
|
488
544
|
private
|
|
489
545
|
|
|
546
|
+
# Walks the DSL rules for the given namespace (:data, :aria, :attribute),
|
|
547
|
+
# evaluates each predicate, resolves each value, and returns a hash. For
|
|
548
|
+
# the :data namespace, DATA_MERGE_KEYS keys accumulate space-separated when
|
|
549
|
+
# the same key appears in multiple included rules.
|
|
550
|
+
def resolved_attr_rules(namespace)
|
|
551
|
+
rules = case namespace
|
|
552
|
+
when :data then self.class._data_rules
|
|
553
|
+
when :aria then self.class._aria_rules
|
|
554
|
+
when :attribute then self.class._attribute_rules
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
rules.each_with_object({}) do |rule, result|
|
|
558
|
+
next unless predicate_met?(rule[:predicate])
|
|
559
|
+
|
|
560
|
+
rule[:attrs].each do |key, value|
|
|
561
|
+
resolved = resolve_attr_value(value)
|
|
562
|
+
next if resolved.nil?
|
|
563
|
+
|
|
564
|
+
result[key] = if namespace == :data && DATA_MERGE_KEYS.include?(key) && result.key?(key)
|
|
565
|
+
"#{result[key]} #{resolved}"
|
|
566
|
+
else
|
|
567
|
+
resolved
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Layers `addition` on top of `base` using DATA_MERGE_KEYS semantics: keys
|
|
574
|
+
# in DATA_MERGE_KEYS concatenate space-separated, every other key replaces.
|
|
575
|
+
def merge_data_layer(base, addition)
|
|
576
|
+
addition.each_with_object(base.dup) do |(key, value), result|
|
|
577
|
+
result[key] = if DATA_MERGE_KEYS.include?(key)
|
|
578
|
+
[result[key], value].compact.join(" ")
|
|
579
|
+
else
|
|
580
|
+
value
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Resolves a predicate. nil predicate -> always true. Symbol -> call instance
|
|
586
|
+
# method. Proc -> instance_exec.
|
|
587
|
+
def predicate_met?(predicate)
|
|
588
|
+
case predicate
|
|
589
|
+
when nil then true
|
|
590
|
+
when Symbol then send(predicate)
|
|
591
|
+
when Proc then instance_exec(&predicate)
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Resolves a value for a DSL-declared attribute. Symbols become method calls
|
|
596
|
+
# on the instance; Procs are instance_exec'd; literals pass through. Result
|
|
597
|
+
# is stringified unless nil (which drops the attribute).
|
|
598
|
+
def resolve_attr_value(value)
|
|
599
|
+
resolved = case value
|
|
600
|
+
when Symbol then send(value)
|
|
601
|
+
when Proc then instance_exec(&value)
|
|
602
|
+
else value
|
|
603
|
+
end
|
|
604
|
+
resolved&.to_s
|
|
605
|
+
end
|
|
606
|
+
|
|
490
607
|
def build_classes
|
|
491
608
|
validate_axes!
|
|
492
609
|
|