Skip to content

Latest commit

 

History

History
473 lines (357 loc) · 32.7 KB

File metadata and controls

473 lines (357 loc) · 32.7 KB

Form Validity Observer

The FormValidityObserver is an extension of the FormObserver that automatically validates your fields and displays accessible error messages for those fields as users interact with your forms. Additionally, it exposes methods that can be used to handle manual field/form validation and manual error display/removal.

Example

<textarea name="external-textbox" form="my-form" maxlength="150" required aria-describedby="textarea-error"></textarea>
<div id="textarea-error"></div>

<form id="my-form">
  <input name="textbox" type="text" minlength="10" pattern="\d+" required aria-describedby="textbox-error" />
  <div id="textbox-error"></div>

  <input name="checkbox" type="checkbox" required aria-describedby="checkbox-error" />
  <div id="checkbox-error"></div>

  <fieldset role="radiogroup" aria-describedby="radios-error">
    <input name="flavor" type="radio" value="vanilla" required />
    <input name="flavor" type="radio" value="strawberry" />
    <input name="flavor" type="radio" value="chocolate" />
  </fieldset>
  <div id="radios-error"></div>

  <select name="settings" required aria-describedby="combobox-error">
    <option value="">Select an Option</option>
    <option>1</option>
    <option>2</option>
    <option>3</option>
  </select>
  <div id="combobox-error"></div>

  <button type="submit">Submit</button>
</form>
import { FormValidityObserver } from "@form-observer/core";
// or import FormValidityObserver from "@form-observer/core/FormValidityObserver";

// Automatically validate fields that a user leaves.
// When a field that a user has left is invalid, an accessible error message will be displayed for that field.
const observer = new FormValidityObserver("focusout");
const form = document.getElementById("my-form");
observer.observe(form);

// Prevent the browser from creating error bubbles `onsubmit` (optional)
form.setAttribute("novalidate", "");
form.addEventListener("submit", handleSubmit);

function handleSubmit(event) {
  event.preventDefault();
  const success = observer.validateFields({ focus: true });

  if (success) {
    // Submit data to server
  }
}

Features and Benefits

As a child of the FormObserver, the FormValidityObserver inherits the same benefits as its parent class. Besides its great performance, there are 2 benefits of the FormValidityObserver that we want to call attention to in particular:

Consistent API across All Frameworks

Like the Testing Library Family, the FormValidityObserver gives you a simple-yet-powerful API that works with pure JS and with all JS frameworks out of the box. Tired of having to learn a new form validation library every time you try (or abandon) another JS framework? We've got you covered. And for those who are interested, we also provide (optional) convenience wrappers for several popular frameworks. (Don't worry, you'll still be working with the exact same API.)

Minimal and Familiar API

The FormValidityObserver embraces Svelte's philosophy of enhancing the features that browsers provide natively (instead of replacing them or introducing unnecessary complexity). Consequently, you won't have to reach for our API unless you want to. When you do, the code you write will be minimal, and it will feel very similar to what you'd write if you were using the browser's form validation functions.

Want to reuse the browser's native error messages and make them accessible? Simply add the correct HTML validation attributes to your field, add an error element to your markup, and the FormValidityObserver will take care of the rest. (This is what our earlier code example did.) No extra JS is required; there's no need to complicate your code with framework-specific components or functions.

As expected for any form validation library, we also support the following features for both accessible error messages and the browser's native error bubbles:

  • Synchronous and asynchronous custom validation.
  • Custom error messages.
  • Automatic handling of fields that are dynamically added to (or removed from) the DOM. (Say goodbye to complex calls to register and unregister.)
  • Progressive Enhancement: Because the FormValidityObserver enhances browser functionality, your form validation will fallback to the browser's native behavior when JS is unavailable.
  • And much more...

API

Constructor: FormValidityObserver(types, options)

The FormValidityObserver() constructor creates a new observer and configures it with the options that you pass in. Because the FormValidityObserver only focuses on one task, it has a simple constructor with no overloads.

type: EventType | null

A string representing the type of event that should cause a form's field to be validated. As with the FormObserver, the string can be a commonly recognized event type or your own custom event type. But in the case of the FormValidityObserver, only one event type may be specified.

If you only want to validate fields manually, you can specify null instead of an event type. This can be useful, for instance, if you only want to validate your fields onsubmit. (You would still need to call FormValidityObserver.validateFields() manually in your submit handler in that scenario.)

options (Optional)

The options used to configure the FormValidityObserver. The following properties can be provided:

useEventCapturing: boolean
Indicates that the observer's event listener should be called during the event capturing phase instead of the event bubbling phase. Defaults to false. See DOM Event Flow for more details on event phases.
scroller: (fieldOrRadiogroup: ValidatableField) => void
The function used to scroll a field (or radiogroup) that has failed validation into view. Defaults to a function that calls scrollIntoView() on the field (or radiogroup) that failed validation.
revalidateOn: EventType

The type of event that will cause a form field to be revalidated. (Revalidation for a form field is enabled after it is validated at least once -- whether manually or automatically.)

This can be helpful, for example, if you want to validate your fields oninput, but only after the user has visited them. In that case, you could write new FormValidityObserver("focusout", { revalidateOn: "input" }). Similarly, you might only want to validate your fields oninput after your form has been submitted. In that case, you could write new FormValidityObserver(null, { revalidateOn: "input" }).

renderer: (errorContainer: HTMLElement, errorMessage: M | null) => void

The function used to render error messages (typically to the DOM) when a validation constraint's render option is true or when FormValidityObserver.setFieldError() is called with the render=true option. (See the ValidationErrors type for more details about validation constraints.) When a field becomes valid (or when FormValidityObserver.clearFieldError() is called), this function will be called with null. Note that this function will only be called if the field has an accessible error container.

The Message Type, M, is determined from your function definition. The type can be anything (e.g., a string, an object, a ReactElement, or anything else).

The renderer defaults to a function that accepts error messages of type string and renders them to the DOM as raw HTML.

renderByDefault: R extends boolean

Determines the default value for every validation constraint's render option. (Also sets the default value for FormValidityObserver.setFieldError's render option.)

Note: When renderByDefault is true, the renderer function must account for error messages of type string. (The default renderer function already accounts for this.) So, for example, if you wanted your renderer function to support ReactElements, and the renderByDefault option was true, then your renderer's Message Type, M, would need to be ReactElement | string.

defaultErrors: ValidationErrors<M, E, R>

Configures the default error messages to display for the validation constraints. (See the configure method for more details about error message configuration, and refer to the ValidationErrors type for more details about validation constraints.)

Note: The defaultErrors.validate option will provide a default custom validation function for all fields in your form. This is primarily useful if you have a reusable validation function that you want to apply to all of your form's fields (for example, if you are using Zod). See Getting the Most out of the defaultErrors Option for examples on how to use this option effectively.

Example

// Use default `scroller` and `renderer`
const observerWithDefaults = new FormValidityObserver("input");

// Use custom `scroller` and `renderer`
const observer = new FormValidityObserver("focusout", {
  // Scroll field into view WITH its label (if possible)
  scroller(fieldOrRadiogroup) {
    if ("labels" in fieldOrRadiogroup) {
      const [label] = fieldOrRadiogroup.labels as NodeListOf<HTMLLabelElement>;
      return label.scrollIntoView({ behavior: "smooth" });
    }

    fieldOrRadiogroup.scrollIntoView({ behavior: "smooth" });
  },
  // Error messages will be rendered to the DOM as raw DOM Nodes
  renderer(errorContainer: HTMLElement, errorMessage: HTMLElement | null) {
    if (errorMessage === null) return errorContainer.replaceChildren();
    errorContainer.replaceChildren(errorMessage);
  },
});

Method: FormValidityObserver.observe(form: HTMLFormElement): boolean

Instructs the observer to validate any fields (belonging to the provided form) that a user interacts with, and registers the observer's validation methods with the provided form. Automatic field validation will only occur when a field belonging to the form emits an event matching the type that was specified during the observer's construction. Unlike the FormObserver and the FormStorageObserver, the FormValidityObserver may only observe 1 form at a time.

Note that the name attribute is what the observer uses to identify fields during manual form validation and error handling. Therefore, a valid name is required for all validated fields. If a field does not have a name, then it will not participate in form validation. Since the web specification does not allow nameless fields to participate in form submission, this is likely a requirement that your application already satisfies.

If the provided form element was not being watched before observe() was called, the method will run any necessary setup logic and return true. Otherwise, the method does nothing and returns false.

Example

const observer = new FormValidityObserver("input");
const form = document.getElementById("my-form");

observer.observe(form); // Returns `true`, sets up manual validation/error-handling methods
observer.observe(form); // Returns `false`, does nothing

form.elements[0].dispatchEvent(new InputEvent("input", { bubbles: true })); // Field gets validated

Method: FormValidityObserver.unobserve(form: HTMLFormElement): boolean

Instructs the observer to stop watching a form for user interactions. The form's fields will no longer be validated when a user interacts with them, and the observer's manual validation methods will be disabled.

If the provided form element was being watched before unobserve() was called, the method will run any necessary teardown logic and return true. Otherwise, the method does nothing and returns false.

Example

const observer = new FormValidityObserver("change");
const form = document.getElementById("my-form");
observer.unobserve(form); // Returns `false`, does nothing

observer.observe(form);
form.elements[0].dispatchEvent(new Event("change", { bubbles: true })); // Field gets validated

observer.unobserve(form); // Returns `true`, disables manual validation/error-handling methods
form.elements[1].dispatchEvent(new Event("change", { bubbles: true })); // Does nothing, the form is no longer being observed

Method: FormValidityObserver.disconnect(): void

Behaves the same way as unobserve, except that 1) You do not need to provide the currently-observed form as an argument, and 2) the method does not return a value.

Example

const observer = new FormValidityObserver("focusout");
const form = document.getElementById("my-form");

observer.observe(form);
observer.disconnect(); // `unobserve`s the currently-watched form

observer.unobserve(form); // Returns `false` because the form was already `unobserve`d
form1.elements[0].dispatchEvent(new FocusEvent("focusout", { bubbles: true })); // Does nothing

Method: FormValidityObserver.configure<E>(name: string, errorMessages: ValidationErrors<M, E, R>): void

Configures the error messages that will be displayed for a form field's validation constraints. If an error message is not configured for a validation constraint and there is no corresponding default configuration, then the field's validationMessage will be used instead. For native form fields, the browser automatically supplies a default validationMessage depending on the broken constraint.

Note: If the field is only using the configured defaultErrors and/or the browser's default error messages, it does not need to be configured.

The Field Element Type, E, represents the form field being configured. This type is inferred from the errorMessages configuration and defaults to a general ValidatableField.

Parameters

name
The name of the form field.
errorMessages
A key-value pair of validation constraints (key) and their corresponding error messages (value).

Example

<form>
  <label for="credit-card">Credit Card</label>
  <input id="credit-card" name="credit-card" pattern="\d{16}" required aria-describedby="credit-card-error" />
  <div id="credit-card-error" role="alert"></div>

  <!-- Other Form Fields -->
</form>
const observer = new FormValidityObserver("focusout");
const form = document.querySelector("form");
observer.observe(form);

// `configure` a field
observer.configure("credit-card", { pattern: "Card number must be 16 digits" });
const creditCardField = document.querySelector("[name='credit-card']");

// Browser's native error message for `required` fields will be ACCESSIBLY displayed.
creditCardField.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));

// Our custom error message for `pattern` will be ACCESSIBLY displayed,
// _not_ the browser's native error message for the `pattern` attribute.
creditCardField.value = "abcd";
creditCardField.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));

Method: FormValidityObserver.validateFields(options?: ValidateFieldsOptions): boolean | Promise<boolean>

Validates all of the observed form's fields, returning true if all of the validated fields pass validation and false otherwise. The boolean that validateFields() returns will be wrapped in a Promise if any of the validated fields use an asynchronous function for the validate constraint. This promise will resolve after all asynchronous validation functions have settled.

Parameters

validateFields() accepts a single argument: an optional options object. The object supports the following properties:

focus

Indicates that the first field in the DOM that fails validation should be focused and scrolled into view. Defaults to false.

When the focus option is false, you can consider validateFields() to be an enhanced version of form.checkValidity(). When the focus option is true, you can consider validateFields() to be an enhanced version of form.reportValidity().

enableRevalidation

Enables revalidation for all of the form's fields. Defaults to true. (This option is only relevant if a value was provided for the observer's revalidateOn option.)

Note that the enableRevalidation option can prevent field revalidation from being turned on, but it cannot be used to turn off revalidation.

Method: FormValidityObserver.validateField(name: string, options?: ValidateFieldOptions): boolean | Promise<boolean>

Validates the form field with the specified name, returning true if the field passes validation and false otherwise. The boolean that validateField() returns will be wrapped in a Promise if the field's validate constraint runs asynchronously. This promise will resolve after the asynchronous validation function resolves. Unlike the validateFields() method, this promise will also reject if the asynchronous validation function rejects.

Note: Per the HTML spec, any field whose willValidate property is false will automatically pass validation.

Parameters

name
The name of the form field being validated
options (Optional)

An object used to configure the validateField() method. The following properties are supported:

focus

Indicates that the field should be focused and scrolled into view if it fails validation. Defaults to false.

When the focus option is false, you can consider validateField() to be an enhanced version of field.checkValidity(). When the focus option is true, you can consider validateField() to be an enhanced version of field.reportValidity().

enableRevalidation

Enables revalidation for the validated field. Defaults to true. (This option is only relevant if a value was provided for the observer's revalidateOn option.)

Note that the enableRevalidation option can prevent field revalidation from being turned on, but it cannot be used to turn off revalidation.

Method: FormValidityObserver.setFieldError<E>(name: string, message: ErrorMessage<string, E>|ErrorMessage<M, E>, render?: boolean): void

Marks the form field having the specified name as invalid (via the [aria-invalid="true"] attribute) and applies the provided error message to it. Typically, you shouldn't need to call this method manually; but in rare situations it might be helpful.

The Field Element Type, E, represents the invalid form field. This type is inferred from the error message if it is a function. Otherwise, E defaults to a general ValidatableField.

Parameters

name
The name of the invalid form field
message

The error message to apply to the invalid form field. If the field has an accessible error container, then the field's error message will be displayed there. Otherwise, the error will only be displayed when the browser displays its native error bubbles.

render (Optional)

Indicates that the field's error message should be rendered using the observer's renderer function. Defaults to the value of the observer's renderByDefault configuration option.

When the render argument is false, then the error message must be of type string. When render is true, then the error message must be of type M, where M is determined from the observer's renderer function.

Example

// By default, the `renderer` renders strings as raw HTML, and the `renderByDefault` option is `false`
const observer = new FormValidityObserver("change");
const form = document.getElementById("my-form");
observer.observe(form);

// Regular `string` Error Messages
observer.setFieldError("combobox", "There was a problem with this field...");

// Special `rendered` Error Messages
const htmlErrorString = `<ul><li>Field is missing a cool word.</li><li>Also we just don't like the value.</li></ul>`;
observer.setFieldError("textbox", htmlErrorString, true);

Method: FormValidityObserver.clearFieldError(name: string): void

Marks the form field with the specified name as valid (via the [aria-invalid="false"] attribute) and clears its error message.

Typically, you shouldn't need to call this method manually; but in rare situations it might be helpful. For example, if you manually exclude a field from constraint validation by marking it as disabled with JavaScript, then you can use this method to delete the obsolete error message.

Parameters

name
The name of the form field whose error should be cleared.

Restrictions

All frontend tools for forms require you to adhere to certain guidelines in order for the tool to function correctly with your application. Our tool is no different. But instead of introducing you to several tool-specific props, components, or functions to accomplish this goal, we rely on what HTML provides out of the box wherever possible. We do this for two reasons:

  1. If you're writing accessible, progressively enhanced forms, then you'll already be following the guidelines that we require without any additional effort.
  2. This approach results in developers writing less code.

The idea here is to make form validation as quick and easy as possible for those who are already following good web standards, and to encourage good web standards for those who aren't yet leaning into all of the power and accessibility features of the modern web. Here are our 3 unique requirements:

1) Form fields that participate in validation must have a name attribute.

Justification

If your forms are progressively enhanced, you will already be satisfying this requirement. Leveraging the name attribute enables users who lack access to JavaScript to use your forms. Moreover, the name attribute enables many well-known form-related tools to identify fields without causing friction for developers. Given these realities, this restriction seems reasonable to us.

2) Only valid form controls may participate in form field validation.

Justification

Again, if your forms are progressively enhanced, you will already be satisfying this requirement. Using valid form controls is required to enable users who lack access to JavaScript to use your forms. It also enables form validation libraries like this one to leverage the ValidityState interface for form validation, which is great for simplicity and performance.

If you're new to progressive enhancement, then don't worry. It's fairly easy to update your code to satisfy this requirement -- whether it's written with pure JS or with the help of a JS framework.

(Note: For complex form controls, you can create a Web Component that acts like a form control. However, Web Components are not accessible to users who don't have JavaScript; so it is still recommended to have a fallback that functions with just HTML -- though it is not required.)

3) A radio button group will only be validated if it is inside a fieldset element with the radiogroup role.

Justification

If your forms provide accessible radio button groups to your users, you will likely already be satisfying this requirement. (At most, you will only need to add role="radiogroup" to a few fieldsets.) We believe this requirement improves accessibility for end users by distinguishing radiogroups from general groups. It also provides a clear way for the FormValidityObserver to identify radio button groups without sacrificing the developer experience. (If you want deeper insight into why we made this decision, see Why Are Radio Buttons Only Validated When They're inside a fieldset with Role radiogroup?.)

What about aria-errormessage?

If you're familiar with the aria-errormessage attribute, then you'll know that it is technically "better" than the aria-describedby attribute when it comes to conveying error messages for invalid form fields. Although it is technically superior, the aria-errormessage attribute is also far less supported by assistive technologies (as of 2024-04-13). Because the aria-describedby attribute is accepted by the WAI as a valid means to convey error messages for fields, and because the attribute is more widely supported by assistive technologies, the FormValidityObserver uses this attribute for conveying error messages instead.

In the future, when aria-errormessage has better support, the FormValidityObserver will be updated to support it. Until then, the attribute will not be supported.

What's Next?