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