Skip to content

Commit

Permalink
feat: Support Form Field Revalidation
Browse files Browse the repository at this point in the history
This feature will be especially helpful for developers
who only want to validate their fields `oninput` _after_
their form has already been submitted.
  • Loading branch information
ITenthusiasm committed May 3, 2024
1 parent fe3d39a commit c2ca263
Show file tree
Hide file tree
Showing 4 changed files with 272 additions and 25 deletions.
25 changes: 25 additions & 0 deletions docs/form-validity-observer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ The `FormValidityObserver()` constructor creates a new observer and configures i
<dd>
The function used to scroll a field (or radiogroup) that has failed validation into view. Defaults to a function that calls <a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView"><code>scrollIntoView()</code></a> on the field (or radiogroup) that failed validation.
</dd>
<dt id="form-validity-observer-options-revalidate-on"><code>revalidateOn: EventType</code></dt>
<dd>
<p>
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.)
</p>
<p>
This can be helpful, for example, if you want to validate your fields <code>oninput</code>, but only after the user has visited them. In that case, you could write <code>new FormValidityObserver("focusout", { revalidateOn: "input" })</code>. Similarly, you might only want to validate your fields <code>oninput</code> after your form has been submitted. In that case, you could write <code>new FormValidityObserver(null, { revalidateOn: "input" })</code>.
</p>
</dd>
<dt id="form-validity-observer-options-renderer"><code>renderer: (errorContainer: HTMLElement, errorMessage: M | null) => void</code></dt>
<dd>
<p>
Expand Down Expand Up @@ -292,10 +301,18 @@ Validates all of the observed form's fields, returning `true` if _all_ of the va
<dd>
<p>Indicates that the <em>first</em> field in the DOM that fails validation should be focused. Defaults to <code>false</code>.</p>
</dd>
<dt><code>enableRevalidation</code></dt>
<dd>
<p>
Enables revalidation for <strong>all</strong> of the form's fields. Defaults to <code>true</code>. (This option is only relevant if a value was provided for the observer's <a href="#form-validity-observer-options-revalidate-on"><code>revalidateOn</code></a> option.)
</p>
</dd>
</dl>

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()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/reportValidity).

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`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) if the field's [`validate` constraint](./types.md#validationerrorsm-e-r) runs asynchronously. This promise will `resolve` after the asynchronous validation function `resolves`. Unlike the [`validateFields()`](#method-formvalidityobservervalidatefieldsoptions-validatefieldsoptions-boolean--promiseboolean) method, this promise will also `reject` if the asynchronous validation function `rejects`.
Expand All @@ -314,12 +331,20 @@ Validates the form field with the specified `name`, returning `true` if the fiel
<dl>
<dt><code>focus</code></dt>
<dd>Indicates that the field should be focused if it fails validation. Defaults to <code>false</code>.</dd>
<dt><code>enableRevalidation</code></dt>
<dd>
<p>
Enables revalidation for the validated field. Defaults to <code>true</code>. (This option is only relevant if a value was provided for the observer's <a href="#form-validity-observer-options-revalidate-on"><code>revalidateOn</code></a> option.)
</p>
</dd>
</dl>
</dd>
</dl>

When the `focus` option is `false`, you can consider `validateField()` to be an enhanced version of [`field.checkValidity()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/checkValidity). When the `focus` option is `true`, you can consider `validateField()` to be an enhanced version of [`field.reportValidity()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/reportValidity).

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>`](./types.md#errormessagem-e)`|`[`ErrorMessage<M, E>`](./types.md#errormessagem-e)`, render?: boolean): void`

Marks the form field having the specified `name` as invalid (via the [`[aria-invalid="true"]`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid) 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.
Expand Down
16 changes: 16 additions & 0 deletions packages/core/FormValidityObserver.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export interface FormValidityObserverOptions<
*/
scroller?(fieldOrRadiogroup: ValidatableField): void;

/**
* 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).
*/
revalidateOn?: EventType;

/**
* The function used to render error messages to the DOM when a validation constraint's `render` option is `true`.
* (It will be called with `null` when a field passes validation.) Defaults to a function that accepts a string
Expand All @@ -83,11 +89,21 @@ export interface FormValidityObserverOptions<
export interface ValidateFieldOptions {
/** Indicates that the field should be focused if it fails validation. Defaults to `false`. */
focus?: boolean;
/**
* Enables revalidation for the validated field. Defaults to `true`.
* (This option is only relevant if a value was provided for the observer's `revalidateOn` constructor option.)
*/
enableRevalidation?: boolean;
}

export interface ValidateFieldsOptions {
/** Indicates that the _first_ field in the DOM that fails validation should be focused. Defaults to `false`. */
focus?: boolean;
/**
* 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` constructor option.)
*/
enableRevalidation?: boolean;
}

interface FormValidityObserverConstructor {
Expand Down
31 changes: 28 additions & 3 deletions packages/core/FormValidityObserver.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import FormObserver from "./FormObserver.js";

const radiogroupSelector = "fieldset[role='radiogroup']";
const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-invalid": "aria-invalid" });
const attrs = Object.freeze({
"aria-describedby": "aria-describedby",
"aria-invalid": "aria-invalid",
"data-fvo-revalidate": "data-fvo-revalidate",
});

// NOTE: Generic `T` = Event TYPE. Generic `M` = Error MESSAGE. Generic `E` = ELEMENT. Generic `R` = RENDER by default.

Expand Down Expand Up @@ -68,6 +72,10 @@ const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-inva
* scroll a `field` (or `radiogroup`) that has failed validation into view. Defaults to a function that calls
* `fieldOrRadiogroup.scrollIntoView()`.
*
* @property {import("./types.d.ts").EventType} [revalidateOn] 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).
*
* @property {(errorContainer: HTMLElement, errorMessage: M | null) => void} [renderer] The function used to render
* error messages to the DOM when a validation constraint's `render` option is `true`. (It will be called with `null`
* when a field passes validation.) Defaults to a function that accepts a string and renders it to the DOM as raw HTML.
Expand All @@ -85,12 +93,20 @@ const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-inva
/**
* @typedef {Object} ValidateFieldOptions
* @property {boolean} [focus] Indicates that the field should be focused if it fails validation. Defaults to `false`.
*
* @property {boolean} [enableRevalidation] Enables revalidation for the validated field. Defaults to `true`.
* (This option is only relevant if a value was provided for the observer's
* {@link FormValidityObserverOptions.revalidateOn `revalidateOn`} option.)
*/

/**
* @typedef {Object} ValidateFieldsOptions
* @property {boolean} [focus] Indicates that the _first_ field in the DOM that fails validation should be focused.
* Defaults to `false`.
*
* @property {boolean} [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
* {@link FormValidityObserverOptions.revalidateOn `revalidateOn`} option.)
*/

/** @template [M=string] @template {boolean} [R=false] */
Expand Down Expand Up @@ -138,7 +154,6 @@ class FormValidityObserver extends FormObserver {
/** @type {import("./types.d.ts").EventType[]} */ const types = [];
/** @type {((event: Event & {target: import("./types.d.ts").ValidatableField }) => void)[]} */ const listeners = [];

// NOTE: We know this looks like overkill for something so simple. It'll make sense when we support `revalidateOn`.
if (typeof type === "string") {
types.push(type);
listeners.push((event) => {
Expand All @@ -147,6 +162,14 @@ class FormValidityObserver extends FormObserver {
});
}

if (typeof options?.revalidateOn === "string") {
types.push(options.revalidateOn);
listeners.push((event) => {
const field = event.target;
if (field.hasAttribute(attrs["data-fvo-revalidate"])) this.validateField(field.name);
});
}

super(types, listeners, { passive: true, capture: options?.useEventCapturing });
this.#scrollTo = options?.scroller ?? defaultScroller;
this.#renderError = /** @type {any} Necessary because of double `M`s */ (options?.renderer ?? defaultErrorRenderer);
Expand Down Expand Up @@ -214,6 +237,7 @@ class FormValidityObserver extends FormObserver {
validateFields(options) {
assertFormExists(this.#form);
let syncValidationPassed = true;
/** @type {ValidateFieldOptions} */ const validatorOptions = { enableRevalidation: options?.enableRevalidation };

/** @type {Promise<boolean>[] | undefined} */
let pendingValidations;
Expand All @@ -238,7 +262,7 @@ class FormValidityObserver extends FormObserver {
if (field.type === "radio") validatedRadiogroups.add(name);

// Validate Field and Update Internal State
const result = this.validateField(name);
const result = this.validateField(name, validatorOptions);
if (result === true) continue;
if (result === false) {
syncValidationPassed = false;
Expand Down Expand Up @@ -313,6 +337,7 @@ class FormValidityObserver extends FormObserver {
const field = this.#getTargetField(name);
if (!field) return false; // TODO: should we give a warning that the field doesn't exist? Same for other methods.
if (!field.willValidate) return true;
if (options?.enableRevalidation ?? true) field.setAttribute(attrs["data-fvo-revalidate"], "");

field.setCustomValidity?.(""); // Reset the custom error message in case a default browser error is displayed next.

Expand Down
Loading

0 comments on commit c2ca263

Please sign in to comment.