Skip to content

Commit

Permalink
feat: Support Rendering Error Messages to the DOM by Default
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ITenthusiasm committed Apr 12, 2024
1 parent 14505c4 commit f1b9270
Show file tree
Hide file tree
Showing 3 changed files with 368 additions and 60 deletions.
77 changes: 53 additions & 24 deletions packages/core/FormValidityObserver.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,42 @@ import type { OneOrMany, EventType, ValidatableField } from "./types.d.ts";

export type ErrorMessage<M, E extends ValidatableField = ValidatableField> = M | ((field: E) => M);

export type ErrorDetails<M, E extends ValidatableField = ValidatableField> =
| ErrorMessage<string, E>
| { render: true; message: ErrorMessage<M, E> }
| { render?: false; message: ErrorMessage<string, E> };
export type ErrorDetails<M, E extends ValidatableField = ValidatableField, R extends boolean = false> = R extends true
?
| ErrorMessage<M, E>
| { render?: true; message: ErrorMessage<M, E> }
| { render: false; message: ErrorMessage<string, E> }
:
| ErrorMessage<string, E>
| { render: true; message: ErrorMessage<M, E> }
| { render?: false; message: ErrorMessage<string, E> };

/** The errors to display to the user in the various situations where a field fails validation. */
export interface ValidationErrors<M, E extends ValidatableField = ValidatableField> {
required?: ErrorDetails<M, E>;
minlength?: ErrorDetails<M, E>;
min?: ErrorDetails<M, E>;
maxlength?: ErrorDetails<M, E>;
max?: ErrorDetails<M, E>;
step?: ErrorDetails<M, E>;
type?: ErrorDetails<M, E>;
pattern?: ErrorDetails<M, E>;
export interface ValidationErrors<M, E extends ValidatableField = ValidatableField, R extends boolean = false> {
required?: ErrorDetails<M, E, R>;
minlength?: ErrorDetails<M, E, R>;
min?: ErrorDetails<M, E, R>;
maxlength?: ErrorDetails<M, E, R>;
max?: ErrorDetails<M, E, R>;
step?: ErrorDetails<M, E, R>;
type?: ErrorDetails<M, E, R>;
pattern?: ErrorDetails<M, E, R>;

/**
* 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<M, E>;
badinput?: ErrorDetails<M, E, R>;

/** A function that runs custom validation logic for a field. This validation is always run _last_. */
validate?(field: E): void | ErrorDetails<M, E> | Promise<void | ErrorDetails<M, E>>;
validate?(field: E): void | ErrorDetails<M, E, R> | Promise<void | ErrorDetails<M, E, R>>;
}

export interface FormValidityObserverOptions<M, E extends ValidatableField = ValidatableField> {
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`.
Expand All @@ -58,11 +67,17 @@ export interface FormValidityObserverOptions<M, E extends ValidatableField = Val
*/
renderer?(errorContainer: HTMLElement, errorMessage: M | null): void;

/**
* Determines the default value for every validation constraint's `render` option. (Also sets the default value
* for `setFieldError`'s `render` option.)
*/
renderByDefault?: R;

/**
* The default errors to display for the field constraints. (The `validate` option configures the default
* _custom validation function_ used for all form fields.)
*/
defaultErrors?: ValidationErrors<M, E>;
defaultErrors?: ValidationErrors<M, E, R>;
}

export interface ValidateFieldOptions {
Expand All @@ -82,13 +97,18 @@ interface FormValidityObserverConstructor {
*
* @param types The type(s) of event(s) that trigger(s) form field validation.
*/
new <T extends OneOrMany<EventType>, M = string, E extends ValidatableField = ValidatableField>(
new <
T extends OneOrMany<EventType>,
M = string,
E extends ValidatableField = ValidatableField,
R extends boolean = false,
>(
types: T,
options?: FormValidityObserverOptions<M, E>,
): FormValidityObserver<M>;
options?: FormValidityObserverOptions<M, E, R>,
): FormValidityObserver<M, R>;
}

interface FormValidityObserver<M = string> {
interface FormValidityObserver<M = string, R extends boolean = false> {
/**
* Instructs the observer to watch the validity state of the provided `form`'s fields.
* Also connects the `form` to the observer's validation functions.
Expand Down Expand Up @@ -149,8 +169,17 @@ interface FormValidityObserver<M = string> {
* @param render When `true`, the error `message` will be rendered to the DOM using the observer's
* {@link FormValidityObserverOptions.renderer `renderer`} function.
*/
setFieldError<E extends ValidatableField>(name: string, message: ErrorMessage<M, E>, render: true): void;
setFieldError<E extends ValidatableField>(name: string, message: ErrorMessage<string, E>, render?: false): void;
setFieldError<E extends ValidatableField>(
name: string,
message: R extends true ? ErrorMessage<string, E> : ErrorMessage<M, E>,
render: R extends true ? false : true,
): void;

setFieldError<E extends ValidatableField>(
name: string,
message: R extends true ? ErrorMessage<M, E> : ErrorMessage<string, E>,
render?: R,
): void;

/**
* Marks the form field with the specified `name` as valid (`[aria-invalid="false"]`) and clears its error message.
Expand All @@ -176,7 +205,7 @@ interface FormValidityObserver<M = string> {
* // 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<E extends ValidatableField>(name: string, errorMessages: ValidationErrors<M, E>): void;
configure<E extends ValidatableField>(name: string, errorMessages: ValidationErrors<M, E, R>): void;
}

declare const FormValidityObserver: FormValidityObserverConstructor;
Expand Down
79 changes: 48 additions & 31 deletions packages/core/FormValidityObserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,42 +14,50 @@ 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<string, E>
| { render: true; message: ErrorMessage<M, E> }
| { render?: false; message: ErrorMessage<string, E> }
* @template {boolean} [R=false]
* @typedef {R extends true
?
| ErrorMessage<M, E>
| { render?: true; message: ErrorMessage<M, E> }
| { render: false; message: ErrorMessage<string, E> }
:
| ErrorMessage<string, E>
| { render: true; message: ErrorMessage<M, E> }
| { render?: false; message: ErrorMessage<string, E> }
} ErrorDetails
*/

/**
* 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<M, E>} [required]
* @property {ErrorDetails<M, E>} [minlength]
* @property {ErrorDetails<M, E>} [min]
* @property {ErrorDetails<M, E>} [maxlength]
* @property {ErrorDetails<M, E>} [max]
* @property {ErrorDetails<M, E>} [step]
* @property {ErrorDetails<M, E>} [type]
* @property {ErrorDetails<M, E>} [pattern]
* @property {ErrorDetails<M, E, R>} [required]
* @property {ErrorDetails<M, E, R>} [minlength]
* @property {ErrorDetails<M, E, R>} [min]
* @property {ErrorDetails<M, E, R>} [maxlength]
* @property {ErrorDetails<M, E, R>} [max]
* @property {ErrorDetails<M, E, R>} [step]
* @property {ErrorDetails<M, E, R>} [type]
* @property {ErrorDetails<M, E, R>} [pattern]
*
*
* @property {ErrorDetails<M, E>} [badinput] The error to display when the user's input is malformed, such as an
* @property {ErrorDetails<M, E, R>} [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<M, E> | Promise<void | ErrorDetails<M, E>>
(field: E) => void | ErrorDetails<M, E, R> | Promise<void | ErrorDetails<M, E, R>>
} [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
Expand All @@ -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<M, E>} [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<M, E, R>} [defaultErrors] The default errors to display for the field constraints.
* (The `validate` option configures the default _custom validation function_ used for all form fields.)
*/

Expand All @@ -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<FormValidityObserverOptions<M>>["scroller"]} */ #scrollTo;
/** @readonly @type {Required<FormValidityObserverOptions<M>>["renderer"]} */ #renderError;
/** @readonly @type {FormValidityObserverOptions<M, any, R>["renderByDefault"]} */ #renderByDefault;
/** @readonly @type {FormValidityObserverOptions<M>["defaultErrors"]} */ #defaultErrors;

/**
* @readonly
* @type {Map<string, ValidationErrors<M, any> | undefined>}
* @type {Map<string, ValidationErrors<M, any, R> | 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<import("./types.d.ts").EventType>} 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<M, E>} [options]
* @returns {FormValidityObserver<M>}
* @param {FormValidityObserverOptions<M, E, R>} [options]
* @returns {FormValidityObserver<M, R>}
*/

/**
* @param {import("./types.d.ts").OneOrMany<import("./types.d.ts").EventType>} types
* @param {FormValidityObserverOptions<M>} [options]
* @param {FormValidityObserverOptions<M, import("./types.d.ts").ValidatableField, R>} [options]
*/
constructor(types, options) {
/**
Expand All @@ -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);
}

Expand Down Expand Up @@ -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<M> | void} error The error to apply to the `field`, if any
* @param {ErrorDetails<M, import("./types.d.ts").ValidatableField, boolean> | 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.
Expand All @@ -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;
Expand Down Expand Up @@ -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<M, E>} 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<string, E> : ErrorMessage<M, E>} 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}
*/

Expand All @@ -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<string, E>} 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<M, E> : ErrorMessage<string, E>} 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}
*/
Expand All @@ -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;

Expand Down Expand Up @@ -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<M, E>} errorMessages A `key`-`value` pair of validation constraints (key)
* @param {ValidationErrors<M, E, R>} errorMessages A `key`-`value` pair of validation constraints (key)
* and their corresponding error messages (value)
* @returns {void}
*
Expand Down
Loading

0 comments on commit f1b9270

Please sign in to comment.