diff --git a/docs/form-validity-observer/README.md b/docs/form-validity-observer/README.md
index 99b9000..be7852f 100644
--- a/docs/form-validity-observer/README.md
+++ b/docs/form-validity-observer/README.md
@@ -109,6 +109,15 @@ The `FormValidityObserver()` constructor creates a new observer and configures i
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
@@ -292,10 +301,18 @@ Validates all of the observed form's fields, returning `true` if _all_ of the va
Indicates that the first field in the DOM that fails validation should be focused. Defaults to false.
+
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.)
+
+
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`
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`.
@@ -314,12 +331,20 @@ Validates the form field with the specified `name`, returning `true` if the fiel
focus
Indicates that the field should be focused if it fails validation. Defaults to false.
+
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.)
+
+
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(name: string, message: `[`ErrorMessage`](./types.md#errormessagem-e)`|`[`ErrorMessage`](./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.
diff --git a/packages/core/FormValidityObserver.d.ts b/packages/core/FormValidityObserver.d.ts
index 7cc9a7f..cc17242 100644
--- a/packages/core/FormValidityObserver.d.ts
+++ b/packages/core/FormValidityObserver.d.ts
@@ -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
@@ -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 {
diff --git a/packages/core/FormValidityObserver.js b/packages/core/FormValidityObserver.js
index 99c2cef..e8e841f 100644
--- a/packages/core/FormValidityObserver.js
+++ b/packages/core/FormValidityObserver.js
@@ -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.
@@ -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.
@@ -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] */
@@ -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) => {
@@ -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);
@@ -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[] | undefined} */
let pendingValidations;
@@ -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;
@@ -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.
diff --git a/packages/core/__tests__/FormValidityObserver.test.ts b/packages/core/__tests__/FormValidityObserver.test.ts
index fa3946f..bddc261 100644
--- a/packages/core/__tests__/FormValidityObserver.test.ts
+++ b/packages/core/__tests__/FormValidityObserver.test.ts
@@ -1808,6 +1808,107 @@ describe("Form Validity Observer (Class)", () => {
expect(formValidityObserver2.setFieldError).toHaveBeenNthCalledWith(4, field.name, error);
});
+ it("Enables revalidation for a form field when the `options` require it (defaults to `true`)", async () => {
+ /* ---------- Setup ---------- */
+ // Render Fields
+ document.body.innerHTML = `
+
+ `;
+
+ const form = screen.getByRole("form");
+ const textbox = screen.getByRole("textbox");
+ const combobox = screen.getByRole("combobox");
+ [textbox, combobox].forEach(renderErrorContainerForField);
+
+ // Setup `FormValidityObserver`
+ const formValidityObserver = new FormValidityObserver(null, { revalidateOn: "input" });
+ formValidityObserver.observe(form);
+
+ /* ---------- Default Behavior ---------- */
+ // Nothing happens because revalidation is not enabled
+ await userEvent.type(textbox, "abc");
+ expect(textbox.validationMessage).not.toBe("");
+ expect(textbox).not.toHaveAttribute("aria-invalid");
+ expect(textbox).not.toHaveAccessibleDescription();
+
+ // Revalidation is enabled during field validation by default
+ await userEvent.clear(textbox);
+ expect(formValidityObserver.validateField(textbox.name)).toBe(true);
+
+ // Now revalidation kicks in
+ await userEvent.type(textbox, "abc");
+ expect(textbox.validationMessage).not.toBe("");
+ expect(textbox).toHaveAttribute("aria-invalid", String(true));
+ expect(textbox).toHaveAccessibleDescription(textbox.validationMessage);
+
+ /* ---------- Behavior with the `enableRevalidation` Option Explicitly Provided ---------- */
+ // Nothing happens because revalidation is not enabled
+ await userEvent.selectOptions(combobox, "Empty");
+ expect(combobox.validationMessage).not.toBe("");
+ expect(combobox).not.toHaveAttribute("aria-invalid");
+ expect(combobox).not.toHaveAccessibleDescription();
+
+ // Revalidation REMAINS DISABLED after field validation because of the provided options
+ await userEvent.selectOptions(combobox, "Something");
+ expect(formValidityObserver.validateField(combobox.name, { enableRevalidation: false })).toBe(true);
+
+ // Again, nothing happens because revalidation was never enabled for this field
+ await userEvent.selectOptions(combobox, "Empty");
+ expect(combobox.validationMessage).not.toBe("");
+ expect(combobox).not.toHaveAttribute("aria-invalid", String(true));
+ expect(combobox).not.toHaveAccessibleDescription();
+
+ // Revalidation is EXPLICITLY turned on during field validation with the provided options
+ await userEvent.selectOptions(combobox, "Something");
+ expect(formValidityObserver.validateField(combobox.name, { enableRevalidation: true })).toBe(true);
+
+ // Now revalidation finally kicks in
+ await userEvent.selectOptions(combobox, "Empty");
+ expect(combobox.validationMessage).not.toBe("");
+ expect(combobox).toHaveAttribute("aria-invalid", String(true));
+ expect(combobox).toHaveAccessibleDescription(combobox.validationMessage);
+ });
+
+ it("Does not enable revalidation for fields that don't partake in form validation", async () => {
+ // Render Field
+ const { form, field } = renderField(createElementWithProps("input", { name: "revalidate", pattern: "\\d+" }));
+ renderErrorContainerForField(field);
+
+ // Setup `FormValidityObserver`
+ const formValidityObserver = new FormValidityObserver(null, { revalidateOn: "input" });
+ formValidityObserver.observe(form);
+
+ // Revalidation REMAINS DISABLED because `disabled` fields don't partake in form validation
+ field.disabled = true;
+ expect(field.willValidate).toBe(false);
+ expect(formValidityObserver.validateField(field.name, { enableRevalidation: true })).toBe(true);
+
+ // Mark field as a candidate for form validation
+ field.disabled = false;
+ expect(field.willValidate).toBe(true);
+
+ // Nothing happens because revalidation was never enabled for this field
+ await userEvent.type(field, "abc");
+ expect(field.validationMessage).not.toBe("");
+ expect(field).not.toHaveAttribute("aria-invalid");
+ expect(field).not.toHaveAccessibleDescription();
+
+ // Now that the field partakes in form validation, revalidation should be enabled during field validation
+ await userEvent.clear(field);
+ expect(formValidityObserver.validateField(field.name, { enableRevalidation: true })).toBe(true);
+
+ await userEvent.type(field, "abc");
+ expect(field.validationMessage).not.toBe("");
+ expect(field).toHaveAttribute("aria-invalid", String(true));
+ expect(field).toHaveAccessibleDescription(field.validationMessage);
+ });
+
it("Rejects non-`string` error messages when the `render` option is not `true`", () => {
// Render Field
const { form, field } = renderField(createElementWithProps("input", { name: "renderer", required: true }));
@@ -2007,7 +2108,9 @@ describe("Form Validity Observer (Class)", () => {
// Run Assertions
formValidityObserver.validateFields();
expect(formValidityObserver.validateField).toHaveBeenCalledTimes(uniqueFieldNames.size);
- validatableFields.forEach((f) => expect(formValidityObserver.validateField).toHaveBeenCalledWith(f.name));
+ validatableFields.forEach((f) =>
+ expect(formValidityObserver.validateField).toHaveBeenCalledWith(f.name, { enableValidation: undefined }),
+ );
});
it("Returns `true` if ALL of the validated fields PASS validation", () => {
@@ -2367,17 +2470,54 @@ describe("Form Validity Observer (Class)", () => {
expect(firstRadio.reportValidity).not.toHaveBeenCalled();
});
+ /*
+ * Note: This test might be a bit odd because it's highly implementation-specific. In fact, even the
+ * test name itself is highly implementation-specific. However, in order to reduce code duplication,
+ * we actually WANT to verify that `validateFields()` correctly calls and passes options to the `validateField()`
+ * method. So this oddity is intentional.
+ */
+ it("Passes its `enableRevalidation` option to the `validateField()` method", () => {
+ // Render Form
+ const { form, fields } = renderEmptyFields();
+ const validatableFieldCount = fields.length + 1; // Note: The "+1" accounts for the `radiogroup`
+
+ // Setup `FormValidityObserver`
+ const formValidityObserver = new FormValidityObserver(eventType, { revalidateOn: "change" });
+ formValidityObserver.observe(form);
+ const validateField = vi.spyOn(formValidityObserver, "validateField");
+
+ /* ---------- Assertions ---------- */
+ // With no `enableRevalidation` option provided
+ formValidityObserver.validateFields();
+ expect(validateField).toHaveBeenCalledTimes(validatableFieldCount);
+ validateField.mock.calls.forEach((args) => expect(args[1]).toStrictEqual({ enableRevalidation: undefined }));
+
+ // With the `enableRevalidation` option disabled
+ validateField.mockClear();
+ formValidityObserver.validateFields({ enableRevalidation: false });
+
+ expect(validateField).toHaveBeenCalledTimes(validatableFieldCount);
+ validateField.mock.calls.forEach((args) => expect(args[1]).toStrictEqual({ enableRevalidation: false }));
+
+ // With the `enableRevalidation` option enabled
+ validateField.mockClear();
+ formValidityObserver.validateFields({ enableRevalidation: true });
+
+ expect(validateField).toHaveBeenCalledTimes(validatableFieldCount);
+ validateField.mock.calls.forEach((args) => expect(args[1]).toStrictEqual({ enableRevalidation: true }));
+ });
+
it("Does not validate the same `radiogroup` more than once (Performance Test)", () => {
const radioName = "radio";
// Render Form
document.body.innerHTML = `
-
- `;
+
+ `;
// Observe Form
const formValidityObserver = new FormValidityObserver(eventType);
@@ -2387,7 +2527,7 @@ describe("Form Validity Observer (Class)", () => {
// Run Assertions
formValidityObserver.validateFields();
expect(formValidityObserver.validateField).toHaveBeenCalledTimes(1);
- expect(formValidityObserver.validateField).toHaveBeenCalledWith(radioName);
+ expect(formValidityObserver.validateField).toHaveBeenCalledWith(radioName, { enableRevalidation: undefined });
});
it("Ignores form controls that don't have a `name`", () => {
@@ -2395,11 +2535,11 @@ describe("Form Validity Observer (Class)", () => {
// Render HTML
document.body.innerHTML = `
-
-
-
- `;
+
+
+
+ `;
// Observe Form
const formValidityObserver = new FormValidityObserver(eventType);
@@ -2409,7 +2549,9 @@ describe("Form Validity Observer (Class)", () => {
// Run Assertions
formValidityObserver.validateFields();
expect(formValidityObserver.validateField).toHaveBeenCalledTimes(1);
- expect(formValidityObserver.validateField).toHaveBeenCalledWith(validatableFieldName);
+ expect(formValidityObserver.validateField).toHaveBeenCalledWith(validatableFieldName, {
+ enableRevalidation: undefined,
+ });
});
/*
@@ -2427,13 +2569,13 @@ describe("Form Validity Observer (Class)", () => {
// Render Form
document.body.innerHTML = `
-
- `;
+
+ `;
const form = screen.getByRole("form");
expect(Array.from(form.elements).every((e) => e.getAttribute("name"))).toBeTruthy();
@@ -2446,7 +2588,9 @@ describe("Form Validity Observer (Class)", () => {
// Run Assertions (unsupported elements are ignored)
formValidityObserver.validateFields();
expect(formValidityObserver.validateField).toHaveBeenCalledTimes(1);
- expect(formValidityObserver.validateField).toHaveBeenCalledWith(validatableFieldName);
+ expect(formValidityObserver.validateField).toHaveBeenCalledWith(validatableFieldName, {
+ enableRevalidation: undefined,
+ });
});
});
@@ -2680,6 +2824,43 @@ describe("Form Validity Observer (Class)", () => {
}
});
+ it("Automatically enables revalidation for form fields", async () => {
+ // Render Form
+ const { form, field } = renderField(
+ createElementWithProps("input", { name: "auto", required: true, pattern: "\\d+" }),
+ { accessible: true },
+ );
+
+ // Setup `FormValidityObserver`
+ const formValidityObserver = new FormValidityObserver("focusout", { revalidateOn: "input" });
+ formValidityObserver.observe(form);
+
+ /* ---------- Assertions ---------- */
+ // Nothing happens. (Validation hasn't happened yet, AND revalidation is not enabled yet.)
+ await userEvent.type(field, "abc");
+ expect(field.validationMessage).not.toBe("");
+ expect(field).not.toHaveAttribute("aria-invalid");
+ expect(field).not.toHaveAccessibleDescription();
+
+ // Again, nothing happens. (Still no validation or revalidation.)
+ await userEvent.clear(field);
+ expect(field.validationMessage).not.toBe("");
+ expect(field).not.toHaveAttribute("aria-invalid");
+ expect(field).not.toHaveAccessibleDescription();
+
+ // Trigger automatic field validation
+ await userEvent.type(field, "{Tab}");
+ expect(field.validationMessage).not.toBe("");
+ expect(field).toHaveAttribute("aria-invalid", String(true));
+ expect(field).toHaveAccessibleDescription(field.validationMessage);
+
+ // Now revalidation kicks in (enabled by the automatic field validation)
+ await userEvent.type(field, "123");
+ expect(field.validationMessage).toBe("");
+ expect(field).toHaveAttribute("aria-invalid", String(false));
+ expect(field).not.toHaveAccessibleDescription();
+ });
+
describe.each(testCases)("for %s", (testCase) => {
const [method, type, rendering] = testCase.split(" ") as [ErrorMethod, ErrorType, ErrorRendering];
const accessible = method === "Accessible";