Constraint Validations

Integrate ActiveModel::Validations, ActionView, and Browser-provided Constraint Validation API

Currently testing against rails@main or rails >= 6.2.0.alpha

ActionView and Accessibility

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.

ActionView and the Constraint Validations API

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).

Usage

The ConstraintValidations::FormBuilder declares several new methods:

ConstraintValidations::FormBuilder#validation_message_template(&block)

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> %>

ConstraintValidations::FormBuilder#validation_message(field, **attributes, &block)

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> %>

ConstraintValidations::FormBuilder#errors(field, &block)

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 %>

ConstraintValidations::FormBuilder#validation_message_id(field)

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)} %>

Configuring client-side validation messages

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.

config.constraint_validations.validation_messages_for_object

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)
  }
}

config.constraint_validations.validation_messages_for_object_name

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")
  }
}

Examples

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 %>

Ruby Installation

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.

JavaScript Installion

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>.

Validate on events

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 })

Disabling submit buttons when invalid

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"
})

Experimental: Validating input[type="checkbox"][required] as a group

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.

How it works

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

Testing it out locally

To test this out on your own, clone the repository and execute:

bundle install
bin/rails test test/**/*_test.rb

Contributing

Read the CONTRIBUTING.md guidelines to learn how to make contributions.

License

The gem is available as open source under the terms of the MIT License.