pantographe / view_component-form

Rails FormBuilder for ViewComponent
MIT License
201 stars 16 forks source link

Grouping labels & fields #36

Open Spone opened 3 years ago

Spone commented 3 years ago

A common use case when building forms is the need to group labels and fields, or multiple fields together. Let's discuss these cases.

A label + a field

It's the most common use case. That's for instance what is generated by Rails scaffolding:

  <div class="field">
    <%= form.label :first_name %>
    <%= form.text_field :first_name %>
  </div>

When the field is a check_box or a radio_button, you usually want to invert the label and the input:

  <div class="field">
    <%= form.check_box :accepts_terms %>
    <%= form.label :accepts_terms %>
  </div>

We could have a ViewComponent::Form::GroupComponent for this purpose.

We could also add a label option to some helpers (see #16).

Errors

When a field has errors, it's a good practice to display them next to the field. The errors could be handled by the ViewComponent::Form::GroupComponent.

Hints

Some fields require additional information to help the user. The ViewComponent::Form::GroupComponent could accept a hint option for this. See GOV.UK for an example implementation.

A group of fields (and their labels)

The <fieldset> element is used for this.

<fieldset>
  <legend>Your identity</legend>

  <div class="field">
    <%= form.label :first_name %>
    <%= form.text_field :first_name %>
  </div>

  <div class="field">
    <%= form.label :last_name %>
    <%= form.text_field :last_name %>
  </div>
</fieldset>

Rails does not provide a dedicated helper for this element.

We could have a ViewComponent::Form::FieldsetComponent for this purpose, or reuse the ViewComponent::Form::GroupComponent (but it would make it more complex).

Spone commented 3 years ago

Here are some syntax options for groups, which one do you prefer? Or maybe we can implement both?

1. Additional params to the existing Rails helpers

We could pass strings:

<%= f.text_field :first_name, label: "First name", hint: "How should we call you?" %>
<div>
  <label for="user_first_name">First name</label>
  <span>How should we call you?</span>
  <input type="text" id="user_first_name" name"user[first_name]" />
</div>

or booleans... in this case the label and the hint come from the locale files:

<%= f.text_field :first_name, label: true, hint: true %>
# config/locale/en.yml
helpers:
  label:
    user:
      first_name: First name
  hint:
    user:
      first_name: How should we call you?

We can also pass hashes with a :text key. This allows us to add more params later (for instance to add class or position). That's similar to what GOV.UK does.

<%= f.text_field :first_name, label: { text: "First name" }, hint: { text: "How should we call you?" } %>
<%= f.text_field :first_name, label: { text: "First name", class: "my-custom-label" }, hint: { text: "How should we call you?", position: :after_input } %>
<div>
  <label for="user_first_name" class="my-custom-label">First name</label>
  <input type="text" id="user_first_name" name"user[first_name]" />
  <span>How should we call you?</span>
</div>

2. Using a group helper, with a block

<%= f.group :first_name, hint: "How should we call you?" do %>
  <%= f.text_field :first_name %>
<% end %>
<div>
  <label for="user_first_name">First name</label>
  <span>How should we call you?</span>
  <input type="text" id="user_first_name" name"user[first_name]" />
</div>
nicolas-brousse commented 2 years ago

I personally prefer the second one

Spone commented 2 years ago

After discussing it, we'll go for the second option (Using a group helper, with a block).

While working on a first implementation, we hit the following roadblocks:

  1. it would be useful for the input to "inherit" some options from the containing group (for instance if the group has the class .form-group you may want the input to have .form-group-input)
  2. there will be some similar I18n lookup logic in the group (label, hint) and in the input (placeholder), we should try to keep it DRY
  3. when implementing the hint, we need to add the hint element ID in an aria-describedby attribute of the input (by the way, #field_id can be used for this)
  4. when the group contains a collection of checkboxes or radio buttons, we don't want to use a <label> element for the group label, since the labels are already attached to each checkbox / radio

A potential solution to 1. 2. 3. is for the block to receive its own FormBuilder instance as an argument, so instead of:

<%= f.group :first_name, hint: "How should we call you?" do %>
  <%= f.text_field :first_name %>
<% end %>

we would have

<%= f.group :first_name, hint: "How should we call you?" do |g| %>
  <%= g.text_field :first_name %>
<% end %>

Then we could inject the class, placeholder, aria-describedby.

For 4. we may need to create another helper, such as collection_group or maybe pass a param label_tag: :div to group?

Feel free to contribute ideas :pray:

tmaier commented 2 years ago

Hi, it would be great to have the "primitives" for this in the code... I think having such a group method could be the second step, as the primitives "hints" and "error message" are always useful.

I created something like this. Maybe it is useful for anyone: https://gist.github.com/tmaier/22966c6bddac86e3612c8eddc072b919

Spone commented 2 years ago

Hi, it would be great to have the "primitives" for this in the code... I think having such a group method could be the second step, as the primitives "hints" and "error message" are always useful.

Hi @tmaier I opened an issue for this (#97) would you like to contribute a PR for this?

Spone commented 1 year ago

Related to #127

woller commented 1 month ago

I've been playing around with a slot-based solution for this. There are some downsides as the syntax is not exactly the same as using a proper FormBuilder.

# frozen_string_literal: true

class Form::GroupComponent < ViewComponent::Form::FieldComponent
  renders_one :input, ->(input, options = {}) do
    "Form::#{input.to_s.camelize}FieldComponent".constantize.new(
      form,
      object_name,
      method_name,
      default_input_options.merge(options)
    )
  end

  def show_label?
    options[:hide_label] != true
  end

  def hint
    options[:hint] # || t(some.translation.key.default)
  end

  def default_input_options
    return {} unless hint.present?

    {aria: {describedby: hint_id}}
  end

  def hint_id
    form.field_id(method_name, :hint)
  end
end
<fieldset>
  <% if show_label? %>
    <label for="<%= method_name %>" class="block text-sm font-medium leading-6 text-gray-900"><%= label_text %></label>
  <% end %>
  <div class="mt-2">
    <div class="relative rounded-md shadow-sm">
      <%= input %>

      <% if method_errors? %>
        <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
          <%= render IconComponent.new(name: :circle_exclamation, scheme: :alert) %>
        </div>
      <% end %>
    </div>

    <%= form.hint method_name, hint, id: hint_id %>
    <%= form.error_message method_name %>
  </div>
</fieldset>
<%= form.group :email, hint: "What is the email you signed up with?" do |group| %>
  <%= group.with_input :email, autocomplete: "email", class: "w-full" %>
<% end %>
<fieldset>
  <label for="email" class="block text-sm font-medium leading-6 text-gray-900">Email</label>
  <div class="mt-2">
    <div class="relative rounded-md shadow-sm">
      <input aria-describedby="admin_email_hint" autocomplete="email"
        class="w-full block rounded-md border-0 shadow-sm py-1.5 ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 text-slate-900 ring-slate-300 placeholder:text-slate-400 focus:ring-slate-500"
        type="email" name="admin[email]" id="admin_email">

    </div>

    <div skip_default_ids="false" allow_method_names_outside_object="true" id="admin_email_hint"
      object="#<Admin:0x0000000170f4ac88>" class="mt-1 text-sm text-gray-500">What is the email you signed up with?
    </div>

  </div>
</fieldset>

It needs some more work (I have no idea, why it outputs an Admin instance), but I think there is some potential.