Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions docs/guide/slots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
<div class="greeting">
<%= message("Hello", "World") %>
</div>
```

```erb
<%# index.html.erb %>
<%= render GreetingComponent.new do |component| %>
<% component.with_message do |greeting, name| %>
<%= greeting %>, <%= name %>!
<% end %>
<% end %>
```

Returning:

```erb
<div class="greeting">
Hello, World!
</div>
```

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 %>
<ul>
<% items.each_with_index do |item, index| %>
<li><%= item.call(index) %></li>
<% end %>
</ul>
```

```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
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
```

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
Expand Down
40 changes: 34 additions & 6 deletions lib/view_component/slot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,43 @@ 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)

if defined?(@__vc_content_block) && defined?(@__vc_content_set_by_with_content)
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
Expand All @@ -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
Expand Down
18 changes: 14 additions & 4 deletions lib/view_component/slotable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions test/sandbox/app/components/slot_args_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<div class="slot-args">
<% if greeting? %>
<div class="greeting"><%= greeting("Hello", "World") %></div>
<% end %>
<% items.each_with_index do |item, index| %>
<div class="item"><%= item.call(index, "extra") %></div>
<% end %>
<% if kwargs_slot? %>
<div class="kwargs"><%= kwargs_slot(name: "test", value: 42) %></div>
<% end %>
<% if mixed_slot? %>
<div class="mixed"><%= mixed_slot("positional", key: "keyword") %></div>
<% end %>
</div>
8 changes: 8 additions & 0 deletions test/sandbox/app/components/slot_args_component.rb
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions test/sandbox/test/slotable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading