Abstract Validation Message rendering 
Declare ValidationMessageFormBuilder#validation_message to combine all
of the newly introduced FormBuilder concepts:
For fields without any errors , do nothing and render nothing.
When a field has any errors for the attribute, create an instance of
ActionView::Helpers::TagHelper#tag that is pre-built with the correct
DOM [id] attribute, generated by validation_message_id.
When passed block, yield the collection of ActiveModel::Error
instances along with the pre-built tag. The calling block is
responsible for building an element to contain the validation messages.
When a block is not given , render a <span> element with the
pre-built tag instance.
In either case, pass along any attributes passed as part of the options.
         
          
              
  
    Collapse app/views/authentications/new.html.erb 
  Expand app/views/authentications/new.html.erb 
  
      app/views/authentications/new.html.erb
    
   
diff --git a/app/views/authentications/new.html.erb b/app/views/authentications/new.html.erb
index 8762274..6cc430b 100644
 --- a/app/views/authentications/new.html.erb
 +++ b/app/views/authentications/new.html.erb
 @@ -1,28 +1,22 @@ 
 <%= form_with(model: authentication) do |form| %>
-  <% form.errors(:base) do |errors| %>
-    <% if errors.any? %>
-      <%= tag.span errors.to_sentence, aria: { live: "assertive" } %>
-    <% end %>
 +  <%= form.validation_message :base, aria: { live: "assertive" } do |errors, tag| %>
+    <%= tag.p errors.to_sentence %>
    <% end %>
 
-  <% form.errors(:username) do |errors| %>
-    <%= form.label :username %>
-    <%= form.text_field :username, aria: {
-      describedby: form.validation_message_id(:username),
-    } %>
-    <% if errors.any? %>
-      <%= tag.span errors.to_sentence, id: form.validation_message_id(:username) %>
-    <% end %>
 +  <%= form.label :username %>
+  <%= form.text_field :username, aria: {
+    describedby: form.validation_message_id(:username),
+  } %>
+  <%= form.validation_message :username do |errors, tag| %>
+    <%= tag.span errors.to_sentence %>
    <% end %>
 
-  <% form.errors(:password) do |errors| %>
-    <%= form.label :password %>
-    <%= form.password_field :password, aria: {
-      describedby: form.validation_message_id(:password),
-    } %>
-    <% if errors.any? %>
-      <%= tag.span errors.to_sentence, id: form.validation_message_id(:password) %>
-    <% end %>
 +  <%= form.label :password %>
+  <%= form.password_field :password, aria: {
+    describedby: form.validation_message_id(:password),
+  } %>
+  <%= form.validation_message :password do |errors, tag| %>
+    <%= tag.span errors.to_sentence %>
    <% end %>
 
   <%= form.button %>
 
              
  
    Collapse app/views/messages/index.html.erb 
  Expand app/views/messages/index.html.erb 
  
      app/views/messages/index.html.erb
    
   
diff --git a/app/views/messages/index.html.erb b/app/views/messages/index.html.erb
index ff163b9..16ac38b 100644
 --- a/app/views/messages/index.html.erb
 +++ b/app/views/messages/index.html.erb
 @@ -6,14 +6,12 @@ 
 
 <% if Current.user %>
   <%= form_with model: message do |form| %>
-    <% form.errors(:content) do |errors| %>
-      <%= form.label :content %>
-      <%= form.rich_text_area :content, aria: {
-        describedby: form.validation_message_id(:content),
-      } %>
-      <% if errors.any? %>
-        <%= tag.span errors.to_sentence, id: form.validation_message_id(:content) %>
-      <% end %>
 +    <%= form.label :content %>
+    <%= form.rich_text_area :content, aria: {
+      describedby: form.validation_message_id(:content),
+    } %>
+    <%= form.validation_message :content do |errors, tag| %>
+      <%= tag.span errors.to_sentence %>
      <% end %>
 
     <%= form.button %>
 
              
  
    Collapse config/application.rb 
  Expand config/application.rb 
  
      config/application.rb
    
   
diff --git a/config/application.rb b/config/application.rb
index eed0811..e7e125c 100644
 --- a/config/application.rb
 +++ b/config/application.rb
 @@ -15,5 +15,6 @@  module ConstraintValidationExample
     # Application configuration can go into files in config/initializers
     # -- all .rb files in that directory are automatically loaded after loading
     # the framework and any gems in your application.
+    config.action_view.field_error_proc = proc { |html_tag| html_tag }
    end
 end
 
              
  
    Collapse lib/validation_message_form_builder.rb 
  Expand lib/validation_message_form_builder.rb 
  
      lib/validation_message_form_builder.rb
    
   
diff --git a/lib/validation_message_form_builder.rb b/lib/validation_message_form_builder.rb
index 9d3def8..d89a837 100644
 --- a/lib/validation_message_form_builder.rb
 +++ b/lib/validation_message_form_builder.rb
 @@ -1,4 +1,20 @@ 
 class ValidationMessageFormBuilder < ActionView::Helpers::FormBuilder
+  def validation_message(field, **attributes, &block)
+    errors field do |messages|
+      id = validation_message_id(field)
+
+      if messages.any?
+        @template.tag.with_options id: id, **attributes do |tag|
+          if block_given?
+            yield messages, tag
+          else
+            tag.span(messages.to_sentence)
+          end
+        end
+      end
+    end
+  end
+
    def validation_message_id(field)
     if errors(field).any?
       field_id(field, :validation_message)
 
              
  
    Collapse test/controllers/authentications_controller_test.rb 
  Expand test/controllers/authentications_controller_test.rb 
  
      test/controllers/authentications_controller_test.rb
    
   
diff --git a/test/controllers/authentications_controller_test.rb b/test/controllers/authentications_controller_test.rb
index 659e9bf..04e9c2d 100644
 --- a/test/controllers/authentications_controller_test.rb
 +++ b/test/controllers/authentications_controller_test.rb
 @@ -8,6 +8,14 @@  class AuthenticationsControllerTest < ActionDispatch::IntegrationTest
     assert_redirected_to messages_url
   end
 
+  test "#new renders a fresh Authentication form" do
+    get new_authentication_path
+
+    assert_response :success
+    assert_select "input:not([aria-invalid]):not([aria-describedby])#authentication_username ~ span", count: 0
+    assert_select "input:not([aria-invalid]):not([aria-describedby])#authentication_password ~ span", count: 0
+  end
+
    test "#create with a valid username/password pairing writes to the session" do
     alice = users(:alice)
 
@@ -30,9 +38,9 @@  class AuthenticationsControllerTest < ActionDispatch::IntegrationTest
     assert_redirected_to(new_authentication_url).then { follow_redirect! }
     assert_flash_params({ authentication: { username: "" } })
     assert_nil session[:user_id]
-    assert_select "#authentication_username_validation_message", text: "can't be blank"
 +    assert_select "input#authentication_username ~ #authentication_username_validation_message", text: "can't be blank"
      assert_select %(input[type="text"][aria-invalid="true"][aria-describedby~="authentication_username_validation_message"])
-    assert_select "#authentication_password_validation_message", text: "can't be blank"
 +    assert_select "input#authentication_password ~ #authentication_password_validation_message", text: "can't be blank"
      assert_select %(input[type="password"][aria-invalid="true"][aria-describedby~="authentication_password_validation_message"])
   end
 
@@ -49,7 +57,7 @@  class AuthenticationsControllerTest < ActionDispatch::IntegrationTest
     assert_redirected_to(new_authentication_url).then { follow_redirect! }
     assert_flash_params({ authentication: { username: alice.username } })
     assert_nil session[:user_id]
-    assert_select %(span[aria-live="assertive"]), text: "is invalid"
 +    assert_select %(p[aria-live="assertive"][id="authentication_base_validation_message"]), text: "is invalid", count: 1
      assert_select %(input[type="text"][value="#{alice.username}"])
     assert_select %(input[type="password"]:not([value]))
   end
 
              
  
    Collapse test/controllers/messages_controller_test.rb 
  Expand test/controllers/messages_controller_test.rb 
  
      test/controllers/messages_controller_test.rb
    
   
diff --git a/test/controllers/messages_controller_test.rb b/test/controllers/messages_controller_test.rb
index 7b91666..f63e917 100644
 --- a/test/controllers/messages_controller_test.rb
 +++ b/test/controllers/messages_controller_test.rb
 @@ -41,7 +41,8 @@  class MessagesControllerTest < ActionDispatch::IntegrationTest
     get messages_path
 
     assert_response :success
-    assert_select "#message_content_validation_message", count: 0
 +    assert_select "trix-editor#message_content ~ #message_content_validation_message", count: 0
+    assert_select "trix-editor#message_content ~ span", count: 0
      assert_select %{trix-editor:not([aria-invalid="true"]):not([aria-describedby])}
   end
 
@@ -52,7 +53,7 @@  class MessagesControllerTest < ActionDispatch::IntegrationTest
     }
 
     assert_redirected_to(messages_url).then { follow_redirect! }
-    assert_select "#message_content_validation_message", "can't be blank"
 +    assert_select "trix-editor#message_content ~ #message_content_validation_message", "can't be blank"
      assert_select %{trix-editor[aria-invalid="true"][aria-describedby~="message_content_validation_message"]}
   end
 end