From de3e0a04f5e595d70541347ded89a3f5134b1109 Mon Sep 17 00:00:00 2001 From: "Ben Sheldon [he/him]" Date: Mon, 12 Oct 2026 06:30:11 -0700 Subject: [PATCH] Add counter: and iteration: options to with_collection_parameter --- docs/CHANGELOG.md | 4 +++ docs/api.md | 10 +++++- docs/guide/collections.md | 18 ++++++++-- lib/view_component/base.rb | 26 +++++++++++--- test/sandbox/test/collection_test.rb | 54 ++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7e55e92d5..e789a4852 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* Add `counter:` and `iteration:` options to `with_collection_parameter`, and make its parameter name optional, to explicitly declare whether a component receives the `_counter` and `_iteration` arguments when rendered as a collection. + + *Ben Sheldon* + * Update development and documentation dependencies, baseline existing Audition findings, and accommodate updated lint rules. *Joel Hawksley* diff --git a/docs/api.md b/docs/api.md index b615b2d54..c5524d403 100644 --- a/docs/api.md +++ b/docs/api.md @@ -58,7 +58,7 @@ Render a component for each element in a collection ([documentation](/guide/coll render(ProductsComponent.with_collection(@products, foo: :bar)) ``` -### `.with_collection_parameter(parameter)` +### `.with_collection_parameter(parameter = nil, counter: nil, iteration: nil)` Set the parameter name used when rendering elements of a collection ([documentation](/guide/collections)): @@ -66,6 +66,14 @@ Set the parameter name used when rendering elements of a collection ([documentat with_collection_parameter :item ``` +By default, the `_counter` and `_iteration` arguments are passed when the +initializer's signature accepts them. Set `counter:` or `iteration:` to +explicitly always (`true`) or never (`false`) pass them: + +```ruby +with_collection_parameter :item, counter: true, iteration: true +``` + ## Instance methods ### `#around_render` → [void] diff --git a/docs/guide/collections.md b/docs/guide/collections.md index 91ad90e3c..2d314f878 100644 --- a/docs/guide/collections.md +++ b/docs/guide/collections.md @@ -70,10 +70,12 @@ end Since 2.5.0 {: .label } -ViewComponent defines a counter variable matching the parameter name above, followed by `_counter`. To access the variable, add it to `initialize` as an argument: +ViewComponent defines a counter variable matching the parameter name above, followed by `_counter`. To access the variable, declare it with `counter: true` and add it to `initialize` as an argument: ```ruby class ProductComponent < ViewComponent::Base + with_collection_parameter counter: true + erb_template <<-ERB
  • <%= @counter %> <%= @product.name %> @@ -94,10 +96,12 @@ Since 2.33.0 ViewComponent defines an iteration variable matching the parameter name above, followed by `_iteration`. This gives contextual information about the iteration to components within the collection (`#size`, `#index`, `#first?`, and `#last?`). -To access the variable, add it to `initialize` as an argument: +To access the variable, declare it with `iteration: true` and add it to `initialize` as an argument: ```ruby class ProductComponent < ViewComponent::Base + with_collection_parameter iteration: true + erb_template <<-ERB
  • "> <%= @product.name %> @@ -111,6 +115,16 @@ class ProductComponent < ViewComponent::Base end ``` +## Declaring the counter and iteration + +`counter:` and `iteration:` can be combined with a custom parameter name, and are inherited by subclasses: + +```ruby +with_collection_parameter :item, counter: true, iteration: true +``` + +Set either to `false` to never pass it. If neither is declared, ViewComponent passes `_counter` and `_iteration` when it finds them in the component's `initialize` signature. + ## Spacer components Since 3.20.0 diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index b525c7f5a..1eca9f550 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -70,6 +70,8 @@ def config # Config option that strips trailing whitespace in templates before compiling them. class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false + class_attribute :__vc_collection_counter, instance_accessor: false, instance_predicate: false, default: nil + class_attribute :__vc_collection_iteration, instance_accessor: false, instance_predicate: false, default: nil attr_accessor :__vc_original_view_context attr_reader :current_template @@ -713,9 +715,21 @@ def __vc_compiler # with_collection_parameter :item # ``` # + # By default, the `_counter` and `_iteration` arguments are passed when the + # initializer's signature accepts them. Set `counter:` or `iteration:` to + # explicitly always (`true`) or never (`false`) pass them: + # + # ```ruby + # with_collection_parameter :item, counter: true, iteration: true + # ``` + # # @param parameter [Symbol] The parameter name used when rendering elements of a collection. - def with_collection_parameter(parameter) - @__vc_provided_collection_parameter = parameter + # @param counter [Boolean] Whether to pass the `_counter` argument. + # @param iteration [Boolean] Whether to pass the `_iteration` argument. + def with_collection_parameter(parameter = nil, counter: nil, iteration: nil) + @__vc_provided_collection_parameter = parameter if parameter + self.__vc_collection_counter = counter unless counter.nil? + self.__vc_collection_iteration = iteration unless iteration.nil? @__vc_initialize_parameters = nil end @@ -776,7 +790,7 @@ def __vc_collection_counter_parameter # @private def __vc_counter_argument_present? - __vc_initialize_parameter_names.include?(__vc_collection_counter_parameter) + __vc_collection_argument_present?(__vc_collection_counter, __vc_collection_counter_parameter) end # @private @@ -786,11 +800,15 @@ def __vc_collection_iteration_parameter # @private def __vc_iteration_argument_present? - __vc_initialize_parameter_names.include?(__vc_collection_iteration_parameter) + __vc_collection_argument_present?(__vc_collection_iteration, __vc_collection_iteration_parameter) end private + def __vc_collection_argument_present?(provided, parameter) + provided.nil? ? __vc_initialize_parameter_names.include?(parameter) : provided + end + def __vc_splatted_keyword_argument_present? __vc_initialize_parameters.flatten.include?(:keyrest) end diff --git a/test/sandbox/test/collection_test.rb b/test/sandbox/test/collection_test.rb index 2a73edd88..11259b6de 100644 --- a/test/sandbox/test/collection_test.rb +++ b/test/sandbox/test/collection_test.rb @@ -56,5 +56,59 @@ def test_collection_escapes_html_unsafe_around_render_output assert_includes @rendered_content, "<script>alert(1)</script>" assert_predicate @rendered_content, :html_safe? end + + class ExplicitCountersComponent < ViewComponent::Base + with_collection_parameter :item, counter: true, iteration: true + + def initialize(**) + assign(**) + end + + def call + "

    #{@item.name} #{@counter.inspect} #{@iteration&.last?.inspect}

    ".html_safe + end + + private + + def assign(item:, **options) + @item = item + @counter = options[:item_counter] + @iteration = options[:item_iteration] + end + end + + class InheritedExplicitCountersComponent < ExplicitCountersComponent + end + + class DisabledCountersComponent < ExplicitCountersComponent + with_collection_parameter counter: false, iteration: false + + def initialize(item:, item_counter: nil, item_iteration: nil) + @item = item + @counter = item_counter + @iteration = item_iteration + end + end + + def test_with_collection_parameter_passes_counter_and_iteration + render_inline(ExplicitCountersComponent.with_collection(@products)) + + assert_selector("p", text: "Radio clock 0 false") + assert_selector("p", text: "Mints 1 true") + end + + def test_with_collection_parameter_counter_and_iteration_are_inherited + render_inline(InheritedExplicitCountersComponent.with_collection(@products)) + + assert_selector("p", text: "Radio clock 0 false") + assert_selector("p", text: "Mints 1 true") + end + + def test_with_collection_parameter_counter_and_iteration_false_skips_them + render_inline(DisabledCountersComponent.with_collection(@products)) + + assert_selector("p", text: "Radio clock nil nil") + assert_selector("p", text: "Mints nil nil") + end end end