Integrate with Browser Constraint Validation API
Progressively enhance forms with real-time validation message reporting
through the Constraint validation API .
First, render form elements with declarative validation
attributes that match their
ActiveModel::Validation
counterparts .
When rendering a form, declare a <template>
element by calling
#validation_message_template
with a block. Render the <template>
element with the contents of an “empty” call of that block. Assign the
block as a FormBuilder-local instance variable, and use it to construct
elements in subsequent calls to #validation_message
. By ensuring that
both server-side and client-side validation messages are rendered with
the same HTML, we can unify our error reporting.
invalid
events
Next, declare listeners to intercept invalid
events .
Unfortunately, the InvalidEvent
does not bubble , so these need to be
declared directly on the fields themselves.
In order to listen for dispatched InvalidEvent
without relying on
bubbling mechanics, the document
-level addEventListener
call must
pass a third parameter that specifies { capture: true
}
. Additionally, since the listener can
invoke Event.preventDefault
, also specify { passive: false }
.
When handling an invalid
event, find an existent validation message
element or create a new one from the form’s <template>
. Replace the
element’s innerHTML
with the validation message, and ensure that the
references to the corresponding element (like [aria-invalid="true"]
,
[aria-describedby]
, [id]
, etc.) are intact.
Finally, re-validate through a call to reportValidity()
when the input
loses focus.
Testing
Unfortunately, the in-Browser validation messages are not synchronized
with out application’s validation messages. For example, what would be a
server-side blank
validation message (i.e. can't be blank
) is
reported by the browser as Please fill out this field
.
To account for that variation, this commit updates the System tests to
assert the Browser generated message.
Collapse Gemfile
Expand Gemfile
Gemfile
diff --git a/Gemfile b/Gemfile
index b6451e0..ccba5e3 100644
--- a/Gemfile
+++ b/Gemfile
@@ -30,6 +30,7 @@ gem 'bcrypt', '~> 3.1.7'
gem 'bootsnap', '>= 1.4.4', require: false
gem 'activerecord-session_store'
+gem 'html5_validators'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
Collapse Gemfile.lock
Expand Gemfile.lock
Gemfile.lock
diff --git a/Gemfile.lock b/Gemfile.lock
index 0e4ad44..be047b1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -143,6 +143,8 @@ GEM
ffi (1.13.1)
globalid (0.4.2)
activesupport (>= 4.2.0)
+ html5_validators (1.9.0)
+ activemodel
i18n (1.8.5)
concurrent-ruby (~> 1.0)
jbuilder (2.10.1)
@@ -238,6 +240,7 @@ DEPENDENCIES
byebug
capybara (>= 3.26)
capybara_accessible_selectors!
+ html5_validators
jbuilder (~> 2.7)
listen (~> 3.2)
pg (~> 1.1)
Collapse app/javascript/initializers/index.js
Expand app/javascript/initializers/index.js
app/javascript/initializers/index.js
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
new file mode 100644
index 0000000..7cde6a0
--- /dev/null
+++ b/app/javascript/initializers/index.js
@@ -0,0 +1,2 @@
+const context = require.context(".", true, /\.(js|ts)$/)
+context.keys().forEach(context)
Collapse app/javascript/initializers/validations.ts
Expand app/javascript/initializers/validations.ts
app/javascript/initializers/validations.ts
diff --git a/app/javascript/initializers/validations.ts b/app/javascript/initializers/validations.ts
new file mode 100644
index 0000000..0008f1f
--- /dev/null
+++ b/app/javascript/initializers/validations.ts
@@ -0,0 +1,74 @@
+type FieldElement =
+ HTMLButtonElement |
+ HTMLInputElement |
+ HTMLObjectElement |
+ HTMLOutputElement |
+ HTMLSelectElement |
+ HTMLTextAreaElement
+
+addEventListener("invalid", (event) => {
+ if (isFieldElement(event.target)) {
+ reportValidity(event.target)
+
+ event.preventDefault()
+ }
+}, { capture: true, passive: false })
+
+addEventListener("focusout", ({ target }) => {
+ if (isFieldElement(target)) {
+ clearValidity(target)
+ reportValidity(target)
+
+ target.reportValidity()
+ }
+})
+
+function clearValidity(input: FieldElement): void {
+ input.setCustomValidity("")
+
+ reportValidity(input)
+}
+
+function reportValidity(input: FieldElement): void {
+ if (input.form?.hasAttribute("novalidate")) return
+
+ const id = input.getAttribute("data-validation-message")
+ const validationMessage = input.validationMessage
+ const element = document.getElementById(id) || createValidationMessageFragment(input.form)
+
+ if (element) {
+ element.id = id
+ element.innerHTML = validationMessage
+
+ if (validationMessage) {
+ input.setAttribute("aria-describedby", id)
+ input.setAttribute("aria-invalid", "true")
+ } else {
+ input.removeAttribute("aria-describedby")
+ input.removeAttribute("aria-invalid")
+ }
+
+ if (!element.parentElement) {
+ input.parentElement.append(element)
+ }
+ }
+}
+
+function createValidationMessageFragment(form) {
+ if (form) {
+ const template = form.querySelector("[data-validation-message-template]")
+
+ return template?.content.children[0].cloneNode()
+ }
+}
+
+function isFieldElement(element: any): element is FieldElement {
+ return [
+ HTMLButtonElement,
+ HTMLInputElement,
+ HTMLObjectElement,
+ HTMLOutputElement,
+ HTMLSelectElement,
+ HTMLTextAreaElement,
+ ].some(field => element instanceof field)
+}
Collapse app/javascript/packs/application.js
Expand app/javascript/packs/application.js
app/javascript/packs/application.js
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index 50f0126..b0b096f 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -17,3 +17,5 @@ ActiveStorage.start()
//
// const images = require.context('../images', true)
// const imagePath = (name) => images(name, true)
+
+import "initializers"
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 6cc430b..ac77308 100644
--- a/app/views/authentications/new.html.erb
+++ b/app/views/authentications/new.html.erb
@@ -1,23 +1,27 @@
<%= form_with(model: authentication) do |form| %>
+ <%= form.validation_message_template do |messages, tag| %>
+ <%= tag.span(messages.to_sentence) %>
+ <% end %>
+
<%= form.validation_message :base, aria: { live: "assertive" } do |errors, tag| %>
<%= tag.p errors.to_sentence %>
<% 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 %>
+ <div>
+ <%= form.label :username %>
+ <%= form.text_field :username, aria: {
+ describedby: form.validation_message_id(:username),
+ } %>
+ <%= form.validation_message :username %>
+ </div>
- <%= 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 %>
+ <div>
+ <%= form.label :password %>
+ <%= form.password_field :password, aria: {
+ describedby: form.validation_message_id(:password),
+ } %>
+ <%= form.validation_message :password %>
+ </div>
<%= form.button %>
<% end %>
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 16ac38b..256c69f 100644
--- a/app/views/messages/index.html.erb
+++ b/app/views/messages/index.html.erb
@@ -6,14 +6,18 @@
<% if Current.user %>
<%= form_with model: message do |form| %>
- <%= 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 %>
+ <%= form.validation_message_template do |messages, tag| %>
+ <%= tag.span(messages.to_sentence) %>
<% end %>
+ <div>
+ <%= form.label :content %>
+ <%= form.rich_text_area :content, aria: {
+ describedby: form.validation_message_id(:content),
+ } %>
+ <%= form.validation_message :content %>
+ </div>
+
<%= form.button %>
<% end %>
Collapse lib/rails_ext/action_view.rb
Expand lib/rails_ext/action_view.rb
lib/rails_ext/action_view.rb
diff --git a/lib/rails_ext/action_view.rb b/lib/rails_ext/action_view.rb
index b09d171..aa3a192 100644
--- a/lib/rails_ext/action_view.rb
+++ b/lib/rails_ext/action_view.rb
@@ -18,28 +18,43 @@ module CheckableAriaTagsExtension
end
end
+module ValidationMessageExtension
+ def render
+ index = @options.fetch("index", @auto_index)
+
+ @options["data-validation-message"] ||= tag_id(index) + "_validation_message"
+
+ super
+ end
+end
+
ActiveSupport.on_load :action_view do
module ActionView
module Helpers
module Tags
class ActionText
prepend AriaTagsExtension
+ prepend ValidationMessageExtension
end
class Select
prepend AriaTagsExtension
+ prepend ValidationMessageExtension
end
class TextField
prepend AriaTagsExtension
+ prepend ValidationMessageExtension
end
class TextArea
prepend AriaTagsExtension
+ prepend ValidationMessageExtension
end
[RadioButton, CheckBox].each do |kls|
prepend CheckableAriaTagsExtension
+ prepend ValidationMessageExtension
end
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 d89a837..a6cde42 100644
--- a/lib/validation_message_form_builder.rb
+++ b/lib/validation_message_form_builder.rb
@@ -1,4 +1,18 @@
class ValidationMessageFormBuilder < ActionView::Helpers::FormBuilder
+ def initialize(*)
+ super
+
+ @validation_message_template = proc { |messages, tag| tag.span(messages.to_sentence) }
+ end
+
+ def validation_message_template(&block)
+ @validation_message_template = block
+
+ content = @template.with_output_buffer { @validation_message_template.call([], @template.tag) }
+
+ @template.tag.template content, data: { validation_message_template: true }
+ end
+
def validation_message(field, **attributes, &block)
errors field do |messages|
id = validation_message_id(field)
@@ -8,7 +22,7 @@ class ValidationMessageFormBuilder < ActionView::Helpers::FormBuilder
if block_given?
yield messages, tag
else
- tag.span(messages.to_sentence)
+ @validation_message_template.call(messages, tag)
end
end
end
Collapse test/system/authentications_test.rb
Expand test/system/authentications_test.rb
test/system/authentications_test.rb
diff --git a/test/system/authentications_test.rb b/test/system/authentications_test.rb
index c7aa586..7cd5d98 100644
--- a/test/system/authentications_test.rb
+++ b/test/system/authentications_test.rb
@@ -5,7 +5,18 @@ class AuthenticationsTest < ApplicationSystemTestCase
visit new_authentication_path
click_on submit(:authentication)
- assert_field label(:authentication, :username), described_by: "can't be blank"
- assert_field label(:authentication, :password), described_by: "can't be blank"
+ assert_field label(:authentication, :username), validation_error: "Please fill out this field."
+ assert_field label(:authentication, :password), validation_error: "Please fill out this field."
+ end
+
+ test "invalid inputs re-validate on blur renders errors" do
+ visit new_authentication_path
+ click_on submit(:authentication)
+ fill_in label(:authentication, :username), with: "junk"
+ fill_in label(:authentication, :password), with: ""
+
+ assert_field label(:authentication, :username), valid: true
+ assert_field label(:authentication, :password), validation_error: "Please fill out this field."
+ assert_no_field label(:authentication, :username), validation_error: "Please fill out this field."
end
end