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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
98 changes: 97 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
<div class="flex flex-col gap-4">
<h3>Title</h3>
<div class="flex gap-2">
<%= render ExampleA::Component %>
<%= render ExampleB::Component %>
</div>
</div>
```

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| %>
<div class="flex flex-col gap-4">
<h3>Title</h3>
<div class="flex gap-2">
<%= render ExampleA::Component.new.wrapped_in(wrapper) %>
<%= render ExampleB::Component.new.wrapped_in(wrapper) %>
</div>
</div>
<%- 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
<!-- Will only render if at least one of the inner wrappers renders -->
<%= render ViewComponentContrib::WrapperComponent.new do |wrapper| %>
<div class="flex flex-col gap-4">
<h3>Examples</h3>

<!-- Will only render if `FooExampleA` or `FooExampleB` renders -->
<%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |foo_wrapper| %>
<div class="flex flex-col gap-4">
<h4>Foo Examples</h4>
<div class="flex gap-2">
<%= render FooExampleA::Component.new.wrapped_in(foo_wrapper) %>
<%= render FooExampleB::Component.new.wrapped_in(foo_wrapper) %>
</div>
</div>
<%- end -%>

<!-- Will only render if `BarExampleA` or `BarExampleB` renders -->
<%= render ViewComponentContrib::WrapperComponent.new.wrapped_in(wrapper) do |bar_wrapper| %>
<div class="flex flex-col gap-4">
<h4>Bar Examples</h4>
<div class="flex gap-2">
<%= render BarExampleA::Component.new.wrapped_in(bar_wrapper) %>
<%= render BarExampleB::Component.new.wrapped_in(bar_wrapper) %>
</div>
</div>
<%- end -%>
</div>
<%- 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| %>
<div class="flex flex-col gap-4">
<h3>Title</h3>
<div class="flex gap-2">
<%= render ExampleA::Component.new.wrapped_in(wrapper) %>
<%= render ExampleB::Component.new.wrapped_in(wrapper) %>
</div>
</div>

<!-- Will only render if neither `ExampleA` nor `ExampleB` render -->
<%- wrapper.placeholder do -%>
<span>Examples coming soon!</span>
<%- end -%>
<%- end -%>
```

## License

Expand Down
1 change: 1 addition & 0 deletions lib/view_component_contrib.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions lib/view_component_contrib/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ module ViewComponentContrib
class Base < ViewComponent::Base
include TranslationHelper
include WrappedHelper
include WrappedInHelper
end
end
13 changes: 13 additions & 0 deletions lib/view_component_contrib/wrapped_in_helper.rb
Original file line number Diff line number Diff line change
@@ -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
68 changes: 63 additions & 5 deletions lib/view_component_contrib/wrapper_component.rb
Original file line number Diff line number Diff line change
@@ -1,40 +1,98 @@
# 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
raise DoubleRenderError, component_instance if @rendered

@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
Loading