34
loading...
This website collects cookies to deliver better user experience
<%=# posts/show.html.erb %>
<h1><%= @post.name %></h1>
<%= render "some_partial" %>
<%=# posts/_some_partial.html.erb %>
<p><%= @post.created_at %></p>
Notice, how the instance variables are shared without explicitly passing it.
gem
itself.# Gemfile
gem "view_component", require: "view_component/engine"
gem
, create a new file at app/components/application_component.rb
.# app/components/application_component.rb
class ApplicationComponent < ViewComponent::Base
end
class
to add reusable code so that other components can inherit from it, and ViewComponent generators will also automatically inherit from this class
if you've declared it.subhead
component that GitHub utilizes heavily in their settings page.rails g component subhead
subhead
component, we can notice that# app/components/subhead_component.rb
class SubheadComponent < ApplicationComponent
def initialize(title:, description: nil, danger: false)
@title = title
@description = description
@danger = danger
end
def render?
@title.present?
end
end
<%=# app/components/subhead_component.html.erb %>
<div>
<h2><%= @title %></h2>
<p class="<%= @danger ? 'subhead--danger' : 'some other class' %>">
<%= @description %>
</p>
</div>
.erb
files, by calling,<%= render SubheadComponent.new(title: "something", description: "subhead description")
h2
or the p
? What if you need to pass in data-
attributes? Umm, you'll probably feel lost in multiple if-else
statements. This problem could have been avoided in the first place if we made our components more susceptible to changes.lambda
to make our components decoupled from the state.# app/components/application_component.rb
class ApplicationComponent < ViewComponent::Base
def initialize(tag: nil, classes: nil, **options)
@tag = tag
@classes = classes
@options = options
end
def call
content_tag(@tag, content, class: @classes, **@options) if @tag
end
# helpers
def class_names(*args)
classes = []
args.each do |class_name|
case class_name
when String
classes << class_name if class_name.present?
when Hash
class_name.each do |key, val|
classes << key if val
end
when Array
classes << class_names(*class_name).presence
end
end
classes.compact.uniq.join(" ")
end
end
call
method so that we can use our lambda
. It's all Rails, so we can probably use content_tag
and other view
helpers as well. Now let's change our subhead
component.# app/components/subhead_component.rb
class SubheadComponent < ApplicationComponent
renders_one :heading, lambda { |variant: nil, **options|
options[:tag] ||= :h2
options[:classes] = class_names(
options[:classes],
"subhead-heading",
"subhead-heading--danger": variant == "danger",
)
ApplicationComponent.new(**options)
}
renders_one :description, lambda { |**options|
options[:tag] ||= :div
options[:classes] = class_names(
options[:classes],
"subhead-description",
)
ApplicationComponent.new(**options)
}
def initialize(**options)
@options = options
@options[:tag] ||= :div
@options[:classes] = class_names(
options[:classes],
"subhead",
)
end
def render?
heading.present?
end
end
<%=# app/components/subhead_component.html.erb %>
<%= render ApplicationComponent.new(**@options) do %>
<%= heading %>
<%= description %>
<% end %>
<%= render SubheadComponent.new(data: { controller: "subhead" }) do |c| %>
<% c.heading(classes: "more-classes") { "Hey there!" } %>
<% c.description(tag: :div, variant: "danger") do %>
My description
<% end %>
<% end %>
inline
variant of the ViewComponent.rails g component avatar --inline
.rb
file and not the .html.erb
file. For simple components, it's fine to just render
it from the .rb
file itself by making use of the ApplicationComponent
.class AvatarComponent < ApplicationComponent
def initialize(src:, alt:, size: 9, **options)
@options = options
@options[:tag] ||= :img
@options[:src] = src
@options[:alt] = alt
@options[:classes] = class_names(
options[:classes],
"avatar rounded-full flex items-center justify-center",
"avatar--#{size}",
)
end
def call
render ApplicationComponent.new(**@options)
end
end
<%= render AvatarComponent.new(src: "some url", alt: "your alt attribute", size: 10) %>
classes
, data
attributes, and more. In my opinion, this is a good way to build components. They are segregated from your business logic and allow unit testing, which is advantageous as compared to normal Rails partials.// app/javascript/controllers/popover_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["container"]
initialize() {
document.addEventListener("click", (event) => {
if (this.element.contains(event.target)) return
this.hide()
})
}
toggle(event) {
event.preventDefault()
this.containerTarget.toggleAttribute("hidden")
}
hide() {
this.containerTarget.setAttribute("hidden", "")
}
}
app/components/application_component.rb
, so that we can pass in other data
attributes without any complexity.# app/components/application_component.rb
def merge_attributes(*args)
args = Array.new(2) { Hash.new } if args.compact.blank?
hashed_args = args.map { |el| el.presence || {} }
hashed_args.first.deep_merge(hashed_args.second) do |_key, val, other_val|
val + " #{other_val}"
end
end
rails g component popover
and let's get started.# app/components/popover_component.rb
class PopoverComponent < ApplicationComponent
DEFAULT_POSITION = :top_left
POSITIONS = {
bottom: "popover-message--bottom",
bottom_right: "popover-message--bottom-right",
bottom_left: "popover-message--bottom-left",
left: "popover-message--left",
left_bottom: "popover-message--left-bottom",
left_top: "popover-message--left-top",
right: "popover-message--right",
right_bottom: "popover-message--right-bottom",
right_top: "popover-message--right-top",
top_left: "popover-message--top-left",
top_right: "popover-message--top-right"
}.freeze
renders_one :body, lambda { |caret: DEFAULT_POSITION, **options|
options[:tag] ||= :div
options[:classes] = class_names(
options[:classes],
"popover-message box p-3 shadow-lg mt-1",
POSITIONS[caret.to_sym],
)
ApplicationComponent.new(**options)
}
def initialize(**options)
@options = options
@options[:tag] ||= :div
@options[:classes] = class_names(
options[:classes],
"popover",
)
@options[:data] = merge_attributes( # we're utilizing the `merge_attributes` helper that we defined earlier.
options[:data],
popover_target: "container", # from stimulus controller. Compiles to "data-popover-target": "container"
)
end
end
<%=# app/components/popover_component.html.erb %>
<%= render ApplicationComponent.new(**@options, hidden: "") do %>
<%= body %>
<% end %>
Note that we're hiding the popover at first. We'll use stimulus controller to remove this attribute
later.
<div data-controller="popover">
<button type="button" data-action="popover#toggle">
Toggle popover
</button>
<%= render PopoverComponent.new do |c| %>
<% c.body(caret: "bottom_right") do %>
<p>Anything goes inside</p>
<% end %>
<% end %>
</div>
button
in the component.<%=# app/components/popover_component.html.erb %>
<%= render ApplicationComponent.new(**@options, hidden: "") do %>
<button type="button" data-action="popover#toggle">
Toggle popover
</button>
<%= body %>
<% end %>
button
or the anchor_tag
or any other component that is responsible for showing and hiding the popover component.render PopoverComponent.new
doesn't look that good. Calling a class
directly in your views, Ummm, I don't know.# app/helpers/application_helper.rb
def render_component(component_path, collection: nil, **options, &block)
component_klass = "#{component_path.classify}Component".constantize
if collection
render component_klass.with_collection(collection, **options), &block
else
render component_klass.new(**options), &block
end
end
render_component "popover", **@options
, which in my opinion looks much better and reads much better.data_attributes
method to merge_attributes
deep_merge
method that Rails gives us within the merge_attributes
method