view_component_css_dsl 0.1.0 → 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: 9fcd1b87aa8e32ecd595601ffbce3015c1cd7d1119a97967fda706f859499654
4
- data.tar.gz: 3b473073f9fca60d277fa0dd9700cd66b2da76a2f72222830f3a3d76bc294ce7
3
+ metadata.gz: eba29c79b834ffc0651fc3ff6b244e860de04218b6b29ca31df90634211fc6a6
4
+ data.tar.gz: fb7183fa91dc0bb773d61dc0953df0cf17fed601d39609cc1b2e2d44da725f51
5
5
  SHA512:
6
- metadata.gz: f4c117c6970000983d5acbdbb2d75254ac9875b5f0faeb64e80e78e88dd8644ee5dc3a01c24dfbac1257405cac55aa92c5975c1ac639cf28b8f2632e42002eec
7
- data.tar.gz: a57068184216a9c7f3d0554efa9658805749d35aa57f8d1592a1a7c3f26bb5a2b3bc0333d68e073cb4281e619cff6067c84db55118196ee227b32bdbddfbcc98
6
+ metadata.gz: d28bfc76e89df1e4899fa396f0d45be296bbef32bed288d4893f908ad9038f28bc3f3e444675acf1408e01859f2cd6b007cb4fc346bad8389377765768952ff9
7
+ data.tar.gz: 9353657fc812e95881ec400152852886d05f2292f978d590dc0475abb64cb87dfe61ba38c1cde972d8c2c6f22eb74d0e88bd2f2c632455d1caa5d368f6d230e4
data/CHANGELOG.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
4
3
 
5
- ## [0.1.0]
6
- - Initial release. Extracted from SOFware/forge.
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
+
11
+ ## [0.1.1] - 2026-05-15
12
+
13
+ ### Changed
14
+
15
+ - `view_component` dependency pinned to `~> 4.0` (was `>= 4.0`) — RubyGems-recommended SemVer-aware constraint
16
+ - Minimum Ruby version raised to 3.2 (was 3.1), matching the floor for `view_component >= 4.0`
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.
@@ -327,6 +445,8 @@ bundle exec rspec
327
445
  bundle exec standardrb
328
446
  ```
329
447
 
448
+ Releases are managed by [reissue](https://github.com/SOFware/reissue). When committing, add Keep-a-Changelog trailers (`Added:`, `Changed:`, `Fixed:`, etc.) and reissue will collate them into `CHANGELOG.md` at release time. To publish a new version, run the "Release gem to RubyGems.org" workflow from GitHub Actions.
449
+
330
450
  ## License
331
451
 
332
452
  MIT. See [LICENSE.txt](LICENSE.txt).
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ViewComponentCssDsl
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -12,11 +12,6 @@ require_relative "view_component_css_dsl/version"
12
12
  module ViewComponentCssDsl
13
13
  extend ActiveSupport::Concern
14
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
15
  HTML_ATTR_KEYS = Set[
21
16
  :alt, :aria, :autofocus,
22
17
  :class, :colspan, :contenteditable,
@@ -138,6 +133,10 @@ module ViewComponentCssDsl
138
133
  class_attribute :_css_cache, instance_writer: false, default: nil
139
134
  # Memoization cache for smart_merge results (axis + method + proc combinations)
140
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: []
141
140
  end
142
141
 
143
142
  class_methods do
@@ -203,23 +202,117 @@ module ViewComponentCssDsl
203
202
  end
204
203
  end
205
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
+
206
259
  # Override `new` to auto-extract HTML attributes from kwargs into @html_attrs,
207
260
  # so components don't need to declare **html_attrs in their initialize signature.
208
261
  # Anything in HTML_ATTR_KEYS that wasn't declared as a kwarg is captured.
262
+ #
263
+ # These can then be referenced in the component's template as `html_attrs`.
264
+ #
265
+ # Why?
266
+ # - DX: All components can accept arbitrary html attrs for free. Dev can pass
267
+ # arbitrary html attrs in at any caller and have it output at the component's
268
+ # top-level element.
269
+ # - Removes boilerplate of putting `**html_attrs` as the last argument in every
270
+ # single component's initialize signature, and then having to set the same as an
271
+ # ivar within the initialize body.
272
+ # - Requires following a pattern of declaring `**html_attrs` in the top level
273
+ # element of every single component's template.
274
+ # - Example template definition:
275
+ #
276
+ # # html_attrs contains :class, :data, etc defined either in the component
277
+ # # or passed in by the caller.
278
+ # <%= tag.my_component **html_attrs do %>
279
+ # <%= content %>
280
+ # <% end %>
281
+ #
282
+ # Example caller:
283
+ #
284
+ # render MyComponent.new(text: "Hello", class: "custom", data: {foo: "bar"})
285
+ #
286
+ # - :text goes to component's #initialize method, as normal
287
+ # - :class, :data, etc are captured and merged into @html_attrs automatically
288
+ # - The component's initialize method will look like:
289
+ #
290
+ # def initialize(text)
291
+ # @text = text
292
+ # end
293
+ #
294
+ # To opt out of this behavior, inherit from ViewComponent::Base directly instead of
295
+ # ApplicationComponent.
296
+ #
209
297
  def new(*args, **kwargs, &block)
210
298
  info = initialize_params_info
211
299
  html_attrs = {}
300
+
301
+ # Only extract HTML attrs if the component uses **html_attrs pattern.
302
+ # Components with other keyrest names (like **options) should receive all kwargs.
212
303
  if info[:uses_html_attrs_keyrest]
304
+ # Extract HTML attrs, but NOT if they're declared component params
213
305
  extractable = HTML_ATTR_KEYS.intersection(kwargs.keys) - info[:declared_kwargs]
214
306
  html_attrs = kwargs.extract!(*extractable)
215
307
  end
216
308
 
217
309
  instance = allocate
310
+ # Set @html_attrs BEFORE initialize so components can access it there
218
311
  instance.instance_variable_set(:@html_attrs, html_attrs)
219
312
  instance.send(:initialize, *args, **kwargs, &block)
220
313
 
221
- # Merge with any @html_attrs the component set inside initialize (older
222
- # components that still declare **html_attrs). Caller-provided values win.
314
+ # Merge with any @html_attrs set by initialize (old pattern components).
315
+ # Caller-provided values (html_attrs) take precedence over component defaults.
223
316
  existing = instance.instance_variable_get(:@html_attrs) || {}
224
317
  instance.instance_variable_set(:@html_attrs, existing.merge(html_attrs))
225
318
  instance
@@ -234,12 +327,17 @@ module ViewComponentCssDsl
234
327
  keyrest_name = nil
235
328
  instance_method(:initialize).parameters.each do |type, name|
236
329
  case type
237
- when :key, :keyreq then declared_kwargs << name
238
- when :keyrest then keyrest_name = name
330
+ when :key, :keyreq
331
+ declared_kwargs << name
332
+ when :keyrest
333
+ keyrest_name = name
239
334
  end
240
335
  end
241
- uses_html_attrs_keyrest = keyrest_name.nil? || keyrest_name == :html_attrs
242
- {declared_kwargs:, uses_html_attrs_keyrest:}
336
+
337
+ {
338
+ declared_kwargs:,
339
+ uses_html_attrs_keyrest: keyrest_name == :html_attrs || keyrest_name.nil?
340
+ }
243
341
  end
244
342
  end
245
343
 
@@ -361,7 +459,10 @@ module ViewComponentCssDsl
361
459
  def html_attrs
362
460
  return {} unless @html_attrs
363
461
 
364
- 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))
365
466
 
366
467
  # Only include aria/data if they have content, otherwise they'd override
367
468
  # inline attrs in templates like: tag.div data: {foo: "bar"}, **html_attrs
@@ -422,16 +523,11 @@ module ViewComponentCssDsl
422
523
  # - In contrast, data-label from the caller overwrites the default
423
524
  #
424
525
  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
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, {}))
435
531
  end
436
532
 
437
533
  # Overwrite in subclass to define default aria-attrs
@@ -439,14 +535,75 @@ module ViewComponentCssDsl
439
535
  {}
440
536
  end
441
537
 
442
- # Using merge allows for default value #aria_attrs, but also for dev to override
443
- # 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).
444
540
  def final_aria_attrs
445
- aria_attrs.merge(@html_attrs.fetch(:aria, {}))
541
+ resolved_attr_rules(:aria).merge(aria_attrs).merge(@html_attrs.fetch(:aria, {}))
446
542
  end
447
543
 
448
544
  private
449
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
+
450
607
  def build_classes
451
608
  validate_axes!
452
609
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component_css_dsl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Lange
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -44,6 +43,20 @@ dependencies:
44
43
  - - "~>"
45
44
  - !ruby/object:Gem::Version
46
45
  version: '4.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: reissue
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '0.4'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.4'
47
60
  - !ruby/object:Gem::Dependency
48
61
  name: rspec
49
62
  requirement: !ruby/object:Gem::Requirement
@@ -95,7 +108,6 @@ metadata:
95
108
  source_code_uri: https://github.com/SOFware/view_component_css_dsl
96
109
  changelog_uri: https://github.com/SOFware/view_component_css_dsl/blob/main/CHANGELOG.md
97
110
  rubygems_mfa_required: 'true'
98
- post_install_message:
99
111
  rdoc_options: []
100
112
  require_paths:
101
113
  - lib
@@ -110,8 +122,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
110
122
  - !ruby/object:Gem::Version
111
123
  version: '0'
112
124
  requirements: []
113
- rubygems_version: 3.0.3.1
114
- signing_key:
125
+ rubygems_version: 4.0.3
115
126
  specification_version: 4
116
127
  summary: Declarative CSS class DSL for ViewComponent + Tailwind
117
128
  test_files: []