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 = ` -
-
- ${testOptions.map((v) => ``).join("")} -
-
- `; +
+
+ ${testOptions.map((v) => ``).join("")} +
+
+ `; // 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 = ` -
- My Output - -
- -
- `; +
+ My Output + +
+ +
+ `; 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";