diff --git a/CHANGELOG.md b/CHANGELOG.md
index f108f7c..4fcb257 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
## master
+- Add `#wrapped_in` helper to support wrappers that are conditional on multiple child components
+- Add `#placeholder` method to `ViewComponentContrib::WrapperComponent` that only renders a block if no conditional child components render
+
## 0.2.5 (2025-08-11)
- Fix compatibility with view_component v4.
diff --git a/README.md b/README.md
index 0c8124c..b82c5a2 100644
--- a/README.md
+++ b/README.md
@@ -765,6 +765,8 @@ You can add this to following line to your component generator (unless it's alre
## Wrapped components
+### Wrapping a single component
+
Sometimes we need to wrap a component into a custom HTML container (for positioning or whatever). By default, such wrapping doesn't play well with the `#render?` method because if we don't need a component, we don't need a wrapper.
To solve this problem, we introduce a special `ViewComponentContrib::WrapperComponent` class: it takes any component as the only argument and accepts a block during rendering to define a wrapping HTML. And it renders only if the _inner component_'s `#render?` method returns true.
@@ -797,7 +799,101 @@ And the template looks like this now:
<%- end -%>
```
-You can use the `#wrapped` method on any component inherited from `ApplicationViewComponent` to wrap it automatically:
+You can use the `#wrapped` method on any component inherited from `ApplicationViewComponent` to wrap it automatically.
+
+### Wrapping multiple components
+
+Sometimes a wrapper needs to wrap multiple components, and only render if at least one of the inner components renders.
+
+For example, consider a container element with a title and two components.
+
+```erb
+
+
Title
+
+ <%= render ExampleA::Component %>
+ <%= render ExampleB::Component %>
+
+
+```
+
+Both components have their own `#render?` method. If neither of the components render, then we don't want to render the container either.
+
+We introduce the `#wrapped_in` method that can be called on any component rendered within a `ViewComponentContrib::WrapperComponent`. Calling `#wrapped_in` on a child component will _register_ that component with the wrapper. The wrapper and its contents will only render if _at least one_ of the registered components' `#render?` methods returns true.
+
+```erb
+<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>
+
+
Title
+
+ <%= render ExampleA::Component.new.wrapped_in(wrapper) %>
+ <%= render ExampleB::Component.new.wrapped_in(wrapper) %>
+
+
+<%- end -%>
+```
+
+To use the method, include the helper in your base ViewComponent class:
+
+```ruby
+class ApplicationViewComponent < ViewComponent::Base
+ # adds #wrapped_in method
+ # NOTE: Already included into ViewComponentContrib::Base
+ include ViewComponentContrib::WrappedInHelper
+end
+```
+
+You can place any content inside a wrapper component. You can even nest wrapper components:
+
+```erb
+
+<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>
+
+
Examples
+
+
+ <%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |foo_wrapper| %>
+
+
Foo Examples
+
+ <%= render FooExampleA::Component.new.wrapped_in(foo_wrapper) %>
+ <%= render FooExampleB::Component.new.wrapped_in(foo_wrapper) %>
+
+
+ <%- end -%>
+
+
+ <%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |bar_wrapper| %>
+
+
Bar Examples
+
+ <%= render BarExampleA::Component.new.wrapped_in(bar_wrapper) %>
+ <%= render BarExampleB::Component.new.wrapped_in(bar_wrapper) %>
+
+
+ <%- end -%>
+
+<%- end -%>
+```
+
+You can also use the `#placeholder` method on a wrapper component to render a block _only_ if none of the registered components render.
+
+```erb
+<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>
+
+
Title
+
+ <%= render ExampleA::Component.new.wrapped_in(wrapper) %>
+ <%= render ExampleB::Component.new.wrapped_in(wrapper) %>
+
+
+
+
+ <%- wrapper.placeholder do -%>
+ Examples coming soon!
+ <%- end -%>
+<%- end -%>
+```
## License
diff --git a/lib/view_component_contrib.rb b/lib/view_component_contrib.rb
index af66467..2b20996 100644
--- a/lib/view_component_contrib.rb
+++ b/lib/view_component_contrib.rb
@@ -11,6 +11,7 @@ module ViewComponentContrib
autoload :TranslationHelper, "view_component_contrib/translation_helper"
autoload :WrapperComponent, "view_component_contrib/wrapper_component"
autoload :WrappedHelper, "view_component_contrib/wrapped_helper"
+ autoload :WrappedInHelper, "view_component_contrib/wrapped_in_helper"
autoload :StyleVariants, "view_component_contrib/style_variants"
autoload :Base, "view_component_contrib/base"
diff --git a/lib/view_component_contrib/base.rb b/lib/view_component_contrib/base.rb
index 312b982..9376757 100644
--- a/lib/view_component_contrib/base.rb
+++ b/lib/view_component_contrib/base.rb
@@ -5,5 +5,6 @@ module ViewComponentContrib
class Base < ViewComponent::Base
include TranslationHelper
include WrappedHelper
+ include WrappedInHelper
end
end
diff --git a/lib/view_component_contrib/wrapped_in_helper.rb b/lib/view_component_contrib/wrapped_in_helper.rb
new file mode 100644
index 0000000..ece2c1e
--- /dev/null
+++ b/lib/view_component_contrib/wrapped_in_helper.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module ViewComponentContrib
+ # Adds `#wrapped_in` method to register a component with a wrapper
+ module WrappedInHelper
+ def wrapped_in(wrapper)
+ raise ArgumentError, "Expected a ViewComponentContrib::WrapperComponent" unless wrapper.is_a?(WrapperComponent)
+
+ wrapper.register(self)
+ self
+ end
+ end
+end
diff --git a/lib/view_component_contrib/wrapper_component.rb b/lib/view_component_contrib/wrapper_component.rb
index 4b063eb..9e7eb02 100644
--- a/lib/view_component_contrib/wrapper_component.rb
+++ b/lib/view_component_contrib/wrapper_component.rb
@@ -1,34 +1,58 @@
# frozen_string_literal: true
module ViewComponentContrib
- # WrapperComponent allwows to wrap any component with a custom HTML code.
+ # WrapperComponent allows to wrap any component with a custom HTML code.
# The whole wrapper is only rendered when the child component.render? returns true.
# Thus, wrapper could be used to conditionally render the outer html for components without
# conditionals in templates.
class WrapperComponent < ViewComponent::Base
+ include WrappedInHelper
+
class DoubleRenderError < StandardError
def initialize(component)
super("A child component could only be rendered once within a wrapper: #{component}")
end
end
- attr_reader :component_instance
+ class ComponentPresentError < StandardError
+ def initialize
+ super("A wrapper component cannot register a component if it already has a component from the constructor")
+ end
+ end
- delegate :render?, to: :component_instance
+ attr_reader :component_instance, :registered_components, :placeholder_block
+
+ # We need to touch `content` before the `render?` method is called,
+ # otherwise children calling `.wrapped_in` won't be registered.
+ # This overrides the default lazy evaluation of `content` in ViewComponent,
+ # but it's necessary for the wrapper to work properly.
+ def before_render
+ content if component_instance.blank?
+ end
- def initialize(component)
+ def initialize(component = nil)
@component_instance = component
+ @registered_components = []
+ end
+
+ def render?
+ return component_instance.render? if component_instance.present?
+ return true if render_from_registered_components?
+
+ @placeholder_block.present?
end
# Simply return the contents of the block passed to #render_component.
# (Alias couldn't be used here 'cause ViewComponent check for the method presence when
# choosing between #call and a template.)
def call
+ return view_context.capture(&@placeholder_block) if render_placeholder?
+
content
end
# Returns rendered child component
- # The name component is chosen for convienent usage in templates,
+ # The name component is chosen for convenient usage in templates,
# so we can simply call `= wrapper.component` in the place where we're going
# to put the component
def component
@@ -36,5 +60,39 @@ def component
@rendered = component_instance.render_in(view_context).html_safe
end
+
+ # Register a component to be rendered within the wrapper.
+ # If no registered components render, the wrapper itself won't be rendered.
+ def register(component)
+ raise ComponentPresentError if component_instance.present?
+ raise ArgumentError, "Expected a ViewComponent" unless component.is_a?(ViewComponent::Base)
+
+ registered_components << component
+ end
+
+ # Register a placeholder block: `wrapper.placeholder { "Nothing to show" }`
+ # The block is only emitted when:
+ # - no component instance was supplied, AND
+ # - every registered component’s `render?` returns false
+ def placeholder(&block)
+ raise ArgumentError, "Block required" unless block
+
+ @placeholder_block = block
+ nil
+ end
+
+ private
+
+ # Memoize the result of registered components' render? calls as they
+ # could be expensive
+ def render_from_registered_components?
+ @render_from_registered_components ||= registered_components.any?(&:render?)
+ end
+
+ def render_placeholder?
+ @placeholder_block.present? &&
+ component_instance.blank? &&
+ !render_from_registered_components?
+ end
end
end
diff --git a/test/cases/wrapped_in_test.rb b/test/cases/wrapped_in_test.rb
new file mode 100644
index 0000000..5272961
--- /dev/null
+++ b/test/cases/wrapped_in_test.rb
@@ -0,0 +1,182 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class WrappedInTest < ViewTestCase
+ class Component < ViewComponentContrib::Base
+ attr_reader :should_render
+
+ def initialize(should_render: true)
+ @should_render = should_render
+ end
+
+ alias_method :render?, :should_render
+
+ def call
+ "Hello from test".html_safe
+ end
+ end
+
+ class NonViewComponent
+ include ViewComponentContrib::WrappedInHelper
+
+ def call
+ "Hello from test".html_safe
+ end
+ end
+
+ def test_renders_when_two_inner_components_render
+ inner_component_a = Component.new
+ inner_component_b = Component.new
+ wrapper_component = ViewComponentContrib::WrapperComponent.new
+
+ render_inline(wrapper_component) do |wrapper|
+ "Title
" \
+ "#{render_inline(inner_component_a.wrapped_in(wrapper)).to_html}
" \
+ "#{render_inline(inner_component_b.wrapped_in(wrapper)).to_html}
".html_safe
+ end
+
+ assert_selector page, "h3", count: 1, text: "Title"
+ assert_selector page, "div", count: 2
+ assert_selector page, "div", text: "Hello from test", count: 2
+ end
+
+ def test_renders_when_one_inner_component_renders
+ inner_component_a = Component.new
+ inner_component_b = Component.new(should_render: false)
+ wrapper_component = ViewComponentContrib::WrapperComponent.new
+
+ render_inline(wrapper_component) do |wrapper|
+ "Title
" \
+ "#{render_inline(inner_component_a.wrapped_in(wrapper)).to_html}
" \
+ "#{render_inline(inner_component_b.wrapped_in(wrapper)).to_html}
".html_safe
+ end
+
+ assert_selector page, "h3", count: 1, text: "Title"
+ assert_selector page, "div", count: 2
+ assert_selector page, "div", text: "Hello from test", count: 1
+ end
+
+ def test_does_not_render_when_no_inner_components_render
+ inner_component_a = Component.new(should_render: false)
+ inner_component_b = Component.new(should_render: false)
+ wrapper_component = ViewComponentContrib::WrapperComponent.new
+
+ render_inline(wrapper_component) do |wrapper|
+ "Title
" \
+ "#{render_inline(inner_component_a.wrapped_in(wrapper)).to_html}
" \
+ "#{render_inline(inner_component_b.wrapped_in(wrapper)).to_html}
".html_safe
+ end
+
+ assert_no_selector page, "h3"
+ assert_no_selector page, "div"
+ end
+
+ def test_outer_wrapper_renders_when_one_inner_wrapper_renders
+ inner_components_a = [Component.new, Component.new(should_render: false)]
+ inner_components_b = [Component.new(should_render: false), Component.new(should_render: false)]
+ inner_wrapper_component_a = ViewComponentContrib::WrapperComponent.new
+ inner_wrapper_component_b = ViewComponentContrib::WrapperComponent.new
+ outer_wrapper_component = ViewComponentContrib::WrapperComponent.new
+
+ render_inline(outer_wrapper_component) do |outer_wrapper|
+ "Title
" \
+ "#{
+ render_inline(inner_wrapper_component_a.wrapped_in(outer_wrapper)) do |inner_wrapper_a|
+ "Subtitle A
" \
+ "#{render_inline(inner_components_a[0].wrapped_in(inner_wrapper_a)).to_html}
" \
+ "#{render_inline(inner_components_a[1].wrapped_in(inner_wrapper_a)).to_html}
".html_safe
+ end
+ }" \
+ "#{
+ render_inline(inner_wrapper_component_b.wrapped_in(outer_wrapper)) do |inner_wrapper_b|
+ "Subtitle B
" \
+ "#{render_inline(inner_components_b[0].wrapped_in(inner_wrapper_b)).to_html}
" \
+ "#{render_inline(inner_components_b[1].wrapped_in(inner_wrapper_b)).to_html}
".html_safe
+ end
+ }".html_safe
+ end
+
+ assert_selector page, "h3", count: 1, text: "Title"
+ assert_selector page, "h3", count: 1, text: "Subtitle A"
+ assert_no_selector page, "h3", text: "Subtitle B"
+ assert_selector page, "div", text: "Hello from test", count: 1
+ end
+
+ def test_outer_wrapper_does_not_render_when_no_inner_wrappers_render
+ inner_components_a = [Component.new(should_render: false), Component.new(should_render: false)]
+ inner_components_b = [Component.new(should_render: false), Component.new(should_render: false)]
+ inner_wrapper_component_a = ViewComponentContrib::WrapperComponent.new
+ inner_wrapper_component_b = ViewComponentContrib::WrapperComponent.new
+ outer_wrapper_component = ViewComponentContrib::WrapperComponent.new
+
+ render_inline(outer_wrapper_component) do |outer_wrapper|
+ "Title
" \
+ "#{
+ render_inline(inner_wrapper_component_a.wrapped_in(outer_wrapper)) do |inner_wrapper_a|
+ "Subtitle A
" \
+ "#{render_inline(inner_components_a[0].wrapped_in(inner_wrapper_a)).to_html}
" \
+ "#{render_inline(inner_components_a[1].wrapped_in(inner_wrapper_a)).to_html}
".html_safe
+ end
+ }" \
+ "#{
+ render_inline(inner_wrapper_component_b.wrapped_in(outer_wrapper)) do |inner_wrapper_b|
+ "Subtitle B
" \
+ "#{render_inline(inner_components_b[0].wrapped_in(inner_wrapper_b)).to_html}
" \
+ "#{render_inline(inner_components_b[1].wrapped_in(inner_wrapper_b)).to_html}
".html_safe
+ end
+ }".html_safe
+ end
+
+ assert_no_selector page, "h3"
+ assert_no_selector page, "div"
+ end
+
+ def test_raises_error_when_passing_non_wrapper_component
+ component_a = Component.new
+ component_b = Component.new
+
+ assert_raises(ArgumentError) do
+ component_a.wrapped_in(component_b)
+ end
+ end
+
+ def test_raises_error_when_passing_non_view_component
+ non_view_component = NonViewComponent.new
+ wrapper_component = ViewComponentContrib::WrapperComponent.new
+
+ assert_raises(ArgumentError) do
+ non_view_component.wrapped_in(wrapper_component)
+ end
+ end
+
+ def test_placeholder_does_not_render_when_any_inner_component_renders
+ inner_component = Component.new
+ wrapper_component = ViewComponentContrib::WrapperComponent.new
+
+ render_inline(wrapper_component) do |wrapper|
+ "Title
" \
+ "#{render_inline(inner_component.wrapped_in(wrapper)).to_html}
" \
+ "#{wrapper.placeholder { "Nothing to show
".html_safe }}".html_safe
+ end
+
+ assert_selector page, "h3", count: 1, text: "Title"
+ assert_selector page, "div", text: "Hello from test", count: 1
+ assert_no_selector page, "div", text: "Nothing to show"
+ end
+
+ def test_placeholder_renders_when_no_inner_component_renders
+ inner_component = Component.new(should_render: false)
+ wrapper_component = ViewComponentContrib::WrapperComponent.new
+
+ render_inline(wrapper_component) do |wrapper|
+ "Title
" \
+ "#{render_inline(inner_component.wrapped_in(wrapper)).to_html}
" \
+ "#{wrapper.placeholder { "Nothing to show
".html_safe }}".html_safe
+ end
+
+ assert_no_selector page, "h3", count: 1, text: "Title"
+ assert_no_selector page, "div", text: "Hello from test"
+ assert_selector page, "div", text: "Nothing to show", count: 1
+ end
+end