Skip to content

Commit f1b9270

Browse files
committed
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.
1 parent 14505c4 commit f1b9270

File tree

3 files changed

+368
-60
lines changed

3 files changed

+368
-60
lines changed

packages/core/FormValidityObserver.d.ts

+53-24
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,42 @@ import type { OneOrMany, EventType, ValidatableField } from "./types.d.ts";
88

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

11-
export type ErrorDetails<M, E extends ValidatableField = ValidatableField> =
12-
| ErrorMessage<string, E>
13-
| { render: true; message: ErrorMessage<M, E> }
14-
| { render?: false; message: ErrorMessage<string, E> };
11+
export type ErrorDetails<M, E extends ValidatableField = ValidatableField, R extends boolean = false> = R extends true
12+
?
13+
| ErrorMessage<M, E>
14+
| { render?: true; message: ErrorMessage<M, E> }
15+
| { render: false; message: ErrorMessage<string, E> }
16+
:
17+
| ErrorMessage<string, E>
18+
| { render: true; message: ErrorMessage<M, E> }
19+
| { render?: false; message: ErrorMessage<string, E> };
1520

1621
/** The errors to display to the user in the various situations where a field fails validation. */
17-
export interface ValidationErrors<M, E extends ValidatableField = ValidatableField> {
18-
required?: ErrorDetails<M, E>;
19-
minlength?: ErrorDetails<M, E>;
20-
min?: ErrorDetails<M, E>;
21-
maxlength?: ErrorDetails<M, E>;
22-
max?: ErrorDetails<M, E>;
23-
step?: ErrorDetails<M, E>;
24-
type?: ErrorDetails<M, E>;
25-
pattern?: ErrorDetails<M, E>;
22+
export interface ValidationErrors<M, E extends ValidatableField = ValidatableField, R extends boolean = false> {
23+
required?: ErrorDetails<M, E, R>;
24+
minlength?: ErrorDetails<M, E, R>;
25+
min?: ErrorDetails<M, E, R>;
26+
maxlength?: ErrorDetails<M, E, R>;
27+
max?: ErrorDetails<M, E, R>;
28+
step?: ErrorDetails<M, E, R>;
29+
type?: ErrorDetails<M, E, R>;
30+
pattern?: ErrorDetails<M, E, R>;
2631

2732
/**
2833
* The error to display when the user's input is malformed, such as an incomplete date.
2934
* See {@link https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/badInput ValidityState.badInput}
3035
*/
31-
badinput?: ErrorDetails<M, E>;
36+
badinput?: ErrorDetails<M, E, R>;
3237

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

37-
export interface FormValidityObserverOptions<M, E extends ValidatableField = ValidatableField> {
42+
export interface FormValidityObserverOptions<
43+
M,
44+
E extends ValidatableField = ValidatableField,
45+
R extends boolean = false,
46+
> {
3847
/**
3948
* Indicates that the observer's event listener should be called during the event capturing phase instead of
4049
* the event bubbling phase. Defaults to `false`.
@@ -58,11 +67,17 @@ export interface FormValidityObserverOptions<M, E extends ValidatableField = Val
5867
*/
5968
renderer?(errorContainer: HTMLElement, errorMessage: M | null): void;
6069

70+
/**
71+
* Determines the default value for every validation constraint's `render` option. (Also sets the default value
72+
* for `setFieldError`'s `render` option.)
73+
*/
74+
renderByDefault?: R;
75+
6176
/**
6277
* The default errors to display for the field constraints. (The `validate` option configures the default
6378
* _custom validation function_ used for all form fields.)
6479
*/
65-
defaultErrors?: ValidationErrors<M, E>;
80+
defaultErrors?: ValidationErrors<M, E, R>;
6681
}
6782

6883
export interface ValidateFieldOptions {
@@ -82,13 +97,18 @@ interface FormValidityObserverConstructor {
8297
*
8398
* @param types The type(s) of event(s) that trigger(s) form field validation.
8499
*/
85-
new <T extends OneOrMany<EventType>, M = string, E extends ValidatableField = ValidatableField>(
100+
new <
101+
T extends OneOrMany<EventType>,
102+
M = string,
103+
E extends ValidatableField = ValidatableField,
104+
R extends boolean = false,
105+
>(
86106
types: T,
87-
options?: FormValidityObserverOptions<M, E>,
88-
): FormValidityObserver<M>;
107+
options?: FormValidityObserverOptions<M, E, R>,
108+
): FormValidityObserver<M, R>;
89109
}
90110

91-
interface FormValidityObserver<M = string> {
111+
interface FormValidityObserver<M = string, R extends boolean = false> {
92112
/**
93113
* Instructs the observer to watch the validity state of the provided `form`'s fields.
94114
* Also connects the `form` to the observer's validation functions.
@@ -149,8 +169,17 @@ interface FormValidityObserver<M = string> {
149169
* @param render When `true`, the error `message` will be rendered to the DOM using the observer's
150170
* {@link FormValidityObserverOptions.renderer `renderer`} function.
151171
*/
152-
setFieldError<E extends ValidatableField>(name: string, message: ErrorMessage<M, E>, render: true): void;
153-
setFieldError<E extends ValidatableField>(name: string, message: ErrorMessage<string, E>, render?: false): void;
172+
setFieldError<E extends ValidatableField>(
173+
name: string,
174+
message: R extends true ? ErrorMessage<string, E> : ErrorMessage<M, E>,
175+
render: R extends true ? false : true,
176+
): void;
177+
178+
setFieldError<E extends ValidatableField>(
179+
name: string,
180+
message: R extends true ? ErrorMessage<M, E> : ErrorMessage<string, E>,
181+
render?: R,
182+
): void;
154183

155184
/**
156185
* Marks the form field with the specified `name` as valid (`[aria-invalid="false"]`) and clears its error message.
@@ -176,7 +205,7 @@ interface FormValidityObserver<M = string> {
176205
* // If the field passes all of its validation constraints, no error message will be shown.
177206
* observer.configure("credit-card", { required: "You must provide a credit card number" })
178207
*/
179-
configure<E extends ValidatableField>(name: string, errorMessages: ValidationErrors<M, E>): void;
208+
configure<E extends ValidatableField>(name: string, errorMessages: ValidationErrors<M, E, R>): void;
180209
}
181210

182211
declare const FormValidityObserver: FormValidityObserverConstructor;

packages/core/FormValidityObserver.js

+48-31
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import FormObserver from "./FormObserver.js";
33
const radiogroupSelector = "fieldset[role='radiogroup']";
44
const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-invalid": "aria-invalid" });
55

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

88
/**
99
* @template M
@@ -14,42 +14,50 @@ const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-inva
1414
/**
1515
* @template M
1616
* @template {import("./types.d.ts").ValidatableField} [E=import("./types.d.ts").ValidatableField]
17-
* @typedef {
18-
| ErrorMessage<string, E>
19-
| { render: true; message: ErrorMessage<M, E> }
20-
| { render?: false; message: ErrorMessage<string, E> }
17+
* @template {boolean} [R=false]
18+
* @typedef {R extends true
19+
?
20+
| ErrorMessage<M, E>
21+
| { render?: true; message: ErrorMessage<M, E> }
22+
| { render: false; message: ErrorMessage<string, E> }
23+
:
24+
| ErrorMessage<string, E>
25+
| { render: true; message: ErrorMessage<M, E> }
26+
| { render?: false; message: ErrorMessage<string, E> }
2127
} ErrorDetails
2228
*/
2329

2430
/**
2531
* The errors to display to the user in the various situations where a field fails validation.
2632
* @template M
2733
* @template {import("./types.d.ts").ValidatableField} [E=import("./types.d.ts").ValidatableField]
34+
* @template {boolean} [R=false]
2835
* @typedef {Object} ValidationErrors
2936
*
3037
*
31-
* @property {ErrorDetails<M, E>} [required]
32-
* @property {ErrorDetails<M, E>} [minlength]
33-
* @property {ErrorDetails<M, E>} [min]
34-
* @property {ErrorDetails<M, E>} [maxlength]
35-
* @property {ErrorDetails<M, E>} [max]
36-
* @property {ErrorDetails<M, E>} [step]
37-
* @property {ErrorDetails<M, E>} [type]
38-
* @property {ErrorDetails<M, E>} [pattern]
38+
* @property {ErrorDetails<M, E, R>} [required]
39+
* @property {ErrorDetails<M, E, R>} [minlength]
40+
* @property {ErrorDetails<M, E, R>} [min]
41+
* @property {ErrorDetails<M, E, R>} [maxlength]
42+
* @property {ErrorDetails<M, E, R>} [max]
43+
* @property {ErrorDetails<M, E, R>} [step]
44+
* @property {ErrorDetails<M, E, R>} [type]
45+
* @property {ErrorDetails<M, E, R>} [pattern]
3946
*
4047
*
41-
* @property {ErrorDetails<M, E>} [badinput] The error to display when the user's input is malformed, such as an
48+
* @property {ErrorDetails<M, E, R>} [badinput] The error to display when the user's input is malformed, such as an
4249
* incomplete date.
4350
* See {@link https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/badInput ValidityState.badInput}
4451
*
4552
* @property {
46-
(field: E) => void | ErrorDetails<M, E> | Promise<void | ErrorDetails<M, E>>
53+
(field: E) => void | ErrorDetails<M, E, R> | Promise<void | ErrorDetails<M, E, R>>
4754
} [validate] A function that runs custom validation logic for a field. This validation is always run _last_.
4855
*/
4956

5057
/**
5158
* @template M
5259
* @template {import("./types.d.ts").ValidatableField} [E=import("./types.d.ts").ValidatableField]
60+
* @template {boolean} [R=false]
5361
* @typedef {Object} FormValidityObserverOptions
5462
*
5563
* @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
6775
* You can replace the default function with your own `renderer` that renders other types of error messages
6876
* (e.g., DOM Nodes, React Elements, etc.) to the DOM instead.
6977
*
70-
* @property {ValidationErrors<M, E>} [defaultErrors] The default errors to display for the field constraints.
78+
* @property {R} [renderByDefault] Determines the default value for every validation constraint's `render` option.
79+
* (Also sets the default value for {@link FormValidityObserver.setFieldError setFieldError}'s `render` option.)
80+
*
81+
* @property {ValidationErrors<M, E, R>} [defaultErrors] The default errors to display for the field constraints.
7182
* (The `validate` option configures the default _custom validation function_ used for all form fields.)
7283
*/
7384

@@ -82,43 +93,45 @@ const attrs = Object.freeze({ "aria-describedby": "aria-describedby", "aria-inva
8293
* Defaults to `false`.
8394
*/
8495

85-
/** @template [M=string] */
96+
/** @template [M=string] @template {boolean} [R=false] */
8697
class FormValidityObserver extends FormObserver {
8798
/** @type {HTMLFormElement | undefined} The `form` currently being observed by the `FormValidityObserver` */ #form;
8899
/** @type {Document | ShadowRoot | undefined} The Root Node for the currently observed `form`. */ #root;
89100

90101
/** @readonly @type {Required<FormValidityObserverOptions<M>>["scroller"]} */ #scrollTo;
91102
/** @readonly @type {Required<FormValidityObserverOptions<M>>["renderer"]} */ #renderError;
103+
/** @readonly @type {FormValidityObserverOptions<M, any, R>["renderByDefault"]} */ #renderByDefault;
92104
/** @readonly @type {FormValidityObserverOptions<M>["defaultErrors"]} */ #defaultErrors;
93105

94106
/**
95107
* @readonly
96-
* @type {Map<string, ValidationErrors<M, any> | undefined>}
108+
* @type {Map<string, ValidationErrors<M, any, R> | undefined>}
97109
* The {@link configure}d error messages for the various fields belonging to the observed `form`
98110
*/
99111
#errorMessagesByFieldName = new Map();
100112

101113
/*
102-
* TODO: It's a little weird that we have to declare `M` twice for things to work. Maybe it's related to
114+
* TODO: It's a little weird that we have to declare `M`/`R` twice for things to work. Maybe it's related to
103115
* illegal generic constructors?
104116
*/
105117
/**
106118
* @template {import("./types.d.ts").OneOrMany<import("./types.d.ts").EventType>} T
107119
* @template [M=string]
108120
* @template {import("./types.d.ts").ValidatableField} [E=import("./types.d.ts").ValidatableField]
121+
* @template {boolean} [R=false]
109122
* @overload
110123
*
111124
* Provides a way to validate an `HTMLFormElement`'s fields (and to display _accessible_ errors for those fields)
112125
* in response to the events that the fields emit.
113126
*
114127
* @param {T} types The type(s) of event(s) that trigger(s) form field validation.
115-
* @param {FormValidityObserverOptions<M, E>} [options]
116-
* @returns {FormValidityObserver<M>}
128+
* @param {FormValidityObserverOptions<M, E, R>} [options]
129+
* @returns {FormValidityObserver<M, R>}
117130
*/
118131

119132
/**
120133
* @param {import("./types.d.ts").OneOrMany<import("./types.d.ts").EventType>} types
121-
* @param {FormValidityObserverOptions<M>} [options]
134+
* @param {FormValidityObserverOptions<M, import("./types.d.ts").ValidatableField, R>} [options]
122135
*/
123136
constructor(types, options) {
124137
/**
@@ -135,6 +148,7 @@ class FormValidityObserver extends FormObserver {
135148
super(types, eventListener, { passive: true, capture: options?.useEventCapturing });
136149
this.#scrollTo = options?.scroller ?? defaultScroller;
137150
this.#renderError = /** @type {any} Necessary because of double `M`s */ (options?.renderer ?? defaultErrorRenderer);
151+
this.#renderByDefault = /** @type {any} Necessary because of double `R`s */ (options?.renderByDefault);
138152
this.#defaultErrors = /** @type {any} Necessary because of double `M`s */ (options?.defaultErrors);
139153
}
140154

@@ -320,7 +334,8 @@ class FormValidityObserver extends FormObserver {
320334
* a field validation attempt.
321335
*
322336
* @param {import("./types.d.ts").ValidatableField} field The `field` for which the validation was run
323-
* @param {ErrorDetails<M> | void} error The error to apply to the `field`, if any
337+
* @param {ErrorDetails<M, import("./types.d.ts").ValidatableField, boolean> | void} error The error to apply
338+
* to the `field`, if any
324339
* @param {ValidateFieldOptions | undefined} options The options that were used for the field's validation
325340
*
326341
* @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 {
333348

334349
if (typeof error === "object") {
335350
this.setFieldError(field.name, /** @type {any} */ (error).message, /** @type {any} */ (error).render);
336-
} else this.setFieldError(field.name, error);
351+
} else this.setFieldError(field.name, /** @type {any} */ (error));
337352

338353
if (options?.focus) this.#callAttentionTo(field);
339354
return false;
@@ -365,9 +380,10 @@ class FormValidityObserver extends FormObserver {
365380
* and applies the provided error `message` to it.
366381
*
367382
* @param {string} name The name of the invalid form field
368-
* @param {ErrorMessage<M, E>} message The error message to apply to the invalid form field
369-
* @param {true} render When `true`, the error `message` will be rendered to the DOM using the observer's
370-
* {@link FormValidityObserverOptions.renderer `renderer`} function.
383+
* @param {R extends true ? ErrorMessage<string, E> : ErrorMessage<M, E>} message The error message to apply
384+
* to the invalid form field
385+
* @param {R extends true ? false : true} render When `true`, the error `message` will be rendered to the DOM
386+
* using the observer's {@link FormValidityObserverOptions.renderer `renderer`} function.
371387
* @returns {void}
372388
*/
373389

@@ -377,8 +393,9 @@ class FormValidityObserver extends FormObserver {
377393
* and applies the provided error `message` to it.
378394
*
379395
* @param {string} name The name of the invalid form field
380-
* @param {ErrorMessage<string, E>} message The error message to apply to the invalid form field
381-
* @param {false} [render] When `true`, the error `message` will be rendered to the DOM using the observer's
396+
* @param {R extends true ? ErrorMessage<M, E> : ErrorMessage<string, E>} message The error message to apply
397+
* to the invalid form field
398+
* @param {R} [render] When `true`, the error `message` will be rendered to the DOM using the observer's
382399
* {@link FormValidityObserverOptions.renderer `renderer`} function.
383400
* @returns {void}
384401
*/
@@ -390,7 +407,7 @@ class FormValidityObserver extends FormObserver {
390407
* @param {boolean} [render]
391408
* @returns {void}
392409
*/
393-
setFieldError(name, message, render) {
410+
setFieldError(name, message, render = this.#renderByDefault) {
394411
const field = this.#getTargetField(name);
395412
if (!field) return;
396413

@@ -452,7 +469,7 @@ class FormValidityObserver extends FormObserver {
452469
*
453470
* @template {import("./types.d.ts").ValidatableField} E
454471
* @param {string} name The `name` of the form field
455-
* @param {ValidationErrors<M, E>} errorMessages A `key`-`value` pair of validation constraints (key)
472+
* @param {ValidationErrors<M, E, R>} errorMessages A `key`-`value` pair of validation constraints (key)
456473
* and their corresponding error messages (value)
457474
* @returns {void}
458475
*

0 commit comments

Comments
 (0)