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 endTo 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:
$ bundleBy 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_selfIf 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 validateOn: 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, { validateOn: eventNames }) ConstraintValidations.connect(element, { validateOn: 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.rbRead the CONTRIBUTING.md guidelines to learn how to make contributions.
The gem is available as open source under the terms of the MIT License.