Skip to content
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

Modifying HTML attributes within a Nice Partial #13

Open
domchristie opened this issue May 12, 2021 · 3 comments · May be fixed by #14
Open

Modifying HTML attributes within a Nice Partial #13

domchristie opened this issue May 12, 2021 · 3 comments · May be fixed by #14

Comments

@domchristie
Copy link
Contributor

This might be more of a component architecture issue, but I thought I'd throw it out there.

Let's say we have the following Nice Partial for a card component:

<%# app/views/components/_card.html.erb %>
<% yield p = np %>
<%= content_tag :article, class: 'card rounded-lg' do %>
  <h2 class="font-lg font-bold"><%= p.yield :heading %></h2>
  <%= p.yield :blurb %>
<% end %>

… and we render some blog posts using that component:

<%# app/views/posts/index.html.erb %>
<% @posts.each do |post| %>
  <%= render 'components/card' do |p| %>
    <% p.content_for :heading, post.title %>
    <% p.content_for :blurb, post.description %>
  <% end %>
<% end %>

Now, say we want to customize the card style these posts, by reducing the roundness of the corners and adding a card--post modifier. (We'll use both utility and BEM-style class names to demonstrate a couple of approaches.) Our desired output would be:

<article class="card card--post rounded-sm">
  <h2 class="font-lg font-bold">Hello, world!</h2>
  Lorem ipsum
</article>

This is quite tricky since, we need to:

  1. add card--post without clobbering the default class (card)
  2. replace rounded-lg with rounded-sm

To satisfy 1., a nice API might look as follows:

<%# app/views/components/_card.html.erb %>
<% yield p = np %>
<%= content_tag :article, class: ['card', 'rounded-lg', local_assigns[:class]] do %>
  <h2 class="font-lg font-bold"><%= p.yield :heading %></h2>
  <%= p.yield :blurb %>
<% end %>
<%# app/views/posts/index.html.erb %>
<% @posts.each do |post| %>
  <%= render 'components/card', class: 'card--post' do |p| %>
    <% p.content_for :heading, post.title %>
    <% p.content_for :blurb, post.description %>
  <% end %>
<% end %>

But how to we override or remove the rounded-lg class? One approach might be to remove the rounded-lg class from the partial, but this would require an adding rounded- classes to every instance.

Or if we're using Tailwind JIT, we could utilise the !important modifier:

<%# app/views/posts/index.html.erb %>
<% @posts.each do |post| %>
  <%= render 'components/card', class: 'card--post !rounded-sm' do |p| %>
    <% p.content_for :heading, post.title %>
    <% p.content_for :blurb, post.description %>
  <% end %>
<% end %>

… but I'm wondering if there's a possible built-in solution? For example, could we implement something which mimics the DOMTokenList API:

<%# app/views/posts/index.html.erb %>
<% @posts.each do |post| %>
  <%= render 'components/card', class: 'card--post' do |p| %>
    <% p.class_list.replace('rounded-lg', 'rounded-sm') %>
    <% p.content_for :heading, post.title %>
    <% p.content_for :blurb, post.description %>
  <% end %>
<% end %>

I'm also wondering if this could somehow apply to options_for, e.g.:

<%# app/views/posts/index.html.erb %>
<% @posts.each do |post| %>
  <%= render 'components/card', class: 'card--post' do |p| %>
    <% p.class_list.replace('rounded-lg', 'rounded-sm') %>
    
    <% p.content_for :heading, post.title %>
    <% p.options_for(:heading)[:class].remove('font-bold') %>
    
    <% p.content_for :blurb, post.description %>
  <% end %>
<% end %>

(It's been a while since I looked at #9, so this might not be possible to get this working at all!)

@domchristie domchristie linked a pull request May 21, 2021 that will close this issue
@domchristie
Copy link
Contributor Author

The above was a brain dump whilst recovering from my 2nd Covid jab 🥴, so to hopefully provide a bit more clarity…

Given a basic card component partial, let's say we want to render a list of blog posts as cards, but we wish to customise the HTML a little. Our caller template might look like:

<%= render 'components/card', class: 'post', tag: :article do |s| %>
  <% s.options_for :header, class: 'post__header' %>
  <% s.content_for :heading, post.title, tag: :h2 %>
  <% s.content_for :body, post.excerpt, class: 'post__body' %>
<% end %>

This should render:

<article class="card post">
  <header class="card__header post__header">
    <h2></h2>
  </header>
  <div class="card__body post__body"></div>
</article>

The criteria:

  • Customisable tag for the main element
  • Customisable tag for the heading element
  • Class names are merged with those that are preset in the partial (i.e. card post, card__header post__header, and card__body post__body)

Questions

  1. Is it clear that setting options in this way merges the values with the current options?
  2. Or should the class option overwrite/clobber the existing class option by default (by simply calling Hash#merge)?
  3. If so, should there be an API to specify merging (e.g. s.options_for(:header, class: 'post__header', merge: [:class]))? or would this get too repetitive?

I'm finding it a bit difficult to get my head around what's best here. I like the aesthetics of the first code example, but would appreciate some feedback.


I'm totally getting ahead of myself, but if we go ahead with merging values, we could move towards something like the following:

<% # Set up the default options _before_ `yield p` for consumption in the caller template
p = np(class: 'card')
p.options_for :header, class: 'card__header'
p.options_for :body, class: 'card__body'

p.helpers do
  def heading_tag
    @heading_tag ||= (options_for(:heading).delete(:tag) || :h1)
  end
end
  
yield p
%>

<%= content_tag local_assigns.delete(:tag) || :div, options do %>
  <%= content_tag :header, p.options_for :header do %>
    <%= content_tag heading_tag, p.content_for(:heading) %>
  <% end %>
  
  <%= content_tag :div, p.content_for(:body), p.options_for(:body) %>
<% end %>

It's not ideal that the options have to be set at the top, away from the actual elements, but I'm not sure there's a way round this if we want them to be accessible from the caller template. Also there's a lot of content_tags, which isn't to everyone's taste, but I do find the content_tag options API very powerful.

Finally, to add a bit more sugar, we could simplify the following emerging pattern:

<%= content_tag :div, p.content_for(:body), p.options_for(:body) %>

…to:

<%= p.slot_tag_for :div, :body %>

@seanpdoyle
Copy link
Collaborator

rails/rails#42682 has shipped and will be part of Rails 7.

It might be useful if p.content_for :section, class: "..." support were added, since the p could be yielded with a base set of attribute yield p.with_options(class: "my-base-class"). It doesn't quite handle merging the token list, but it opens the door to overriding values.

rails/rails#41638 had originally explored built-in support for (deep-)merging values into data- and aria- prefixed HTML attributes (via tag.attributes) and DOMTokenLists (via token_list or class_names).

It felt a little half baked, so it was closed. In its place, I created https://github.com/seanpdoyle/action_view-attributes_and_token_lists to further explore the possibilities. It feels like it'd be a great fit for NicePartials!

@andrewculver
Copy link
Contributor

@seanpdoyle Really appreciate this! Will take a look soon!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants