Skip to content

Commit

Permalink
Declare ActionView::Helpers::FormBuilder#id
Browse files Browse the repository at this point in the history
`ActionView::Helpers::FormBuilder#id`
---

Generate an HTML `id` attribute value.

Return the [`<form>` element's][mdn-form] `id` attribute.

```html+erb
<%= form_for @post do |f| %>
  <%# ... %>

  <% content_for :sticky_footer do %>
    <%= form.button(form: f.id) %>
  <% end %>
<% end %>
```

In the example above, the `:sticky_footer` content area will exist
outside of the `<form>` element. [By declaring the `form` HTML
attribute][mdn-button-attr-form], we hint to the browser that the
generated `<button>` element should be treated as the `<form>` element's
submit button, regardless of where it exists in the DOM.

[A similar pattern could be used for `<input>`
elements][mdn-input-attr-form] (or other form controls) that do not
descend from the `<form>` element.

`ActionView::Helpers::FormBuilder#field_id`
---

Generate an HTML <tt>id</tt> attribute value for the given field

Return the value generated by the <tt>FormBuilder</tt> for the given
attribute name.

```html+erb
<%= form_for @post do |f| %>
  <%= f.label :title %>
  <%= f.text_field :title, aria: { describedby: form.field_id(:title, :error) } %>
  <span id="<%= f.field_id(:title, :error) %>">is blank</span>
<% end %>
```

In the example above, the <tt><input type="text"></tt> element built by
the call to <tt>FormBuilder#text_field</tt> declares an
<tt>aria-describedby</tt> attribute referencing the <tt><span></tt>
element, sharing a common <tt>id</tt> root (<tt>post_title</tt>, in this
case).

This method is powered by the `field_id` helper declared in
`action_view/helpers/form_tag_helper`, which is made available for
general template calls, separate from a `FormBuilder` instance.

[mdn-form]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form
[mdn-button-attr-form]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-form
[mdn-input-attr-form]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-form
[mdn-aria-describedby]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-describedby_attribute
[w3c-wai]: https://www.w3.org/WAI/tutorials/forms/notifications/#listing-errors
  • Loading branch information
seanpdoyle committed Nov 30, 2020
1 parent 89414f5 commit 59ca21c
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 14 deletions.
28 changes: 27 additions & 1 deletion actionview/CHANGELOG.md
@@ -1,4 +1,30 @@
* Transforms a Hash into HTML Attributes, ready to be interpolated into ERB.
* `ActionView::Helpers::FormBuilder#id` returns the value
of the `<form>` element's `id` attribute. With a `method` argument, returns
the `id` attribute for a form field with that name.

<%= form_for @post do |f| %>
<%# ... %>

<% content_for :sticky_footer do %>
<%= form.button(form: f.id) %>
<% end %>
<% end %>

*Sean Doyle*

* `ActionView::Helpers::FormBuilder#field_id` returns the value generated by
the FormBuilder for the given attribute name.

<%= form_for @post do |f| %>
<%= f.label :title %>
<%= f.text_field :title, aria: { describedby: f.field_id(:title, :error) } %>
<%= tag.span("is blank", id: f.field_id(:title, :error) %>
<% end %>

*Sean Doyle*

* Add `tag.attributes` to transform a Hash into HTML Attributes, ready to be
interpolated into ERB.

<input <%= tag.attributes(type: :text, aria: { label: "Search" }) %> >
# => <input type="text" aria-label="Search">
Expand Down
41 changes: 41 additions & 0 deletions actionview/lib/action_view/helpers/form_helper.rb
Expand Up @@ -1691,6 +1691,47 @@ def initialize(object_name, object, template, options)
@index = options[:index] || options[:child_index]
end

# Generate an HTML <tt>id</tt> attribute value.
#
# return the <tt><form></tt> element's <tt>id</tt> attribute.
#
# <%= form_for @post do |f| %>
# <%# ... %>
#
# <% content_for :sticky_footer do %>
# <%= form.button(form: f.id) %>
# <% end %>
# <% end %>
#
# In the example above, the <tt>:sticky_footer</tt> content area will
# exist outside of the <tt><form></tt> element. By declaring the
# <tt>form</tt> HTML attribute, we hint to the browser that the generated
# <tt><button></tt> element should be treated as the <tt><form></tt>
# element's submit button, regardless of where it exists in the DOM.
def id
options.dig(:html, :id)
end

# Generate an HTML <tt>id</tt> attribute value for the given field
#
# Return the value generated by the <tt>FormBuilder</tt> for the given
# attribute name.
#
# <%= form_for @post do |f| %>
# <%= f.label :title %>
# <%= f.text_field :title, aria: { describedby: f.field_id(:title, :error) } %>
# <%= tag.span("is blank", id: f.field_id(:title, :error) %>
# <% end %>
#
# In the example above, the <tt><input type="text"></tt> element built by
# the call to <tt>FormBuilder#text_field</tt> declares an
# <tt>aria-describedby</tt> attribute referencing the <tt><span></tt>
# element, sharing a common <tt>id</tt> root (<tt>post_title</tt>, in this
# case).
def field_id(method, *suffixes, index: @index)
@template.field_id(@object || @object_name, method, *suffixes, index: @index)
end

##
# :method: text_field
#
Expand Down
36 changes: 36 additions & 0 deletions actionview/lib/action_view/helpers/form_tag_helper.rb
Expand Up @@ -79,6 +79,42 @@ def form_tag(url_for_options = {}, options = {}, &block)
end
end

# Generate an HTML <tt>id</tt> attribute value for the given name and
# field combination
#
# Return the value generated by the <tt>FormBuilder</tt> for the given
# attribute name.
#
# <%= label_tag :post, :title %>
# <%= text_field_tag :post, :title, aria: { describedby: field_id(:post, :title, :error) } %>
# <%= tag.span("is blank", id: field_id(:post, :title, :error) %>
#
# In the example above, the <tt><input type="text"></tt> element built by
# the call to <tt>text_field_tag</tt> declares an
# <tt>aria-describedby</tt> attribute referencing the <tt><span></tt>
# element, sharing a common <tt>id</tt> root (<tt>post_title</tt>, in this
# case).
def field_id(object_name, method_name, *suffixes, index: nil)
if object_name.respond_to?(:model_name)
object_name = object_name.model_name.singular
end

sanitized_object_name = object_name.to_s.gsub(/\]\[|[^-a-zA-Z0-9:.]/, "_").delete_suffix("_")

sanitized_method_name = method_name.to_s.delete_suffix("?")

# a little duplication to construct fewer strings
if sanitized_object_name.empty?
sanitized_method_name.dup
elsif suffixes.any?
[sanitized_object_name, index, sanitized_method_name, *suffixes].compact.join("_")
elsif index
"#{sanitized_object_name}_#{index}_#{sanitized_method_name}"
else
"#{sanitized_object_name}_#{sanitized_method_name}"
end
end

# Creates a dropdown selection box, or if the <tt>:multiple</tt> option is set to true, a multiple
# choice selection box.
#
Expand Down
14 changes: 1 addition & 13 deletions actionview/lib/action_view/helpers/tags/base.rb
Expand Up @@ -117,19 +117,7 @@ def tag_name(multiple = false, index = nil)
end

def tag_id(index = nil)
# a little duplication to construct fewer strings
case
when @object_name.empty?
sanitized_method_name.dup
when index
"#{sanitized_object_name}_#{index}_#{sanitized_method_name}"
else
"#{sanitized_object_name}_#{sanitized_method_name}"
end
end

def sanitized_object_name
@sanitized_object_name ||= @object_name.gsub(/\]\[|[^-a-zA-Z0-9:.]/, "_").delete_suffix("_")
@template_object.field_id(@object_name, @method_name, index: index)
end

def sanitized_method_name
Expand Down
54 changes: 54 additions & 0 deletions actionview/test/template/form_helper_test.rb
Expand Up @@ -1597,6 +1597,60 @@ def test_form_for_is_not_affected_by_form_with_generates_ids
ActionView::Helpers::FormHelper.form_with_generates_ids = old_value
end

def test_form_for_id
form_for(Post.new) do |form|
concat form.button(form: form.id)
end

expected = whole_form("/posts", "new_post", "new_post") do
'<button name="button" type="submit" form="new_post">Create Post</button>'
end

assert_dom_equal expected, output_buffer
end

def test_field_id_with_model
value = field_id(Post.new, :title)

assert_equal "post_title", value
end

def test_field_id_with_predicate_method
value = field_id(Post.new, :secret?)

assert_equal "post_secret", value
end

def test_form_for_field_id
form_for(Post.new) do |form|
concat form.label(:title)
concat form.text_field(:title, aria: { describedby: form.field_id(:title, :error) })
concat tag.span("is blank", id: form.field_id(:title, :error))
end

expected = whole_form("/posts", "new_post", "new_post") do
'<label for="post_title">Title</label>' \
'<input id="post_title" name="post[title]" type="text" aria-describedby="post_title_error">' \
'<span id="post_title_error">is blank</span>'
end

assert_dom_equal expected, output_buffer
end

def test_form_for_field_id_with_index
form_for(Post.new, index: 1) do |form|
concat form.text_field(:title, aria: { describedby: form.field_id(:title, :error) })
concat tag.span("is blank", id: form.field_id(:title, :error))
end

expected = whole_form("/posts", "new_post", "new_post") do
'<input id="post_1_title" name="post[1][title]" type="text" aria-describedby="post_1_title_error">' \
'<span id="post_1_title_error">is blank</span>'
end

assert_dom_equal expected, output_buffer
end

def test_form_for_with_collection_radio_buttons
post = Post.new
def post.active; false; end
Expand Down
24 changes: 24 additions & 0 deletions actionview/test/template/form_tag_helper_test.rb
Expand Up @@ -187,6 +187,30 @@ def test_form_tag_with_block_and_method_in_erb
assert_dom_equal expected, output_buffer
end

def test_field_id_without_suffixes_or_index
value = field_id(:post, :title)

assert_equal "post_title", value
end

def test_field_id_with_suffixes
value = field_id(:post, :title, :error)

assert_equal "post_title_error", value
end

def test_field_id_with_suffixes_and_index
value = field_id(:post, :title, :error, index: 1)

assert_equal "post_1_title_error", value
end

def test_field_id_with_nested_object_name
value = field_id("post[author]", :name)

assert_equal "post_author_name", value
end

def test_hidden_field_tag
actual = hidden_field_tag "id", 3
expected = %(<input id="id" name="id" type="hidden" value="3" />)
Expand Down

0 comments on commit 59ca21c

Please sign in to comment.