Integrate ActiveModel::Validations, ActionView, and Browser-provided Constraint Validation API
Currently testing against rails@main
or rails >= 6.2.0.alpha
The current Action View default configurations for <form>
element construction
don't create accessible forms and fields.
Some of this work explores some possible extensions to Action View that could improve Rails' baked in accessibility.
In addition to building more accessible forms and fields, the Action View extensions introduce some new concepts and patterns to improve the developer experience around rendering Active Model validations in server-generated HTML.
There are also complementary client-side patterns introduced to integrate with
the Browser-provided Constraint Validations API (you know, that thing that every
Rails app on the planet opts-out of by declaring [novalidate]
attributes).
The ConstraintValidations::FormBuilder
declares several new methods:
Captures a block for rendering both server- and client-side validation messages
The block accepts two arguments: errors
and tag
. The errors
argument is an
Array of message Strings generated by an ActiveModel::Errors
instance. The
tag
argument is an ActionView::Helpers::TagHelpers::TagBuilder
instance
prepared to render with an id
attribute generated by a call to
validation_message_id
.
The resulting block will be evaluated by subsequent calls to
validation_message
and will serve as a template for client-side
Constraint Validation message rendering.
<%= form.validation_message_template do |messages, tag| %>
<%= tag.span messages.to_sentence, style: "color: red;" %>
<% end %>
<%# => <template data-validation-message-template> %>
<%# <span style="color: red;"></span> %>
<%# </template> %>
<%= form.validation_message :subject %>
<%# => <span style="color: red;">can't be blank</span> %>
When the form's model is invalid, validation_message
renders HTML that's
generated by iterating over a field's errors and passing them as parameters to
the block captured by the form's call to validation_message_template
. The
resulting element's id
attribute will be generated by validation_message_id
to be referenced by field elements' aria-describedby
attributes.
One-off overrides to the form's validation_message_template
can be made by
passing a block to validation_message
.
<%= form.validation_message :subject %>
<%# => <span id="subject_validation_message">can't be blank</span> %>
<% form.validation_message :subject do |errors, tag| %>
<%= tag.span errors.to_sentence, class: "special-error" %>
<% end %>
<%# => <span id="subject_validation_message" class="special-error">can't be blank</span> %>
Delegates to the FormBuilder#object
property when possible, and returns any
error messages for the field
argument. When passed a block, #errors
will
yield the error messages as the block's first parameter
<span><%= form.errors(:subject).to_sentence %></span>
<% form.errors(:subject) do |messages| %>
<h2><%= pluralize(messages.count, "errors") %></h2>
<ul>
<% messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
When the form's model is invalid, validation_message_id
generates and returns
a DOM id attribute for the field, otherwise returns nil
<%= form.text_field :subject, aria: {describedby: form.validation_message_id(:subject)} %>
The constraint_validations
engine provides a small subset of default mappings
from Active Model validation messages to ValidityState
keys.
For example, fields that are invalid due to valueMissing validations will
render messages for the corresponding Active Model blank
message.
Similarly, fields that are invalid due to the more general badInput
validations will render messages for the general purpose Active Model invalid
message.
To override these messages, there are two keys in the
config.constraint_validations
configuration values that are callable. They
are expected to return Hash
that map ValidityState keys to String
messages.
Invoked when rendering fields with form_with model: ...
or fields model:
calls:
config.constraint_validations.validation_messages_for_object = -> (object:, method_name:) {
{
badInput: object.errors.generate_message(method_name, :invalid),
valueMissing: object.errors.generate_message(method_name, :blank)
}
}
Invoked when rendering fields with form_with scope: ...
, or fields scope:
,
or Action View form helpers calls:
config.constraint_validations.validation_messages_for_object_name = -> () {
{
badInput: I18n.translate(:invalid, scope: "errors.messages"),
valueMissing: I18n.translate(:blank, scope: "errors.messages")
}
}
Consider the following model and controller classes for a hypothetical
Message
:
# app/models/message.rb
class Message < ApplicationRecord
validates :content, length: {maximum: 280}
validates :subject, presence: true, exclusion: {in: %w[forbidden]}
end
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def new
@message = Message.new
end
def create
@message = Message.new(params.require(:message).permit(:subject, :contents))
if @message.valid?
redirect_back or_to: root_url
else
render :new, status: :unprocessable_entity
end
end
end
To integrate with Constraint Validations, make sure to call
form.validation_message_template
and form.validation_message
for each field:
<%# app/views/messages/new.html.erb %>
<%= form_with model: message do |form| %>
<%= form.validation_message_template do |messages, tag| %>
<%= tag.span messages.to_sentence, style: "color: red;" %>
<% end %>
<%= form.label :subject %>
<%= form.text_field :subject %>
<%= form.validation_message :subject %>
<%= form.label :content %>
<%= form.text_area :content %>
<%= form.validation_message :content %>
<%= form.button %>
<% end %>
Add this line to your application's Gemfile
:
gem 'constraint_validations'
And then execute:
$ bundle
By default, the engine will set the
default_form_builder
to
ConstraintValidations::FormBuilder
. If your application is already using
another form builder class, you can extend it by mixing-in the
ConstraintValidations::FormBuilder::Extensions
module.
Next, make JavaScript available to the Asset Pipeline by requiring the library
in your application.js
:
+//= require constraint_validations
//= require_tree .
//= require_self
If your application manages its JavaScript dependencies through import maps,
pin the dependency to constraint_validations.es.js
:
pin "constraint_validations", to: "constraint_validations.es.js"
The next step depends on your application's JavaScript infrastructure.
If you're not depending on any frameworks or other tooling, listening for the
DOMContentLoaded event is the most straightforward way to wire-up
ConstraintValidations
:
addEventListener("DOMContentLoaded", () => {
ConstraintValidations.connect(document)
})
If your application is built with Turbo or Turbolinks, attach an event listener for the turbo:load or turbolinks:load events, respectively:
addEventListener("turbo:load", () => {
ConstraintValidations.connect(document)
})
If your application uses Stimulus, declare a controller and invoke
ConstraintValidations.connect
within its connect() lifecycle hook and
ConstraintValidations.disconnect
within its disconnect() lifecycle hook:
import { Controller } from "@hotwired/stimulus"
import ConstraintValidations from "@seanpdoyle/constraint_validations"
export default class extends Controller {
initialize() {
this.validations = new ConstraintValidations(this.element)
}
connect() {
this.validations.connect()
}
disconnect() {
this.validations.disconnect()
}
}
If you've called connect()
on a <form>
element's ancestor and you'd like to
opt-out of the validation behavior on the <form>
, be sure to declare the
novalidate attribute on the <form>
.
By default, fields will validate (and re-validate) on input and blur events.
To change the events that will trigger validation, pass along a
validatesOn:
option to either the ConstraintValidations
constructor, or to the ConstraintValidations.connect
static method:
const element = ...
const eventNames = ["blur", "input", "my-custom-event"]
new ConstraintValidations(element, { validatesOn: eventNames })
ConstraintValidations.connect(element, { validatesOn: eventNames })
The value of disableSubmitWhenInvalid:
can be a boolean, or a function that
accepts an Element (e.g. document
, or a reference to an HTMLFormElement
instance) and returns a boolean. By default, { disableSubmitWhenInvalid: false }
.
To disable a <form>
element's [type="submit]
elements, pass along a
disableSubmitWhenInvalid:
option to either the ConstraintValidations
constructor, or to the ConstraintValidations.connect
static method:
// configure with the constructor
const validations = new ConstraintValidations(element, {
disableSubmitWhenInvalid: true
})
// configure with the static helper method
ConstraintValidations.connect(element, {
disableSubmitWhenInvalid: true
})
// configure with a function that accepts a form field element
ConstraintValidations.connect(element, {
disableSubmitWhenInvalid: (field) => field.type == "checkbox"
})
While <input type="checkbox">
elements do support built-in Constraint
Validations like ValidityState.valueMissing,
most of the ValidityState
properties will always be
false
. The Constraint Validations API determines the
form control's ValidityState.valueMissing
property from its required
attribute.
When a form requires that a single <input type="checkbox">
choice (like an
acknowledgement of terms) is checked, the built-in support works well
enough. When a form requires that at least one checkbox in a group of
checkboxes is checked
, the built-in support can be more strict than expected.
For example, if there were multiple <input type="checkbox">
elements with the
same [name]
attribute, and each element had the [required]
attribute, they
would all need to be checked to be considered valid.
ConstraintValidations
-powered validations support an experimental checkbox:
validator option to validate <input type="checkbox">
elements that share the
same [name]
attribute as a group. To opt-into support, configure the
ConstraintValidations
instance:
// configure with the constructor
const validations = new ConstraintValidations(element, {
validators: {
checkbox: true
}
})
// configure with the static helper method
ConstraintValidations.connect(element, {
validators: {
checkbox: true
}
})
// configure with a function that accepts a form field element
ConstraintValidations.connect(element, {
validators: {
checkbox: (fields) => fields.some(field => field.name === "special[field]")
}
})
Then, render a group of <input type="checkbox">
elements as [required]
:
<fieldset>
<legend>Multiple [required] checkboxes</legend>
<%= form.validation_message :multiple_required_checkboxes %>
<%= form.collection_check_boxes :multiple_required_checkboxes, [
["1", "Multiple required checkbox #1"],
["2", "Multiple required checkbox #2"]
], :first, :second do |builder| %>
<%= builder.check_box required: true %>
<%= builder.label %>
<% end %>
</fieldset>
Disabled form controls won't be validated.
To work-around the quirks of built-in support, ConstraintValidations
monitors
when <input type="checkbox" required>
elements are connected to the document.
Once connected, ConstraintValidations
removes their [required]
attribute,
then replaces it with an [aria-required="true"]
attribute instead. During
form control validation, it utilizes the [aria-required="true"]
attributes to
determine whether or not the collective group meets the
ValidityState.valueMissing
criteria.
This technique integrates with other built-in mechanisms like:
- matching the
[aria-invalid="true"]
CSS selector - matching the :valid CSS selector when valid
- matching the :user-valid when valid
- matching the :user-invalid when invalid
However, its deviates from other built-in mechanism. For example:
- checkboxes will not match the :required CSS selector
- checkboxes will always match the :optional CSS selector
To test this out on your own, clone the repository and execute:
bundle install
bin/rails test test/**/*_test.rb
Read the CONTRIBUTING.md guidelines to learn how to make contributions.
The gem is available as open source under the terms of the MIT License.