Skip to content

Commit 4003a07

Browse files
committed
feat(form): better defaults for validation
1 parent e8fb252 commit 4003a07

6 files changed

Lines changed: 79 additions & 70 deletions

File tree

packages/form/src/text-field/__tests__/PasswordWithMessage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe("PasswordWithMessage", () => {
104104
expect(field).toHaveAttribute("maxLength", "20");
105105
});
106106

107-
it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
107+
it("should enable the error state if the value is greater than the maxLength", () => {
108108
const { getByPlaceholderText, getByRole } = render(
109109
<Test minLength={5} maxLength={20} messageRole="alert" />
110110
);
@@ -116,8 +116,8 @@ describe("PasswordWithMessage", () => {
116116
expect(message.className).not.toContain("--error");
117117

118118
fireEvent.change(field, { target: { value: "1" } });
119-
expect(container.className).toContain("--error");
120-
expect(message.className).toContain("--error");
119+
expect(container.className).not.toContain("--error");
120+
expect(message.className).not.toContain("--error");
121121

122122
fireEvent.change(field, { target: { value: "Valid" } });
123123
expect(container.className).not.toContain("--error");

packages/form/src/text-field/__tests__/TextAreaWithMessage.tsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ describe("TextAreaWithMessage", () => {
3838

3939
expect(field).toHaveAttribute("aria-describedby", "field-id-message");
4040
expect(field.textContent).toBe("");
41-
/* expect(field.value).toBe(""); */
4241

4342
fireEvent.change(field, { target: { value: "a" } });
4443
expect(field.textContent).toBe("a");
@@ -99,7 +98,7 @@ describe("TextAreaWithMessage", () => {
9998
expect(field).toHaveAttribute("maxLength", "20");
10099
});
101100

102-
it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
101+
it("should enable the error state if the value is greater than the maxLength", () => {
103102
const { getByRole } = render(
104103
<Test minLength={5} maxLength={20} messageRole="alert" />
105104
);
@@ -111,8 +110,8 @@ describe("TextAreaWithMessage", () => {
111110
expect(message.className).not.toContain("--error");
112111

113112
fireEvent.change(field, { target: { value: "1" } });
114-
expect(container.className).toContain("--error");
115-
expect(message.className).toContain("--error");
113+
expect(container.className).not.toContain("--error");
114+
expect(message.className).not.toContain("--error");
116115

117116
fireEvent.change(field, { target: { value: "Valid" } });
118117
expect(container.className).not.toContain("--error");
@@ -134,7 +133,7 @@ describe("TextAreaWithMessage", () => {
134133
it("should not update the error state on change or update the value if the custon onChange event stopped propagation", () => {
135134
const { getByRole } = render(
136135
<Test
137-
minLength={10}
136+
maxLength={3}
138137
onChange={(event) => event.stopPropagation()}
139138
messageRole="alert"
140139
/>
@@ -154,7 +153,7 @@ describe("TextAreaWithMessage", () => {
154153

155154
it("should not update the error state on change if `validateOnChange` is false", () => {
156155
const { getByRole } = render(
157-
<Test minLength={10} validateOnChange={false} messageRole="alert" />
156+
<Test maxLength={3} validateOnChange={false} messageRole="alert" />
158157
);
159158

160159
const field = getByRole("textbox");
@@ -171,7 +170,7 @@ describe("TextAreaWithMessage", () => {
171170

172171
it("should not update the error state on change if `validateOnChange` is an empty array", () => {
173172
const { getByRole } = render(
174-
<Test minLength={10} validateOnChange={[]} messageRole="alert" />
173+
<Test maxLength={3} validateOnChange={[]} messageRole="alert" />
175174
);
176175

177176
const field = getByRole("textbox");
@@ -214,7 +213,7 @@ describe("TextAreaWithMessage", () => {
214213

215214
it("should render an icon next to the text field when there is an error by default", () => {
216215
const { getByRole, getByText } = render(
217-
<Test minLength={10} errorIcon={<ErrorOutlineFontIcon />} />
216+
<Test maxLength={3} errorIcon={<ErrorOutlineFontIcon />} />
218217
);
219218
const field = getByRole("textbox");
220219

@@ -226,7 +225,7 @@ describe("TextAreaWithMessage", () => {
226225
it("should default to the icon from the IconProvider", () => {
227226
const { getByText, getByRole } = render(
228227
<IconProvider>
229-
<Test minLength={10} />
228+
<Test maxLength={3} />
230229
</IconProvider>
231230
);
232231
const field = getByRole("textbox");
@@ -239,7 +238,7 @@ describe("TextAreaWithMessage", () => {
239238
it("should override the IconProvider error icon when the errorIcon prop is defined", () => {
240239
const { getByRole, getByText, rerender } = render(
241240
<IconProvider>
242-
<Test minLength={10} errorIcon={null} />
241+
<Test maxLength={3} errorIcon={null} />
243242
</IconProvider>
244243
);
245244
const field = getByRole("textbox");
@@ -250,7 +249,7 @@ describe("TextAreaWithMessage", () => {
250249

251250
rerender(
252251
<IconProvider>
253-
<Test minLength={10} errorIcon={<span>My Icon!</span>} />
252+
<Test maxLength={3} errorIcon={<span>My Icon!</span>} />
254253
</IconProvider>
255254
);
256255
expect(() => getByText("My Icon!")).not.toThrow();

packages/form/src/text-field/__tests__/TextFieldWithMessage.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ describe("TextFieldWithMessage", () => {
100100
expect(field).toHaveAttribute("maxLength", "20");
101101
});
102102

103-
it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
103+
it("should enable the error state if the value is greater than the maxLength", () => {
104104
const { getByRole } = render(
105105
<Test minLength={5} maxLength={20} messageRole="alert" />
106106
);
@@ -112,8 +112,8 @@ describe("TextFieldWithMessage", () => {
112112
expect(message.className).not.toContain("--error");
113113

114114
fireEvent.change(field, { target: { value: "1" } });
115-
expect(container.className).toContain("--error");
116-
expect(message.className).toContain("--error");
115+
expect(container.className).not.toContain("--error");
116+
expect(message.className).not.toContain("--error");
117117

118118
fireEvent.change(field, { target: { value: "Valid" } });
119119
expect(container.className).not.toContain("--error");
@@ -215,7 +215,7 @@ describe("TextFieldWithMessage", () => {
215215

216216
it("should render an icon next to the text field when there is an error by default", () => {
217217
const { getByRole, getByText } = render(
218-
<Test minLength={10} errorIcon={<ErrorOutlineFontIcon />} />
218+
<Test maxLength={3} errorIcon={<ErrorOutlineFontIcon />} />
219219
);
220220
const field = getByRole("textbox");
221221

@@ -227,7 +227,7 @@ describe("TextFieldWithMessage", () => {
227227
it("should default to the icon from the IconProvider", () => {
228228
const { getByText, getByRole } = render(
229229
<IconProvider>
230-
<Test minLength={10} />
230+
<Test maxLength={3} />
231231
</IconProvider>
232232
);
233233
const field = getByRole("textbox");
@@ -240,7 +240,7 @@ describe("TextFieldWithMessage", () => {
240240
it("should override the IconProvider error icon when the errorIcon prop is defined", () => {
241241
const { getByRole, getByText, rerender } = render(
242242
<IconProvider>
243-
<Test minLength={10} errorIcon={null} />
243+
<Test maxLength={3} errorIcon={null} />
244244
</IconProvider>
245245
);
246246
const field = getByRole("textbox");
@@ -251,7 +251,7 @@ describe("TextFieldWithMessage", () => {
251251

252252
rerender(
253253
<IconProvider>
254-
<Test minLength={10} errorIcon={<span>My Icon!</span>} />
254+
<Test maxLength={3} errorIcon={<span>My Icon!</span>} />
255255
</IconProvider>
256256
);
257257
expect(() => getByText("My Icon!")).not.toThrow();
@@ -306,7 +306,7 @@ describe("TextFieldWithMessage", () => {
306306
<Test
307307
onBlur={(event) => event.stopPropagation()}
308308
messageRole="alert"
309-
minLength={10}
309+
maxLength={3}
310310
validateOnChange={false}
311311
/>
312312
);
@@ -354,7 +354,7 @@ describe("TextFieldWithMessage", () => {
354354
it("should allow for a custom isErrored function", () => {
355355
const isErrored = jest.fn(() => false);
356356
const { getByRole } = render(
357-
<Test isErrored={isErrored} messageRole="alert" minLength={10} />
357+
<Test isErrored={isErrored} messageRole="alert" maxLength={3} />
358358
);
359359

360360
expect(isErrored).not.toBeCalled();
@@ -371,7 +371,7 @@ describe("TextFieldWithMessage", () => {
371371
expect(isErrored).toBeCalledWith({
372372
value: "invalid",
373373
errorMessage: "",
374-
minLength: 10,
374+
maxLength: 3,
375375
isBlurEvent: false,
376376
validateOnChange: "recommended",
377377
validationMessage: "",
@@ -382,15 +382,15 @@ describe("TextFieldWithMessage", () => {
382382
it("should call the onErrorChange option correctly", () => {
383383
const onErrorChange = jest.fn();
384384
const { getByRole } = render(
385-
<Test onErrorChange={onErrorChange} minLength={10} />
385+
<Test onErrorChange={onErrorChange} maxLength={3} />
386386
);
387387

388388
expect(onErrorChange).not.toBeCalled();
389389
const field = getByRole("textbox");
390390
fireEvent.change(field, { target: { value: "invalid" } });
391391
expect(onErrorChange).toBeCalledWith("field-id", true);
392392

393-
fireEvent.change(field, { target: { value: "this is a valid string" } });
393+
fireEvent.change(field, { target: { value: "v" } });
394394
expect(onErrorChange).toBeCalledWith("field-id", false);
395395
expect(onErrorChange).toBeCalledTimes(2);
396396
});
@@ -403,7 +403,7 @@ describe("TextFieldWithMessage", () => {
403403
const errorIcon = <span data-testid="error-icon" />;
404404

405405
const { getByTestId, getByRole } = render(
406-
<Test minLength={10} errorIcon={errorIcon} getErrorIcon={getErrorIcon} />
406+
<Test maxLength={3} errorIcon={errorIcon} getErrorIcon={getErrorIcon} />
407407
);
408408
const field = getByRole("textbox");
409409

packages/form/src/text-field/__tests__/getErrorMessage.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe("defaultGetErrorMessage", () => {
8787
).toBe("");
8888
});
8989

90-
it("should only return the validity message when the validateOnChange is set to recommeded and one of the non RECOMMEDNED_IGNORED_KEYS are errored", () => {
90+
it("should only return the validity message when the validateOnChange is set to recommeded and one of the RECOMMENDED_STATE_KEYS are errored", () => {
9191
const validate = (key: keyof ValidityState, expected: string): void => {
9292
expect(
9393
defaultGetErrorMessage({
@@ -98,16 +98,16 @@ describe("defaultGetErrorMessage", () => {
9898
).toBe(expected);
9999
};
100100

101-
validate("badInput", "");
102-
validate("customError", validationMessage);
103-
validate("patternMismatch", validationMessage);
104-
validate("rangeOverflow", validationMessage);
105-
validate("rangeUnderflow", validationMessage);
106-
validate("stepMismatch", validationMessage);
107-
validate("tooLong", "");
101+
validate("badInput", validationMessage);
102+
validate("customError", "");
103+
validate("patternMismatch", "");
104+
validate("rangeOverflow", "");
105+
validate("rangeUnderflow", "");
106+
validate("stepMismatch", "");
107+
validate("tooLong", validationMessage);
108108
validate("tooShort", "");
109-
validate("typeMismatch", validationMessage);
110-
validate("valueMissing", "");
109+
validate("typeMismatch", "");
110+
validate("valueMissing", validationMessage);
111111
});
112112

113113
it("should only return the validation message for the provided validity state key", () => {

packages/form/src/text-field/getErrorMessage.ts

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -67,24 +67,45 @@ export interface ErrorMessageOptions extends TextConstraints {
6767
*/
6868
export type GetErrorMessage = (options: ErrorMessageOptions) => string;
6969

70-
/** @internal */
71-
const RECOMMENDED_IGNORED_KEYS: readonly (keyof ValidityState)[] = [
70+
const VALIDITY_STATE_KEYS: readonly (keyof ValidityState)[] = [
7271
"badInput",
72+
"customError",
73+
"patternMismatch",
74+
"rangeOverflow",
75+
"rangeUnderflow",
76+
"stepMismatch",
7377
"tooLong",
7478
"tooShort",
79+
"typeMismatch",
80+
"valueMissing",
81+
];
82+
83+
/** @internal */
84+
const RECOMMENDED_STATE_KEYS: readonly (keyof ValidityState)[] = [
85+
"badInput",
86+
"tooLong",
7587
"valueMissing",
7688
];
7789

7890
/**
79-
* The default implementation for getting an error message for the `TextField`
80-
* or `TextArea` components that:
81-
*
82-
* - prevents the browser `minLength` and `tooLong` error text from appearing
83-
* during change events since the message is extremely verbose
84-
* - prevents the `valueMissing` and `badInput` error text from appearing during
85-
* change events since it's better to wait for the blur event.
91+
* The validation message is actually kind of weird since it's possible for a
92+
* form element to have multiple errors at once. The validation message will be
93+
* the first error that appears, so need to make sure that the first error is
94+
* one of the recommended state keys so the message appears for only those types
95+
* of errors.
8696
*
87-
* The above behavior is also configured by the {@link ChangeValidationBehavior}.
97+
* @internal
98+
*/
99+
const isRecommended = (validity: ValidityState): boolean =>
100+
VALIDITY_STATE_KEYS.every((key) => {
101+
const errored = validity[key];
102+
return !errored || RECOMMENDED_STATE_KEYS.includes(key);
103+
});
104+
105+
/**
106+
* The default implementation for getting an error message for the `TextField`
107+
* or `TextArea` components that relies on the behavior of the
108+
* {@link ChangeValidationBehavior}
88109
*/
89110
export const defaultGetErrorMessage: GetErrorMessage = ({
90111
isBlurEvent,
@@ -101,28 +122,16 @@ export const defaultGetErrorMessage: GetErrorMessage = ({
101122
}
102123

103124
if (validateOnChange === "recommended") {
104-
return Object.entries(validity).some(
105-
([key, errored]) =>
106-
errored &&
107-
!RECOMMENDED_IGNORED_KEYS.includes(key as keyof ValidityState)
108-
)
109-
? validationMessage
110-
: "";
125+
return isRecommended(validity) ? validationMessage : "";
111126
}
112127

113-
if (typeof validateOnChange === "string") {
114-
return validity[validateOnChange] ? validationMessage : "";
115-
}
116-
117-
if (
118-
!validateOnChange.length ||
119-
!Object.entries(validity).some(
120-
([key, errored]) =>
121-
errored && validateOnChange.includes(key as keyof ValidityState)
122-
)
123-
) {
124-
return "";
125-
}
128+
const keys =
129+
typeof validateOnChange === "string"
130+
? [validateOnChange]
131+
: validateOnChange;
126132

127-
return validationMessage;
133+
return keys.length &&
134+
VALIDITY_STATE_KEYS.some((key) => validity[key] && keys.includes(key))
135+
? validationMessage
136+
: "";
128137
};

packages/form/src/text-field/isErrored.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ export const defaultIsErrored: IsErrored = ({
2828
errorMessage,
2929
minLength,
3030
maxLength,
31+
isBlurEvent,
3132
}) =>
3233
!!errorMessage ||
33-
(typeof minLength === "number" && value.length < minLength) ||
34-
(typeof maxLength === "number" && value.length > maxLength);
34+
(typeof maxLength === "number" && value.length > maxLength) ||
35+
(isBlurEvent && typeof minLength === "number" && value.length < minLength);

0 commit comments

Comments
 (0)