From f1b92703fe7f61d19ae5e4bd3f80b33a57eb68cc Mon Sep 17 00:00:00 2001 From: Isaiah Thomason <47364027+ITenthusiasm@users.noreply.github.com> Date: Fri, 12 Apr 2024 18:44:05 -0400 Subject: [PATCH] feat: Support Rendering Error Messages to the DOM by Default Although this feature is rather small, it opens up some pretty significant doors: 1. There are frameworks like `Lit` and `Preact` which expect to always have control of the content of the DOM node to which they render. With this `renderByDefault` option, we can guarantee that in these situations, the renderer will ALWAYS be used. So there should never be a time where the renderer suddenly stops working because the `FormValidityObserver` used the browser's default error message and directly modified an element's `textContent`. 2. There are developers who earnestly desire to keep their error messages in some kind of stateful object. They now have this option available if they "render" errors to their stateful object instead of rendering errors to the DOM directly. Then, they can use the stateful object to render error messages to the DOM instead. This isn't our preference or our recommendation. But if we can give people what they want with ease, then it's worth supporting. (More importantly, although we favor stateless forms in React, it's understandable why some might prefer the error messages to be _stateful_ -- primarily to avoid having to think about things like `useMemo` or `React.memo`. We're pretty happy that the types seem to be working fine, and our new changes are backwards compatible. However, it is somewhat... astounding how much TS effort was required compared to JS effort here (including when it came to tests). We've come to the point that @ThePrimeagen talked about where we're "writing code" in TypeScript. Not willing to lose the DX from the IntelliSense at this moment, though. --- packages/core/FormValidityObserver.d.ts | 77 +++-- packages/core/FormValidityObserver.js | 79 +++-- .../__tests__/FormValidityObserver.test.ts | 272 +++++++++++++++++- 3 files changed, 368 insertions(+), 60 deletions(-) diff --git a/packages/core/FormValidityObserver.d.ts b/packages/core/FormValidityObserver.d.ts index 7b39a24..45f930d 100644 --- a/packages/core/FormValidityObserver.d.ts +++ b/packages/core/FormValidityObserver.d.ts @@ -8,33 +8,42 @@ import type { OneOrMany, EventType, ValidatableField } from "./types.d.ts"; export type ErrorMessage = M | ((field: E) => M); -export type ErrorDetails = - | ErrorMessage - | { render: true; message: ErrorMessage } - | { render?: false; message: ErrorMessage }; +export type ErrorDetails = R extends true + ? + | ErrorMessage + | { render?: true; message: ErrorMessage } + | { render: false; message: ErrorMessage } + : + | ErrorMessage + | { render: true; message: ErrorMessage } + | { render?: false; message: ErrorMessage }; /** The errors to display to the user in the various situations where a field fails validation. */ -export interface ValidationErrors { - required?: ErrorDetails; - minlength?: ErrorDetails; - min?: ErrorDetails; - maxlength?: ErrorDetails; - max?: ErrorDetails; - step?: ErrorDetails; - type?: ErrorDetails; - pattern?: ErrorDetails; +export interface ValidationErrors { + required?: ErrorDetails; + minlength?: ErrorDetails; + min?: ErrorDetails; + maxlength?: ErrorDetails; + max?: ErrorDetails; + step?: ErrorDetails; + type?: ErrorDetails; + pattern?: ErrorDetails; /** * The error to display when the user's input is malformed, such as an incomplete date. * See {@link https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/badInput ValidityState.badInput} */ - badinput?: ErrorDetails; + badinput?: ErrorDetails; /** A function that runs custom validation logic for a field. This validation is always run _last_. */ - validate?(field: E): void | ErrorDetails | Promise>; + validate?(field: E): void | ErrorDetails | Promise>; } -export interface FormValidityObserverOptions { +export interface FormValidityObserverOptions< + M, + E extends ValidatableField = ValidatableField, + R extends boolean = false, +> { /** * Indicates that the observer's event listener should be called during the event capturing phase instead of * the event bubbling phase. Defaults to `false`. @@ -58,11 +67,17 @@ export interface FormValidityObserverOptions; + defaultErrors?: ValidationErrors; } export interface ValidateFieldOptions { @@ -82,13 +97,18 @@ interface FormValidityObserverConstructor { * * @param types The type(s) of event(s) that trigger(s) form field validation. */ - new , M = string, E extends ValidatableField = ValidatableField>( + new < + T extends OneOrMany, + M = string, + E extends ValidatableField = ValidatableField, + R extends boolean = false, + >( types: T, - options?: FormValidityObserverOptions, - ): FormValidityObserver; + options?: FormValidityObserverOptions, + ): FormValidityObserver; } -interface FormValidityObserver { +interface FormValidityObserver { /** * Instructs the observer to watch the validity state of the provided `form`'s fields. * Also connects the `form` to the observer's validation functions. @@ -149,8 +169,17 @@ interface FormValidityObserver { * @param render When `true`, the error `message` will be rendered to the DOM using the observer's * {@link FormValidityObserverOptions.renderer `renderer`} function. */ - setFieldError(name: string, message: ErrorMessage, render: true): void; - setFieldError(name: string, message: ErrorMessage, render?: false): void; + setFieldError( + name: string, + message: R extends true ? ErrorMessage : ErrorMessage, + render: R extends true ? false : true, + ): void; + + setFieldError( + name: string, + message: R extends true ? ErrorMessage : ErrorMessage, + render?: R, + ): void; /** * Marks the form field with the specified `name` as valid (`[aria-invalid="false"]`) and clears its error message. @@ -176,7 +205,7 @@ interface FormValidityObserver { * // If the field passes all of its validation constraints, no error message will be shown. * observer.configure("credit-card", { required: "You must provide a credit card number" }) */ - configure(name: string, errorMessages: ValidationErrors): void; + configure(name: string, errorMessages: ValidationErrors): void; } declare const FormValidityObserver: FormValidityObserverConstructor; diff --git a/packages/core/FormValidityObserver.js b/packages/core/FormValidityObserver.js index 04143b9..daced07 100644 --- a/packages/core/FormValidityObserver.js +++ b/packages/core/FormValidityObserver.js @@ -3,7 +3,7 @@ import FormObserver from "./FormObserver.js"; const radiogroupSelector = "fieldset[role='radiogroup']"; const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-invalid": "aria-invalid" }); -// NOTE: Generic `T` = Event TYPE. Generic `M` = Error MESSAGE. Generic `E` = ELEMENT +// NOTE: Generic `T` = Event TYPE. Generic `M` = Error MESSAGE. Generic `E` = ELEMENT. Generic `R` = RENDER by default. /** * @template M @@ -14,10 +14,16 @@ const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-inva /** * @template M * @template {import("./types.d.ts").ValidatableField} [E=import("./types.d.ts").ValidatableField] - * @typedef { - | ErrorMessage - | { render: true; message: ErrorMessage } - | { render?: false; message: ErrorMessage } + * @template {boolean} [R=false] + * @typedef {R extends true + ? + | ErrorMessage + | { render?: true; message: ErrorMessage } + | { render: false; message: ErrorMessage } + : + | ErrorMessage + | { render: true; message: ErrorMessage } + | { render?: false; message: ErrorMessage } } ErrorDetails */ @@ -25,31 +31,33 @@ const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-inva * The errors to display to the user in the various situations where a field fails validation. * @template M * @template {import("./types.d.ts").ValidatableField} [E=import("./types.d.ts").ValidatableField] + * @template {boolean} [R=false] * @typedef {Object} ValidationErrors * * - * @property {ErrorDetails} [required] - * @property {ErrorDetails} [minlength] - * @property {ErrorDetails} [min] - * @property {ErrorDetails} [maxlength] - * @property {ErrorDetails} [max] - * @property {ErrorDetails} [step] - * @property {ErrorDetails} [type] - * @property {ErrorDetails} [pattern] + * @property {ErrorDetails} [required] + * @property {ErrorDetails} [minlength] + * @property {ErrorDetails} [min] + * @property {ErrorDetails} [maxlength] + * @property {ErrorDetails} [max] + * @property {ErrorDetails} [step] + * @property {ErrorDetails} [type] + * @property {ErrorDetails} [pattern] * * - * @property {ErrorDetails} [badinput] The error to display when the user's input is malformed, such as an + * @property {ErrorDetails} [badinput] The error to display when the user's input is malformed, such as an * incomplete date. * See {@link https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/badInput ValidityState.badInput} * * @property { - (field: E) => void | ErrorDetails | Promise> + (field: E) => void | ErrorDetails | Promise> } [validate] A function that runs custom validation logic for a field. This validation is always run _last_. */ /** * @template M * @template {import("./types.d.ts").ValidatableField} [E=import("./types.d.ts").ValidatableField] + * @template {boolean} [R=false] * @typedef {Object} FormValidityObserverOptions * * @property {boolean} [useEventCapturing] Indicates that the observer's event listener should be called during @@ -67,7 +75,10 @@ const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-inva * You can replace the default function with your own `renderer` that renders other types of error messages * (e.g., DOM Nodes, React Elements, etc.) to the DOM instead. * - * @property {ValidationErrors} [defaultErrors] The default errors to display for the field constraints. + * @property {R} [renderByDefault] Determines the default value for every validation constraint's `render` option. + * (Also sets the default value for {@link FormValidityObserver.setFieldError setFieldError}'s `render` option.) + * + * @property {ValidationErrors} [defaultErrors] The default errors to display for the field constraints. * (The `validate` option configures the default _custom validation function_ used for all form fields.) */ @@ -82,43 +93,45 @@ const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-inva * Defaults to `false`. */ -/** @template [M=string] */ +/** @template [M=string] @template {boolean} [R=false] */ class FormValidityObserver extends FormObserver { /** @type {HTMLFormElement | undefined} The `form` currently being observed by the `FormValidityObserver` */ #form; /** @type {Document | ShadowRoot | undefined} The Root Node for the currently observed `form`. */ #root; /** @readonly @type {Required>["scroller"]} */ #scrollTo; /** @readonly @type {Required>["renderer"]} */ #renderError; + /** @readonly @type {FormValidityObserverOptions["renderByDefault"]} */ #renderByDefault; /** @readonly @type {FormValidityObserverOptions["defaultErrors"]} */ #defaultErrors; /** * @readonly - * @type {Map | undefined>} + * @type {Map | undefined>} * The {@link configure}d error messages for the various fields belonging to the observed `form` */ #errorMessagesByFieldName = new Map(); /* - * TODO: It's a little weird that we have to declare `M` twice for things to work. Maybe it's related to + * TODO: It's a little weird that we have to declare `M`/`R` twice for things to work. Maybe it's related to * illegal generic constructors? */ /** * @template {import("./types.d.ts").OneOrMany} T * @template [M=string] * @template {import("./types.d.ts").ValidatableField} [E=import("./types.d.ts").ValidatableField] + * @template {boolean} [R=false] * @overload * * Provides a way to validate an `HTMLFormElement`'s fields (and to display _accessible_ errors for those fields) * in response to the events that the fields emit. * * @param {T} types The type(s) of event(s) that trigger(s) form field validation. - * @param {FormValidityObserverOptions} [options] - * @returns {FormValidityObserver} + * @param {FormValidityObserverOptions} [options] + * @returns {FormValidityObserver} */ /** * @param {import("./types.d.ts").OneOrMany} types - * @param {FormValidityObserverOptions} [options] + * @param {FormValidityObserverOptions} [options] */ constructor(types, options) { /** @@ -135,6 +148,7 @@ class FormValidityObserver extends FormObserver { super(types, eventListener, { passive: true, capture: options?.useEventCapturing }); this.#scrollTo = options?.scroller ?? defaultScroller; this.#renderError = /** @type {any} Necessary because of double `M`s */ (options?.renderer ?? defaultErrorRenderer); + this.#renderByDefault = /** @type {any} Necessary because of double `R`s */ (options?.renderByDefault); this.#defaultErrors = /** @type {any} Necessary because of double `M`s */ (options?.defaultErrors); } @@ -320,7 +334,8 @@ class FormValidityObserver extends FormObserver { * a field validation attempt. * * @param {import("./types.d.ts").ValidatableField} field The `field` for which the validation was run - * @param {ErrorDetails | void} error The error to apply to the `field`, if any + * @param {ErrorDetails | void} error The error to apply + * to the `field`, if any * @param {ValidateFieldOptions | undefined} options The options that were used for the field's validation * * @returns {boolean} `true` if the field passed validation (indicated by a falsy `error` value) and `false` otherwise. @@ -333,7 +348,7 @@ class FormValidityObserver extends FormObserver { if (typeof error === "object") { this.setFieldError(field.name, /** @type {any} */ (error).message, /** @type {any} */ (error).render); - } else this.setFieldError(field.name, error); + } else this.setFieldError(field.name, /** @type {any} */ (error)); if (options?.focus) this.#callAttentionTo(field); return false; @@ -365,9 +380,10 @@ class FormValidityObserver extends FormObserver { * and applies the provided error `message` to it. * * @param {string} name The name of the invalid form field - * @param {ErrorMessage} message The error message to apply to the invalid form field - * @param {true} render When `true`, the error `message` will be rendered to the DOM using the observer's - * {@link FormValidityObserverOptions.renderer `renderer`} function. + * @param {R extends true ? ErrorMessage : ErrorMessage} message The error message to apply + * to the invalid form field + * @param {R extends true ? false : true} render When `true`, the error `message` will be rendered to the DOM + * using the observer's {@link FormValidityObserverOptions.renderer `renderer`} function. * @returns {void} */ @@ -377,8 +393,9 @@ class FormValidityObserver extends FormObserver { * and applies the provided error `message` to it. * * @param {string} name The name of the invalid form field - * @param {ErrorMessage} message The error message to apply to the invalid form field - * @param {false} [render] When `true`, the error `message` will be rendered to the DOM using the observer's + * @param {R extends true ? ErrorMessage : ErrorMessage} message The error message to apply + * to the invalid form field + * @param {R} [render] When `true`, the error `message` will be rendered to the DOM using the observer's * {@link FormValidityObserverOptions.renderer `renderer`} function. * @returns {void} */ @@ -390,7 +407,7 @@ class FormValidityObserver extends FormObserver { * @param {boolean} [render] * @returns {void} */ - setFieldError(name, message, render) { + setFieldError(name, message, render = this.#renderByDefault) { const field = this.#getTargetField(name); if (!field) return; @@ -452,7 +469,7 @@ class FormValidityObserver extends FormObserver { * * @template {import("./types.d.ts").ValidatableField} E * @param {string} name The `name` of the form field - * @param {ValidationErrors} errorMessages A `key`-`value` pair of validation constraints (key) + * @param {ValidationErrors} errorMessages A `key`-`value` pair of validation constraints (key) * and their corresponding error messages (value) * @returns {void} * diff --git a/packages/core/__tests__/FormValidityObserver.test.ts b/packages/core/__tests__/FormValidityObserver.test.ts index 4a75482..05b9674 100644 --- a/packages/core/__tests__/FormValidityObserver.test.ts +++ b/packages/core/__tests__/FormValidityObserver.test.ts @@ -619,6 +619,59 @@ describe("Form Validity Observer (Class)", () => { }); }); + it("Renders error messages to the DOM by default when the `renderByDefault` config option is `true`", () => { + const errorMessage = "
This field isn't correct!
"; + const errorFunc = (field: FormField) => `
Element "${field.tagName}" of type "${field.type}" is bad!
`; + const formValidityObserver = new FormValidityObserver(types, { renderByDefault: true }); + + // Render Form + const { form, fieldset, fields } = renderEmptyFields(); + [fieldset, ...fields].forEach(renderErrorContainerForField); + const radios = Array.from(fieldset.elements) as HTMLInputElement[]; + formValidityObserver.observe(form); + + /* ---------- Run Assertions ---------- */ + ([errorMessage, errorFunc] as const).forEach((e) => { + // Reset Errors + (Array.from(form.elements) as FormField[]).forEach((f) => formValidityObserver.clearFieldError(f.name)); + + // Radio Button Groups + formValidityObserver.setFieldError(radios[0].name, e); + + expect(fieldset).toHaveAttribute(attrs["aria-invalid"], String(true)); + expect(fieldset).not.toHaveAccessibleDescription(typeof e === "function" ? e(radios[0]) : e); + expect(fieldset).toHaveAccessibleDescription(getTextFromMarkup(typeof e === "function" ? e(radios[0]) : e)); + + radios.forEach((radio) => { + expect(radio.validationMessage).toBe(""); + expect(radio).not.toHaveAccessibleDescription(); + expect(radio).not.toHaveAttribute(attrs["aria-invalid"]); + }); + + // Other Fields + fields.forEach((field) => { + formValidityObserver.setFieldError(field.name, e); + + expect(field.validationMessage).toBe(""); + expect(field).toHaveAttribute(attrs["aria-invalid"], String(true)); + expect(field).not.toHaveAccessibleDescription(typeof e === "function" ? e(field) : e); + expect(field).toHaveAccessibleDescription(getTextFromMarkup(typeof e === "function" ? e(field) : e)); + }); + }); + + /* ---------- Verify That the Manual `render` Option Still Works ---------- */ + formValidityObserver.setFieldError(fields[0].name, errorMessage, false); + expect(fields[0].validationMessage).toBe(errorMessage); + expect(fields[0]).toHaveAttribute(attrs["aria-invalid"], String(true)); + expect(fields[0]).toHaveAccessibleDescription(errorMessage); + + formValidityObserver.setFieldError(fields[1].name, errorFunc, true); + expect(fields[1].validationMessage).toBe(""); + expect(fields[1]).toHaveAttribute(attrs["aria-invalid"], String(true)); + expect(fields[1]).not.toHaveAccessibleDescription(errorFunc(fields[1])); + expect(fields[1]).toHaveAccessibleDescription(getTextFromMarkup(errorFunc(fields[1]))); + }); + // See https://developer.mozilla.org/en-US/docs/Web/API/Element/setHTML // eslint-disable-next-line vitest/no-disabled-tests -- TODO: Bring this test back when browser support is better. it.skip("SECURELY renders error messages to the DOM as HTML whenever possible (default renderer)", () => { @@ -1578,7 +1631,7 @@ describe("Form Validity Observer (Class)", () => { it("Renders a field's error as HTML when the user-defined validator requires it (default renderer)", async () => { // Render Field - const error = "

Shall I be rendered? Or not?"; + const error = "

Shall I be rendered? Or not?

"; const { form, field } = renderField(createElementWithProps("input", { name: "user-validated" }), { accessible: true, }); @@ -1663,6 +1716,85 @@ describe("Form Validity Observer (Class)", () => { expect(formValidityObserver.setFieldError).toHaveBeenNthCalledWith(2, field.name, errorFunc, true); }); + it("Renders error messages to the DOM by default when the `renderByDefault` config option is `true`", () => { + // Render Field + const error = "

Shall I be rendered? Or not?

"; + const { form, field } = renderField(createElementWithProps("input", { name: "user-validated" }), { + accessible: true, + }); + + // Setup `FormValidityObserver`s + const formValidityObserver1 = new FormValidityObserver(types[0], { defaultErrors: { required: error } }); + formValidityObserver1.observe(form); + vi.spyOn(formValidityObserver1, "setFieldError"); + + const formValidityObserver2 = new FormValidityObserver(types[0], { + renderByDefault: true, + defaultErrors: { required: error }, + }); + formValidityObserver2.observe(form); + vi.spyOn(formValidityObserver2, "setFieldError"); + + const validate = vi.fn(); + formValidityObserver1.configure(field.name, { validate }); + formValidityObserver2.configure(field.name, { validate }); + + /* ---------- Assertions with Custom Validation ---------- */ + // Test with `render` Option Omitted + validate.mockReturnValueOnce({ message: error }); + expect(formValidityObserver1.validateField(field.name)).toBe(false); + expectErrorFor(field, error, "a11y"); + expect(formValidityObserver1.setFieldError).toHaveBeenNthCalledWith(1, field.name, error, undefined); + + validate.mockReturnValueOnce({ message: error }); + expect(formValidityObserver2.validateField(field.name)).toBe(false); + expectErrorFor(field, expect.not.stringMatching(error), "html"); + expectErrorFor(field, getTextFromMarkup(error), "html"); + expect(formValidityObserver2.setFieldError).toHaveBeenNthCalledWith(1, field.name, error, undefined); + + // Test with `render` Option Disabled + validate.mockReturnValueOnce({ message: error, render: false }); + expect(formValidityObserver1.validateField(field.name)).toBe(false); + expectErrorFor(field, error, "a11y"); + expect(formValidityObserver1.setFieldError).toHaveBeenNthCalledWith(2, field.name, error, false); + + validate.mockReturnValueOnce({ message: error, render: false }); + expect(formValidityObserver2.validateField(field.name)).toBe(false); + expectErrorFor(field, error, "a11y"); + expect(formValidityObserver2.setFieldError).toHaveBeenNthCalledWith(2, field.name, error, false); + + // Test with `render` Option Enabled + validate.mockReturnValueOnce({ message: error, render: true }); + expect(formValidityObserver1.validateField(field.name)).toBe(false); + expectErrorFor(field, expect.not.stringMatching(error), "html"); + expectErrorFor(field, getTextFromMarkup(error), "html"); + expect(formValidityObserver1.setFieldError).toHaveBeenNthCalledWith(3, field.name, error, true); + + validate.mockReturnValueOnce({ message: error, render: true }); + expect(formValidityObserver2.validateField(field.name)).toBe(false); + expectErrorFor(field, expect.not.stringMatching(error), "html"); + expectErrorFor(field, getTextFromMarkup(error), "html"); + expect(formValidityObserver2.setFieldError).toHaveBeenNthCalledWith(3, field.name, error, true); + + /* ---------- Assertions with Regular Errors (also Defaulted) ---------- */ + // Clear errors + validate.mockReturnValueOnce(undefined); + expect(formValidityObserver1.validateField(field.name)).toBe(true); + expectNoErrorsFor(field); + + // Force a `required` Error to Occur + field.required = true; + + expect(formValidityObserver1.validateField(field.name)).toBe(false); + expectErrorFor(field, error, "a11y"); + expect(formValidityObserver1.setFieldError).toHaveBeenNthCalledWith(4, field.name, error); + + expect(formValidityObserver2.validateField(field.name)).toBe(false); + expectErrorFor(field, expect.not.stringMatching(error), "html"); + expectErrorFor(field, getTextFromMarkup(error), "html"); + expect(formValidityObserver2.setFieldError).toHaveBeenNthCalledWith(4, field.name, error); + }); + 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 })); @@ -2740,12 +2872,12 @@ describe("Form Validity Observer (Class)", () => { // Failure Cases // @ts-expect-error -- Only `string`s are allowed for unrendered messages - new FormValidityObserver(event1, { renderer }).setFieldError(name, staticErrorCusotm); + new FormValidityObserver(event1, { renderer }).setFieldError(name, staticCustomError); // @ts-expect-error -- Only `string`s are allowed for unrendered messages new FormValidityObserver(event1, { renderer }).setFieldError(name, dynamicCustomError); // @ts-expect-error -- Only `string`s are allowed for unrendered messages - new FormValidityObserver(event1, { renderer }).setFieldError(name, staticErrorCusotm, false); + new FormValidityObserver(event1, { renderer }).setFieldError(name, staticCustomError, false); // @ts-expect-error -- Only `string`s are allowed for unrendered messages new FormValidityObserver(event1, { renderer }).setFieldError(name, dynamicCustomError, false); @@ -2759,6 +2891,43 @@ describe("Form Validity Observer (Class)", () => { // @ts-expect-error -- Cannot render message types not supported by `renderer` new FormValidityObserver(event1, { renderer }).setFieldError(name, (field) => field.childElementCount, true); + /* ---------- Custom Renderer with `renderByDefault=true` ---------- */ + // Success Cases + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, staticCustomError); + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, dynamicCustomError); + + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, staticErrorString, false); + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, dynamicErrorString, false); + + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, staticCustomError, true); + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, dynamicCustomError, true); + + // Failure Cases + // @ts-expect-error -- Cannot render message types not supported by `renderer` + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, staticErrorString); + // @ts-expect-error -- Cannot render message types not supported by `renderer` + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, dynamicErrorString); + + // @ts-expect-error -- Only `string`s are allowed for unrendered messages + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, staticCustomError, false); + // @ts-expect-error -- Only `string`s are allowed for unrendered messages + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, dynamicCustomError, false); + + // @ts-expect-error -- Cannot render message types not supported by `renderer` + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, staticErrorString, true); + // @ts-expect-error -- Cannot render message types not supported by `renderer` + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, staticErrorString, true); + + // @ts-expect-error -- Cannot render message types not supported by `renderer` + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError(name, 1, true); + + new FormValidityObserver(event1, { renderer, renderByDefault: true }).setFieldError( + name, + // @ts-expect-error -- Cannot render message types not supported by `renderer` + (field) => field.childElementCount, + true, + ); + /* -------------------- Renderer Type Tests --> `configure` -------------------- */ /* ---------- Default Renderer ---------- */ // Success Cases @@ -2864,6 +3033,60 @@ describe("Form Validity Observer (Class)", () => { }, }); + /* ---------- Custom Renderer with `renderByDefault=true` ---------- */ + // Success Cases + new FormValidityObserver(event1, { renderer, renderByDefault: true }).configure(name, { + badinput: staticCustomError, + required: dynamicCustomError, + + min: { message: staticCustomError }, + minlength: { message: dynamicCustomError }, + + max: { message: staticErrorString, render: false }, + maxlength: { message: dynamicErrorString, render: false }, + + type: { message: staticCustomError, render: true }, + pattern: { message: dynamicCustomError, render: true }, + + validate(_field) { + if (Math.random() < 100 / 3) return dynamicCustomError; + if (Math.random() < (100 / 3) * 2) return { message: staticErrorString, render: false }; + return Promise.resolve({ message: dynamicCustomError, render: true }); + }, + }); + + // Failure Cases + new FormValidityObserver(event1, { renderer, renderByDefault: true }).configure(name, { + // @ts-expect-error -- Cannot render message types not supported by `renderer` + badinput: staticErrorString, + // @ts-expect-error -- Cannot render message types not supported by `renderer` + required: dynamicErrorString, + + // @ts-expect-error -- Only `HTMLElement`s are allowed for rendered messages + min: { message: staticErrorString }, + // @ts-expect-error -- Only `HTMLElement`s are allowed for rendered messages + minlength: { message: dynamicErrorString }, + + // @ts-expect-error -- Only `string`s are allowed for unrendered messages + max: { message: staticCustomError, render: false }, + // @ts-expect-error -- Only `string`s are allowed for unrendered messages + maxlength: { message: dynamicCustomError, render: false }, + + // @ts-expect-error -- Cannot render message types not supported by `renderer` + type: { message: staticErrorString, render: true }, + // @ts-expect-error -- Cannot render message types not supported by `renderer` + pattern: { message: dynamicErrorString, render: true }, + + // @ts-expect-error -- Cannot render message types not supported by `renderer` + step: { message: 1, render: true }, + + // @ts-expect-error -- Cannot render unsupported messages + validate(field) { + if (Math.random() < 0.5) return (f) => ({ message: f.childElementCount, render: true }); // Bad render type + return Promise.resolve(field.childElementCount); // Bad render type + }, + }); + /* -------------------- Renderer Type Tests --> `defaultErrors` Option -------------------- */ // Note: Given the extensive tests for the `configure` method, and given that the `defaultErrors` option // uses the same type for the error message configuration, we'll just do some quick happy-path tests here. @@ -2896,8 +3119,33 @@ describe("Form Validity Observer (Class)", () => { validate(_field) { if (Math.random() < 100 / 3) return staticErrorString; - if (Math.random() < (100 / 3) * 2) return { message: staticErrorString, render: false }; - return Promise.resolve({ message: dynamicCustomError, render: true }); + if (Math.random() < (100 / 3) * 2) return { message: staticErrorString, render: false as const }; + return Promise.resolve({ message: dynamicCustomError, render: true as const }); + }, + }, + }); + + // Custom Renderer with `renderByDefault=true` + new FormValidityObserver(event1, { + renderer, + renderByDefault: true, + defaultErrors: { + badinput: staticCustomError, + required: dynamicCustomError, + + min: { message: staticCustomError }, + minlength: { message: dynamicCustomError }, + + max: { message: staticErrorString, render: false }, + maxlength: { message: dynamicErrorString, render: false }, + + type: { message: staticCustomError, render: true }, + pattern: { message: dynamicCustomError, render: true }, + + validate(_field) { + if (Math.random() < 100 / 3) return staticCustomError; + if (Math.random() < (100 / 3) * 2) return { message: staticErrorString, render: false as const }; + return Promise.resolve({ message: dynamicCustomError, render: true as const }); }, }, }); @@ -2936,6 +3184,20 @@ describe("Form Validity Observer (Class)", () => { new FormValidityObserver(event1, { defaultErrors: { required: (_: HTMLInputElement) => "" } }); new FormValidityObserver(event1, { defaultErrors: { required: (_: ValidatableField) => "" } }); new FormValidityObserver(event1, { defaultErrors: { required: (_: FormField) => "" } }); + + new FormValidityObserver(event1, { defaultErrors: { validate: (_: HTMLTextAreaElement) => "" } }); + new FormValidityObserver(event1, { defaultErrors: { validate: (_: HTMLInputElement) => "" } }); + new FormValidityObserver(event1, { defaultErrors: { validate: (_: ValidatableField) => "" } }); + new FormValidityObserver(event1, { defaultErrors: { validate: (_: FormField) => "" } }); + + // Fields must be consistent/compatible, however + new FormValidityObserver(event1, { + defaultErrors: { + required: (_: HTMLInputElement) => "", + // @ts-expect-error -- Incompatible with the `HTMLInputElement` specified earlier + validate: (_: HTMLSelectElement) => "", + }, + }); })(); /* eslint-enable @typescript-eslint/no-empty-function */ /* eslint-enable no-unreachable */