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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49d5390931f1620f410e179868a1ab08cfcf4a0fa05164c8b4e7e7ba860fa1b2
4
- data.tar.gz: 1f51a210129bd2ee1cfd414697e923c60e5be7c0c8870788e193482aacfda6ee
3
+ metadata.gz: eba29c79b834ffc0651fc3ff6b244e860de04218b6b29ca31df90634211fc6a6
4
+ data.tar.gz: fb7183fa91dc0bb773d61dc0953df0cf17fed601d39609cc1b2e2d44da725f51
5
5
  SHA512:
6
- metadata.gz: '0086317b10dbb3b32225d9b7eb9b1695d176c40ea6adad370ca6259366edbb1952038170fa4499ecaa2ad5edfff2af7c2459214140bc2be8dd61b6441fac7e05'
7
- data.tar.gz: f2286966bae1f1c1b12b2f10e09f58d983281cbfdf0d96c863d98f28b3b7feefae85229d17a440f19d156a51ff7d0dbd2d525660186cb310d6273f15bce4d112
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ViewComponentCssDsl
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -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
- result = @html_attrs.except(:aria, :class, :data)
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
- incoming_data = @html_attrs.fetch(:data, {})
466
- incoming_data.each_with_object(data_attrs) do |(key, value), final_data|
467
- final_value = if key.in?(DATA_MERGE_KEYS)
468
- [data_attrs[key], value].compact.join(" ")
469
- else
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
- # Using merge allows for default value #aria_attrs, but also for dev to override
483
- # that value per-instance as needed
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component_css_dsl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Lange