From 0f35dbdf0657a5ac0f4a69876500102aa93f47e3 Mon Sep 17 00:00:00 2001 From: Stanislav Lashmanov Date: Sat, 31 Jan 2026 09:41:39 +0400 Subject: [PATCH] Add support for slot arguments --- docs/CHANGELOG.md | 4 + docs/guide/slots.md | 86 +++++++++++++++++++ lib/view_component/slot.rb | 40 +++++++-- lib/view_component/slotable.rb | 18 +++- .../components/slot_args_component.html.erb | 14 +++ .../app/components/slot_args_component.rb | 8 ++ test/sandbox/test/slotable_test.rb | 54 ++++++++++++ 7 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 test/sandbox/app/components/slot_args_component.html.erb create mode 100644 test/sandbox/app/components/slot_args_component.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d15ec34f9..ccca79ad1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,10 @@ nav_order: 6 *Joel Hawksley* +* Support slot arguments. + + *Stanislav Lashmanov* + ## 4.2.0 * Fix translation scope resolution in deeply nested component blocks (3+ levels). Translations called inside deeply nested slot blocks using `renders_many`/`renders_one` were incorrectly resolving to an intermediate component's scope instead of the partial's scope where the block was defined. The fix captures the virtual path at block definition time and restores it during block execution, ensuring translations always resolve relative to where the block was created regardless of nesting depth. diff --git a/docs/guide/slots.md b/docs/guide/slots.md index d4225ab09..2888d4453 100644 --- a/docs/guide/slots.md +++ b/docs/guide/slots.md @@ -219,6 +219,92 @@ end _Note: While a lambda is called when the `with_*` method is called, a returned component isn't rendered until first use._ +## Passing arguments to slots + +Slots can receive arguments during rendering. This is useful when the component template has data that the slot content needs access to. + +```ruby +# greeting_component.rb +class GreetingComponent < ViewComponent::Base + renders_one :message +end +``` + +```erb +<%# greeting_component.html.erb %> +
+ <%= message("Hello", "World") %> +
+``` + +```erb +<%# index.html.erb %> +<%= render GreetingComponent.new do |component| %> + <% component.with_message do |greeting, name| %> + <%= greeting %>, <%= name %>! + <% end %> +<% end %> +``` + +Returning: + +```erb +
+ Hello, World! +
+``` + +For `renders_many` slots, call `.call()` on each slot item: + +```ruby +# list_component.rb +class ListComponent < ViewComponent::Base + renders_many :items +end +``` + +```erb +<%# list_component.html.erb %> + +``` + +```erb +<%# index.html.erb %> +<%= render ListComponent.new do |component| %> + <% component.with_item do |index| %> + Item <%= index + 1 %> + <% end %> + <% component.with_item do |index| %> + Item <%= index + 1 %> + <% end %> +<% end %> +``` + +Returning: + +```erb + +``` + +Keyword arguments are also supported: + +```erb +<%# In the component template %> +<%= my_slot(name: "Alice", role: "Admin") %> + +<%# When setting the slot %> +<% component.with_my_slot do |name:, role:| %> + <%= name %> (<%= role %>) +<% end %> +``` + ## Rendering collections Since 2.23.0 diff --git a/lib/view_component/slot.rb b/lib/view_component/slot.rb index e70a421e2..9198ed2b3 100644 --- a/lib/view_component/slot.rb +++ b/lib/view_component/slot.rb @@ -43,7 +43,27 @@ def with_content(args) # If there is no slot renderable, we evaluate the block passed to # the slot and return it. def to_s - return @content if defined?(@content) + call + end + + # Renders the slot content, optionally passing arguments to the content block. + # + # This allows slot content blocks to receive arguments from the component + # that defines the slot, enabling patterns like: + # + # # In the component template: + # <%= my_slot.call(arg1, arg2) %> + # + # # When setting the slot: + # component.with_my_slot do |arg1, arg2| + # "Content using #{arg1} and #{arg2}" + # end + # + # @param args [Array] Arguments to pass to the content block + # @param kwargs [Hash] Keyword arguments to pass to the content block + # @return [String] The rendered slot content + def call(*args, **kwargs) + return @content if defined?(@content) && args.empty? && kwargs.empty? view_context = @parent.send(:view_context) @@ -51,15 +71,15 @@ def to_s raise DuplicateSlotContentError.new(self.class.name) end - @content = + content = if __vc_component_instance? @__vc_component_instance.__vc_original_view_context = @parent.__vc_original_view_context if defined?(@__vc_content_block) # render_in is faster than `parent.render` - @__vc_component_instance.render_in(view_context) do |*args| + @__vc_component_instance.render_in(view_context) do |*component_args| @parent.with_captured_virtual_path(@__vc_content_block_virtual_path) do - @__vc_content_block.call(*args) + @__vc_content_block.call(*component_args) end end else @@ -69,13 +89,21 @@ def to_s @__vc_content elsif defined?(@__vc_content_block) @parent.with_captured_virtual_path(@__vc_content_block_virtual_path) do - view_context.capture(&@__vc_content_block) + view_context.capture do + if kwargs.empty? + @__vc_content_block.call(*args) + else + @__vc_content_block.call(*args, **kwargs) + end + end end elsif defined?(@__vc_content_set_by_with_content) @__vc_content_set_by_with_content end - @content = @content.to_s + content = content.to_s + @content = content if args.empty? && kwargs.empty? + content end # Allow access to public component methods via the wrapper diff --git a/lib/view_component/slotable.rb b/lib/view_component/slotable.rb index f1625080d..cfcd7e9c0 100644 --- a/lib/view_component/slotable.rb +++ b/lib/view_component/slotable.rb @@ -86,8 +86,13 @@ def renders_one(slot_name, callable = nil) __vc_set_slot(slot_name, nil, *args, **kwargs, &block) end - self::GeneratedSlotMethods.define_method slot_name do - __vc_get_slot(slot_name) + self::GeneratedSlotMethods.define_method slot_name do |*args, **kwargs, &block| + slot = __vc_get_slot(slot_name) + if (args.any? || kwargs.any? || block) && slot + slot.call(*args, **kwargs, &block) + else + slot + end end self::GeneratedSlotMethods.define_method :"#{slot_name}?" do @@ -229,8 +234,13 @@ def __vc_register_slot(slot_name, **kwargs) end def __vc_register_polymorphic_slot(slot_name, types, collection:) - self::GeneratedSlotMethods.define_method(slot_name) do - __vc_get_slot(slot_name) + self::GeneratedSlotMethods.define_method(slot_name) do |*args, **kwargs, &block| + slot = __vc_get_slot(slot_name) + if (args.any? || kwargs.any? || block) && slot && !slot.is_a?(Array) + slot.call(*args, **kwargs, &block) + else + slot + end end self::GeneratedSlotMethods.define_method(:"#{slot_name}?") do diff --git a/test/sandbox/app/components/slot_args_component.html.erb b/test/sandbox/app/components/slot_args_component.html.erb new file mode 100644 index 000000000..aa5f44475 --- /dev/null +++ b/test/sandbox/app/components/slot_args_component.html.erb @@ -0,0 +1,14 @@ +
+ <% if greeting? %> +
<%= greeting("Hello", "World") %>
+ <% end %> + <% items.each_with_index do |item, index| %> +
<%= item.call(index, "extra") %>
+ <% end %> + <% if kwargs_slot? %> +
<%= kwargs_slot(name: "test", value: 42) %>
+ <% end %> + <% if mixed_slot? %> +
<%= mixed_slot("positional", key: "keyword") %>
+ <% end %> +
diff --git a/test/sandbox/app/components/slot_args_component.rb b/test/sandbox/app/components/slot_args_component.rb new file mode 100644 index 000000000..0e25cc974 --- /dev/null +++ b/test/sandbox/app/components/slot_args_component.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class SlotArgsComponent < ViewComponent::Base + renders_one :greeting + renders_many :items + renders_one :kwargs_slot + renders_one :mixed_slot +end diff --git a/test/sandbox/test/slotable_test.rb b/test/sandbox/test/slotable_test.rb index d13be3692..04aba324f 100644 --- a/test/sandbox/test/slotable_test.rb +++ b/test/sandbox/test/slotable_test.rb @@ -831,4 +831,58 @@ def test_allows_marking_slot_as_last assert_selector(".breadcrumb.active") end + + def test_slot_with_args_renders_one + render_inline(SlotArgsComponent.new) do |component| + component.with_greeting do |arg1, arg2| + "#{arg1} #{arg2}!" + end + end + + assert_selector(".greeting", text: "Hello World!") + end + + def test_slot_with_args_renders_many + render_inline(SlotArgsComponent.new) do |component| + component.with_item do |index, extra| + "Item #{index}: #{extra}" + end + component.with_item do |index, extra| + "Item #{index}: #{extra}" + end + end + + assert_selector(".item", text: "Item 0: extra") + assert_selector(".item", text: "Item 1: extra") + end + + def test_slot_call_with_kwargs + render_inline(SlotArgsComponent.new) do |component| + component.with_kwargs_slot do |name:, value:| + "Name: #{name}, Value: #{value}" + end + end + + assert_selector(".kwargs", text: "Name: test, Value: 42") + end + + def test_slot_call_with_mixed_args_and_kwargs + render_inline(SlotArgsComponent.new) do |component| + component.with_mixed_slot do |pos, key:| + "#{pos} and #{key}" + end + end + + assert_selector(".mixed", text: "positional and keyword") + end + + def test_slot_call_method_directly + render_inline(SlotArgsComponent.new) do |component| + component.with_greeting do |arg1, arg2| + "Direct: #{arg1}-#{arg2}" + end + end + + assert_selector(".greeting", text: "Direct: Hello-World") + end end