-
Notifications
You must be signed in to change notification settings - Fork 443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement Slots as potential successor to Content Areas #348
Changes from all commits
4ebf506
98249e0
87cc569
5d874e7
2b584dd
79e754d
87a27dd
d356522
c1b73da
c57e79d
cc4ddc8
d04cd13
1f5066d
841ff62
532704e
6000376
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -160,6 +160,133 @@ Returning: | |
</div> | ||
``` | ||
|
||
#### Slots (experimental) | ||
|
||
_Slots are currently under development as a successor to Content Areas. The Slot APIs should be considered unfinished and subject to breaking changes in non-major releases of ViewComponent._ | ||
|
||
Slots enable multiple blocks of content to be passed to a single ViewComponent. | ||
|
||
Slots exist in two forms: normal slots and collection slots. | ||
|
||
Normal slots can be rendered once per component. They expose an accessor with the name of the slot that returns an instance of `BoxComponent::Header`, etc. | ||
|
||
Collection slots can be rendered multiple times. They expose an accessor with the pluralized name of the slot (`#rows`), which is an Array of `BoxComponent::Row` instances. | ||
|
||
To learn more about the design of the Slots API, see https://github.com/github/view_component/pull/348. | ||
|
||
##### Defining Slots | ||
|
||
Slots are defined by the `with_slot` macro: | ||
|
||
`with_slot :header` | ||
|
||
To define a collection slot, add `collection: true`: | ||
|
||
`with_slot :row, collection: true` | ||
|
||
To define a slot with a custom class, pass `class_name`: | ||
|
||
`with_slot :body, class_name: 'BodySlot` | ||
|
||
Slot classes should be subclasses of `ViewComponent::Slot`. | ||
|
||
##### Example ViewComponent with Slots | ||
|
||
`# box_component.rb` | ||
```ruby | ||
class BoxComponent < ViewComponent::Base | ||
include ViewComponent::Slotable | ||
|
||
with_slot :body, :footer | ||
with_slot :header, class_name: "Header" | ||
with_slot :row, collection: true, class_name: "Row" | ||
|
||
class Header < ViewComponent::Slot | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if it would be possible to have slots still inherit from If we could do that, I think it'd be one less thing to remember and extracting full components is easier since you no longer have to change what it inherits from. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could see that being possible, but my concern with that approach would be that it might imply that components could be used as Slots, when a Slot is just a simple accessor object. I do wonder if there's a simpler way to express the Slot interface though, as all we really care about is that the object has a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can't help scratch the itch for mr that slots could themselves be ViewComponents. I've mentioned this before and I don't yet have the concrete uses cases (though i have my suspicions) so I'm prepared to let this go for a future release. But here is my thinking. If you stripped out some of the magic around class_name and generating classes we're providing in <% component.slot(:header, HeaderSlot.new(class_names: "my-class-name")) do %>
This is my header!
<% end %> the requirement on HeaderSlot being that it provide a Under that light the Now imagine the guts of the slot = slots[slot_name]
if args.is_a?(Hash)
# The class name of the Slot, such as Header
slot_class = self.class.const_get(slot[:class_name])
# Instantiate Slot class, accommodating Slots that don't accept arguments
slot_instance = args.present? ? slot_class.new(args) : slot_class.new
else
# args were really a class
slot_instance = args
end
if slot_instance.responds_to(:render_in)
# RenderInSlotWrapper exposes a `content` property that is the result of `render_in`
# I'd need to think it through more but I think we need some sort of wrapper that 'captures' the entirety of the rendered ViewComponent
slot_instance = RenderInSlotWrapper.new(slot_instance).render_in(view_context, &block)
else
# Capture block and assign to slot_instance#content
slot_instance.content = view_context.capture(&block) if block_given?
end My spidey senses says there is something power here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jonspalmer I disagree with the following though: <% component.slot(:header, HeaderSlot.new(class_names: "my-class-name")) do %>
This is my header!
<% end %> If the <%= render(MediaObjectComponent.new) do |media_object| %>
<% media_object.slot(:left) do %>
<%= render(AvatarComponent.new(image: comment.image)) %>
<% end %>
<% media_object.slot(:right) do %>
<%= comment.body %>
<% end %>
<% end %> There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with the pattern of using a component in a slot. I believe that will be powerful enough for many uses cases. However, I don't really see the 'internal' piece of a component quite the same way. I see Slots as defining an interface between the component and the slots it uses. Its an Interface not a concrete implementation. As such a ViewComponent leveraging slots comes with so slot classes. IMO it would be powerful allow those classes to be extended. Its let about using those Slot classes outside the component its that I can extend them (subclassing most likely) to be used in the component. For example consider the Row example from the docs class Row < ViewComponent::Slot
def initialize(theme: :gray)
@theme = theme
end
def theme_class_name
case @theme
when :gray
"Box-row--gray"
when :hover_gray
"Box-row--hover-gray"
when :yellow
"Box-row--yellow"
when :blue
"Box-row--blue"
when :hover_blue
"Box-row--hover-blue"
else
"Box-row--gray"
end
end
end The interface that the component relies on is that Row have a content getter/setter and it has a getter for No suppose I want to use this component but add some other classes to the header based on logic special to my use case. I'd do this: class MySpecialRow < Row
def initialize(theme: :gray, special: :foo)
super(theme)
@special = special
end
def theme_class_name
special_classes = some_other_logic(special)
"#{super} #{special_classes}
end
end and then in the component usage <% component.slot(:row, MySpecialRow.new(theme: :hover_blue, special: :make_some_magic) do %>
Special row
<% end %> There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm, couldn't you use composition instead? I'm just thinking composition is generally a better pattern in the view layer than overriding/extending. It's the only way you can build components in React, and it works pretty well there. But I admit I have a hard time seeing all the possibilities of 3rd party components and how people would want to extend them. I guess to phrase it differently, let's say there's a 3rd party library/gem with Twitter bootstrap components built with |
||
def initialize(class_names: "") | ||
@class_names = class_names | ||
end | ||
|
||
def class_names | ||
"Box-header #{@class_names}" | ||
end | ||
end | ||
|
||
class Row < ViewComponent::Slot | ||
def initialize(theme: :gray) | ||
@theme = theme | ||
end | ||
|
||
def theme_class_name | ||
case @theme | ||
when :gray | ||
"Box-row--gray" | ||
when :hover_gray | ||
"Box-row--hover-gray" | ||
when :yellow | ||
"Box-row--yellow" | ||
when :blue | ||
"Box-row--blue" | ||
when :hover_blue | ||
"Box-row--hover-blue" | ||
else | ||
"Box-row--gray" | ||
end | ||
end | ||
end | ||
end | ||
``` | ||
|
||
`# box_component.html.erb` | ||
```erb | ||
<div class="Box"> | ||
<% if header %> | ||
<div class="<%= header.class_names %>"> | ||
<%= header.content %> | ||
</div> | ||
<% end %> | ||
<% if body %> | ||
<div class="Box-body"> | ||
<%= body.content %> | ||
</div> | ||
<% end %> | ||
<% if rows.any? %> | ||
<ul> | ||
<% rows.each do |row| %> | ||
<li class="Box-row <%= row.theme_class_name %>"> | ||
<%= row.content %> | ||
</li> | ||
<% end %> | ||
</ul> | ||
<% end %> | ||
<% if footer %> | ||
<div class="Box-footer"> | ||
<%= footer %> | ||
</div> | ||
<% end %> | ||
</div> | ||
``` | ||
|
||
`# index.html.erb` | ||
```erb | ||
<%= render(BoxComponent.new) do |component| %> | ||
<% component.slot(:header, class_names: "my-class-name") do %> | ||
This is my header! | ||
<% end %> | ||
<% component.slot(:body) do %> | ||
This is the body. | ||
<% end %> | ||
<% component.slot(:row) do %> | ||
Row one | ||
<% end %> | ||
<% component.slot(:row, theme: :yellow) do %> | ||
Yellow row | ||
<% end %> | ||
<% component.slot(:footer) do %> | ||
This is the footer. | ||
<% end %> | ||
<% end %> | ||
``` | ||
|
||
### Inline Component | ||
|
||
ViewComponents can render without a template file, by defining a `call` method: | ||
|
@@ -785,10 +912,10 @@ ViewComponent is built by: | |
|@simonrand|@fugufish|@cover|@franks921|@fsateler| | ||
|Dublin, Ireland|Salt Lake City, Utah|Barcelona|South Africa|Chile| | ||
|
||
|<img src="https://avatars.githubusercontent.com/maxbeizer?s=256" alt="maxbeizer" width="128" />|<img src="https://avatars.githubusercontent.com/franco?s=256" alt="franco" width="128" />|<img src="https://avatars.githubusercontent.com/tbroad-ramsey?s=256" alt="tbroad-ramsey" width="128" />| | ||
|:---:|:---:|:---:| | ||
|@maxbeizer|@franco|@tbroad-ramsey| | ||
|Nashville, TN|Switzerland|Spring Hill, TN| | ||
|<img src="https://avatars.githubusercontent.com/maxbeizer?s=256" alt="maxbeizer" width="128" />|<img src="https://avatars.githubusercontent.com/franco?s=256" alt="franco" width="128" />|<img src="https://avatars.githubusercontent.com/tbroad-ramsey?s=256" alt="tbroad-ramsey" width="128" />|<img src="https://avatars.githubusercontent.com/jensljungblad?s=256" alt="jensljungblad" width="128" />|<img src="https://avatars.githubusercontent.com/bbugh?s=256" alt="bbugh" width="128" />| | ||
|:---:|:---:|:---:|:---:|:---:| | ||
|@maxbeizer|@franco|@tbroad-ramsey|@jensljungblad|@bbugh| | ||
|Nashville, TN|Switzerland|Spring Hill, TN|New York, NY|Austin, TX| | ||
|
||
## License | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ | |
require "view_component/collection" | ||
require "view_component/compile_cache" | ||
require "view_component/previewable" | ||
require "view_component/slotable" | ||
|
||
module ViewComponent | ||
class Base < ActionView::Base | ||
|
@@ -17,6 +18,10 @@ class Base < ActionView::Base | |
class_attribute :content_areas | ||
self.content_areas = [] # class_attribute:default doesn't work until Rails 5.2 | ||
|
||
# Hash of registered Slots | ||
class_attribute :slots | ||
self.slots = {} | ||
Comment on lines
+21
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems like this might belong in the new |
||
|
||
# Entrypoint for rendering components. | ||
# | ||
# view_context: ActionView context from calling view | ||
|
@@ -181,6 +186,10 @@ def inherited(child) | |
# has been re-defined by the consuming application, likely in ApplicationComponent. | ||
child.source_location = caller_locations(1, 10).reject { |l| l.label == "inherited" }[0].absolute_path | ||
|
||
# Clone slot configuration into child class | ||
# see #test_slots_pollution | ||
child.slots = self.slots.clone | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems like it might also belong in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In theory, I agree. I avoided doing so as I didn't want to re-define |
||
|
||
super | ||
end | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
# frozen_string_literal: true | ||
|
||
module ViewComponent | ||
class Slot | ||
attr_accessor :content | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
# frozen_string_literal: true | ||
|
||
require "active_support/concern" | ||
|
||
require "view_component/slot" | ||
|
||
module ViewComponent | ||
module Slotable | ||
extend ActiveSupport::Concern | ||
|
||
class_methods do | ||
# support initializing slots as: | ||
# | ||
# with_slot( | ||
# :header, | ||
# collection: true|false, | ||
# class_name: "Header" # class name string, used to instantiate Slot | ||
# ) | ||
def with_slot(*slot_names, collection: false, class_name: nil) | ||
slot_names.each do |slot_name| | ||
# Ensure slot_name is not already declared | ||
if self.slots.key?(slot_name) | ||
raise ArgumentError.new("#{slot_name} slot declared multiple times") | ||
end | ||
|
||
# Ensure slot name is not :content | ||
if slot_name == :content | ||
raise ArgumentError.new ":content is a reserved slot name. Please use another name, such as ':body'" | ||
end | ||
|
||
# Set the name of the method used to access the Slot(s) | ||
accessor_name = | ||
if collection | ||
# If Slot is a collection, set the accessor | ||
# name to the pluralized form of the slot name | ||
# For example: :tab => :tabs | ||
ActiveSupport::Inflector.pluralize(slot_name) | ||
else | ||
slot_name | ||
end | ||
|
||
instance_variable_name = "@#{accessor_name}" | ||
|
||
# If the slot is a collection, define an accesor that defaults to an empty array | ||
if collection | ||
class_eval <<-RUBY | ||
def #{accessor_name} | ||
#{instance_variable_name} ||= [] | ||
end | ||
RUBY | ||
else | ||
attr_reader accessor_name | ||
end | ||
|
||
# Default class_name to ViewComponent::Slot | ||
class_name = "ViewComponent::Slot" unless class_name.present? | ||
|
||
# Register the slot on the component | ||
self.slots[slot_name] = { | ||
class_name: class_name, | ||
instance_variable_name: instance_variable_name, | ||
collection: collection | ||
} | ||
end | ||
end | ||
end | ||
|
||
# Build a Slot instance on a component, | ||
# exposing it for use inside the | ||
# component template. | ||
# | ||
# slot: Name of Slot, in symbol form | ||
# **args: Arguments to be passed to Slot initializer | ||
# | ||
# For example: | ||
# <%= render(SlotsComponent.new) do |component| %> | ||
# <% component.slot(:footer, class_names: "footer-class") do %> | ||
# <p>This is my footer!</p> | ||
# <% end %> | ||
# <% end %> | ||
# | ||
def slot(slot_name, **args, &block) | ||
# Raise ArgumentError if `slot` does not exist | ||
unless slots.keys.include?(slot_name) | ||
raise ArgumentError.new "Unknown slot '#{slot_name}' - expected one of '#{slots.keys}'" | ||
end | ||
|
||
slot = slots[slot_name] | ||
|
||
# The class name of the Slot, such as Header | ||
slot_class = self.class.const_get(slot[:class_name]) | ||
|
||
unless slot_class <= ViewComponent::Slot | ||
raise ArgumentError.new "#{slot[:class_name]} must inherit from ViewComponent::Slot" | ||
end | ||
|
||
# Instantiate Slot class, accommodating Slots that don't accept arguments | ||
slot_instance = args.present? ? slot_class.new(args) : slot_class.new | ||
|
||
# Capture block and assign to slot_instance#content | ||
slot_instance.content = view_context.capture(&block) if block_given? | ||
|
||
if slot[:collection] | ||
# Initialize instance variable as an empty array | ||
# if slot is a collection and has yet to be initialized | ||
unless instance_variable_defined?(slot[:instance_variable_name]) | ||
instance_variable_set(slot[:instance_variable_name], []) | ||
end | ||
|
||
# Append Slot instance to collection accessor Array | ||
instance_variable_get(slot[:instance_variable_name]) << slot_instance | ||
else | ||
# Assign the Slot instance to the slot accessor | ||
instance_variable_set(slot[:instance_variable_name], slot_instance) | ||
end | ||
|
||
# Return nil, as this method should not output anything to the view itself. | ||
nil | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<%= title %> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
# frozen_string_literal: true | ||
|
||
class BadSlotComponent < ViewComponent::Base | ||
include ViewComponent::Slotable | ||
|
||
with_slot :title, class_name: "Title" | ||
|
||
# slots must inherit from ViewComponent::Slot! | ||
class Title; end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
<div class="card <%= @class_names %>"> | ||
<div class="title"> | ||
<%= title.content %> | ||
</div> | ||
<div class="subtitle"> | ||
<%= subtitle.content %> | ||
</div> | ||
<div class="nav"> | ||
<% if tabs.any? %> | ||
<% tabs.each do |tab| %> | ||
<div class="tab"> | ||
<%= tab.content %> | ||
</div> | ||
<% end %> | ||
<% else %> | ||
No tabs provided | ||
<% end %> | ||
</div> | ||
<div class="body"> | ||
<% if items.any? %> | ||
<% items.each do |item| %> | ||
<div class="item <%= item.class_names %>"> | ||
<%= item.content %> | ||
</div> | ||
<% end %> | ||
<% else %> | ||
No items provided | ||
<% end %> | ||
</div> | ||
<div class="footer <%= footer.class_names %>"> | ||
<%= footer.content %> | ||
</div> | ||
</div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Named slot subclasses aren't automatically generated after cc4ddc8 right?