remix-validated-form 3.0.0-beta.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/.turbo/turbo-test.log +10 -35
  3. package/README.md +41 -13
  4. package/browser/ValidatedForm.d.ts +6 -1
  5. package/browser/ValidatedForm.js +52 -1
  6. package/browser/hooks.d.ts +25 -1
  7. package/browser/hooks.js +44 -13
  8. package/browser/internal/MultiValueMap.d.ts +0 -0
  9. package/browser/internal/MultiValueMap.js +1 -0
  10. package/browser/internal/SingleTypeMultiValueMap.d.ts +8 -0
  11. package/browser/internal/SingleTypeMultiValueMap.js +40 -0
  12. package/browser/internal/formContext.d.ts +25 -1
  13. package/browser/internal/formContext.js +5 -0
  14. package/browser/internal/getInputProps.d.ts +28 -0
  15. package/browser/internal/getInputProps.js +38 -0
  16. package/{build/validation/validation.test.d.ts → browser/internal/test.d.ts} +0 -0
  17. package/browser/internal/test.js +10 -0
  18. package/browser/server.d.ts +1 -1
  19. package/browser/server.js +11 -1
  20. package/browser/validation/types.d.ts +2 -0
  21. package/build/ValidatedForm.d.ts +6 -1
  22. package/build/ValidatedForm.js +52 -1
  23. package/build/hooks.d.ts +25 -1
  24. package/build/hooks.js +43 -12
  25. package/build/internal/SingleTypeMultiValueMap.d.ts +8 -0
  26. package/build/internal/SingleTypeMultiValueMap.js +45 -0
  27. package/build/internal/formContext.d.ts +25 -1
  28. package/build/internal/formContext.js +5 -0
  29. package/build/internal/getInputProps.d.ts +28 -0
  30. package/build/internal/getInputProps.js +42 -0
  31. package/build/internal/test.d.ts +1 -0
  32. package/build/internal/test.js +12 -0
  33. package/build/server.d.ts +1 -1
  34. package/build/server.js +11 -1
  35. package/build/validation/types.d.ts +2 -0
  36. package/package.json +1 -3
  37. package/src/ValidatedForm.tsx +73 -0
  38. package/src/hooks.ts +77 -9
  39. package/src/internal/SingleTypeMultiValueMap.ts +37 -0
  40. package/src/internal/formContext.ts +30 -1
  41. package/src/internal/getInputProps.ts +79 -0
  42. package/src/server.ts +18 -2
  43. package/src/validation/types.ts +8 -0
  44. package/build/test-data/testFormData.d.ts +0 -15
  45. package/build/test-data/testFormData.js +0 -50
  46. package/build/validation/validation.test.js +0 -295
  47. package/build/validation/withYup.d.ts +0 -6
  48. package/build/validation/withYup.js +0 -44
  49. package/build/validation/withZod.d.ts +0 -6
  50. package/build/validation/withZod.js +0 -57
@@ -9,6 +9,7 @@ const react_1 = require("@remix-run/react");
9
9
  const react_2 = require("react");
10
10
  const tiny_invariant_1 = __importDefault(require("tiny-invariant"));
11
11
  const formContext_1 = require("./internal/formContext");
12
+ const SingleTypeMultiValueMap_1 = require("./internal/SingleTypeMultiValueMap");
12
13
  const submissionCallbacks_1 = require("./internal/submissionCallbacks");
13
14
  const util_1 = require("./internal/util");
14
15
  function useFieldErrorsFromBackend(fetcher, subaction) {
@@ -70,15 +71,39 @@ function useDefaultValues(fieldErrors, defaultValues) {
70
71
  const defaultsFromValidationError = fieldErrors === null || fieldErrors === void 0 ? void 0 : fieldErrors._submittedData;
71
72
  return defaultsFromValidationError !== null && defaultsFromValidationError !== void 0 ? defaultsFromValidationError : defaultValues;
72
73
  }
74
+ const focusFirstInvalidInput = (fieldErrors, customFocusHandlers, formElement) => {
75
+ const invalidInputSelector = Object.keys(fieldErrors)
76
+ .map((fieldName) => `input[name="${fieldName}"]`)
77
+ .join(",");
78
+ const invalidInputs = formElement.querySelectorAll(invalidInputSelector);
79
+ for (const element of invalidInputs) {
80
+ const input = element;
81
+ if (customFocusHandlers.has(input.name)) {
82
+ customFocusHandlers.getAll(input.name).forEach((handler) => {
83
+ handler();
84
+ });
85
+ break;
86
+ }
87
+ // We don't filter these out ahead of time because
88
+ // they could have a custom focus handler
89
+ if (input.type === "hidden") {
90
+ continue;
91
+ }
92
+ input.focus();
93
+ break;
94
+ }
95
+ };
73
96
  /**
74
97
  * The primary form component of `remix-validated-form`.
75
98
  */
76
- function ValidatedForm({ validator, onSubmit, children, fetcher, action, defaultValues, formRef: formRefProp, onReset, subaction, resetAfterSubmit, ...rest }) {
99
+ function ValidatedForm({ validator, onSubmit, children, fetcher, action, defaultValues, formRef: formRefProp, onReset, subaction, resetAfterSubmit, disableFocusOnError, ...rest }) {
77
100
  var _a;
78
101
  const fieldErrorsFromBackend = useFieldErrorsFromBackend(fetcher, subaction);
79
102
  const [fieldErrors, setFieldErrors] = useFieldErrors(fieldErrorsFromBackend);
80
103
  const isSubmitting = useIsSubmitting(action, subaction, fetcher);
81
104
  const defaultsToUse = useDefaultValues(fieldErrorsFromBackend, defaultValues);
105
+ const [touchedFields, setTouchedFields] = (0, react_2.useState)({});
106
+ const [hasBeenSubmitted, setHasBeenSubmitted] = (0, react_2.useState)(false);
82
107
  const formRef = (0, react_2.useRef)(null);
83
108
  (0, submissionCallbacks_1.useSubmitComplete)(isSubmitting, () => {
84
109
  var _a;
@@ -86,11 +111,18 @@ function ValidatedForm({ validator, onSubmit, children, fetcher, action, default
86
111
  (_a = formRef.current) === null || _a === void 0 ? void 0 : _a.reset();
87
112
  }
88
113
  });
114
+ const customFocusHandlers = (0, SingleTypeMultiValueMap_1.useMultiValueMap)();
89
115
  const contextValue = (0, react_2.useMemo)(() => ({
90
116
  fieldErrors,
91
117
  action,
92
118
  defaultValues: defaultsToUse,
93
119
  isSubmitting: isSubmitting !== null && isSubmitting !== void 0 ? isSubmitting : false,
120
+ isValid: Object.keys(fieldErrors).length === 0,
121
+ touchedFields,
122
+ setFieldTouched: (fieldName, touched) => setTouchedFields((prev) => ({
123
+ ...prev,
124
+ [fieldName]: touched,
125
+ })),
94
126
  clearError: (fieldName) => {
95
127
  setFieldErrors((prev) => (0, util_1.omit)(prev, fieldName));
96
128
  },
@@ -103,21 +135,38 @@ function ValidatedForm({ validator, onSubmit, children, fetcher, action, default
103
135
  [fieldName]: error,
104
136
  }));
105
137
  }
138
+ else {
139
+ setFieldErrors((prev) => (0, util_1.omit)(prev, fieldName));
140
+ }
141
+ },
142
+ registerReceiveFocus: (fieldName, handler) => {
143
+ customFocusHandlers().add(fieldName, handler);
144
+ return () => {
145
+ customFocusHandlers().remove(fieldName, handler);
146
+ };
106
147
  },
148
+ hasBeenSubmitted,
107
149
  }), [
108
150
  fieldErrors,
109
151
  action,
110
152
  defaultsToUse,
111
153
  isSubmitting,
154
+ touchedFields,
155
+ hasBeenSubmitted,
112
156
  setFieldErrors,
113
157
  validator,
158
+ customFocusHandlers,
114
159
  ]);
115
160
  const Form = (_a = fetcher === null || fetcher === void 0 ? void 0 : fetcher.Form) !== null && _a !== void 0 ? _a : react_1.Form;
116
161
  return ((0, jsx_runtime_1.jsx)(Form, { ref: (0, util_1.mergeRefs)([formRef, formRefProp]), ...rest, action: action, onSubmit: (event) => {
162
+ setHasBeenSubmitted(true);
117
163
  const result = validator.validate(getDataFromForm(event.currentTarget));
118
164
  if (result.error) {
119
165
  event.preventDefault();
120
166
  setFieldErrors(result.error);
167
+ if (!disableFocusOnError) {
168
+ focusFirstInvalidInput(result.error, customFocusHandlers(), formRef.current);
169
+ }
121
170
  }
122
171
  else {
123
172
  onSubmit === null || onSubmit === void 0 ? void 0 : onSubmit(result.data, event);
@@ -127,6 +176,8 @@ function ValidatedForm({ validator, onSubmit, children, fetcher, action, default
127
176
  if (event.defaultPrevented)
128
177
  return;
129
178
  setFieldErrors({});
179
+ setTouchedFields({});
180
+ setHasBeenSubmitted(false);
130
181
  }, children: (0, jsx_runtime_1.jsxs)(formContext_1.FormContext.Provider, { value: contextValue, children: [subaction && ((0, jsx_runtime_1.jsx)("input", { type: "hidden", value: subaction, name: "subaction" }, void 0)), children] }, void 0) }, void 0));
131
182
  }
132
183
  exports.ValidatedForm = ValidatedForm;
package/build/hooks.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { GetInputProps, ValidationBehaviorOptions } from "./internal/getInputProps";
1
2
  export declare type FieldProps = {
2
3
  /**
3
4
  * The validation error message if there is one.
@@ -15,11 +16,34 @@ export declare type FieldProps = {
15
16
  * The default value of the field, if there is one.
16
17
  */
17
18
  defaultValue?: any;
19
+ /**
20
+ * Whether or not the field has been touched.
21
+ */
22
+ touched: boolean;
23
+ /**
24
+ * Helper to set the touched state of the field.
25
+ */
26
+ setTouched: (touched: boolean) => void;
27
+ /**
28
+ * Helper to get all the props necessary for a regular input.
29
+ */
30
+ getInputProps: GetInputProps;
18
31
  };
19
32
  /**
20
33
  * Provides the data and helpers necessary to set up a field.
21
34
  */
22
- export declare const useField: (name: string) => FieldProps;
35
+ export declare const useField: (name: string, options?: {
36
+ /**
37
+ * Allows you to configure a custom function that will be called
38
+ * when the input needs to receive focus due to a validation error.
39
+ * This is useful for custom components that use a hidden input.
40
+ */
41
+ handleReceiveFocus?: (() => void) | undefined;
42
+ /**
43
+ * Allows you to specify when a field gets validated (when using getInputProps)
44
+ */
45
+ validationBehavior?: Partial<ValidationBehaviorOptions> | undefined;
46
+ } | undefined) => FieldProps;
23
47
  /**
24
48
  * Provides access to the entire form context.
25
49
  * This is not usually necessary, but can be useful for advanced use cases.
package/build/hooks.js CHANGED
@@ -8,21 +8,52 @@ const get_1 = __importDefault(require("lodash/get"));
8
8
  const toPath_1 = __importDefault(require("lodash/toPath"));
9
9
  const react_1 = require("react");
10
10
  const formContext_1 = require("./internal/formContext");
11
+ const getInputProps_1 = require("./internal/getInputProps");
11
12
  /**
12
13
  * Provides the data and helpers necessary to set up a field.
13
14
  */
14
- const useField = (name) => {
15
- const { fieldErrors, clearError, validateField, defaultValues } = (0, react_1.useContext)(formContext_1.FormContext);
16
- const field = (0, react_1.useMemo)(() => ({
17
- error: fieldErrors[name],
18
- clearError: () => {
19
- clearError(name);
20
- },
21
- validate: () => validateField(name),
22
- defaultValue: defaultValues
23
- ? (0, get_1.default)(defaultValues, (0, toPath_1.default)(name), undefined)
24
- : undefined,
25
- }), [clearError, defaultValues, fieldErrors, name, validateField]);
15
+ const useField = (name, options) => {
16
+ const { fieldErrors, clearError, validateField, defaultValues, registerReceiveFocus, touchedFields, setFieldTouched, hasBeenSubmitted, } = (0, react_1.useContext)(formContext_1.FormContext);
17
+ const isTouched = !!touchedFields[name];
18
+ const { handleReceiveFocus } = options !== null && options !== void 0 ? options : {};
19
+ (0, react_1.useEffect)(() => {
20
+ if (handleReceiveFocus)
21
+ return registerReceiveFocus(name, handleReceiveFocus);
22
+ }, [handleReceiveFocus, name, registerReceiveFocus]);
23
+ const field = (0, react_1.useMemo)(() => {
24
+ const helpers = {
25
+ error: fieldErrors[name],
26
+ clearError: () => {
27
+ clearError(name);
28
+ },
29
+ validate: () => validateField(name),
30
+ defaultValue: defaultValues
31
+ ? (0, get_1.default)(defaultValues, (0, toPath_1.default)(name), undefined)
32
+ : undefined,
33
+ touched: isTouched,
34
+ setTouched: (touched) => setFieldTouched(name, touched),
35
+ };
36
+ const getInputProps = (0, getInputProps_1.createGetInputProps)({
37
+ ...helpers,
38
+ name,
39
+ hasBeenSubmitted,
40
+ validationBehavior: options === null || options === void 0 ? void 0 : options.validationBehavior,
41
+ });
42
+ return {
43
+ ...helpers,
44
+ getInputProps,
45
+ };
46
+ }, [
47
+ fieldErrors,
48
+ name,
49
+ defaultValues,
50
+ isTouched,
51
+ hasBeenSubmitted,
52
+ options === null || options === void 0 ? void 0 : options.validationBehavior,
53
+ clearError,
54
+ validateField,
55
+ setFieldTouched,
56
+ ]);
26
57
  return field;
27
58
  };
28
59
  exports.useField = useField;
@@ -0,0 +1,8 @@
1
+ export declare class MultiValueMap<Key, Value> {
2
+ private dict;
3
+ add: (key: Key, value: Value) => void;
4
+ remove: (key: Key, value: Value) => void;
5
+ getAll: (key: Key) => Value[];
6
+ has: (key: Key) => boolean;
7
+ }
8
+ export declare const useMultiValueMap: <Key, Value>() => () => MultiValueMap<Key, Value>;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useMultiValueMap = exports.MultiValueMap = void 0;
4
+ const react_1 = require("react");
5
+ class MultiValueMap {
6
+ constructor() {
7
+ this.dict = new Map();
8
+ this.add = (key, value) => {
9
+ var _a;
10
+ this.dict.set(key, [...((_a = this.dict.get(key)) !== null && _a !== void 0 ? _a : []), value]);
11
+ if (this.dict.has(key)) {
12
+ this.dict.get(key).push(value);
13
+ }
14
+ else {
15
+ this.dict.set(key, [value]);
16
+ }
17
+ };
18
+ this.remove = (key, value) => {
19
+ if (!this.dict.has(key))
20
+ return;
21
+ const array = this.dict.get(key);
22
+ const index = array.indexOf(value);
23
+ if (index !== -1)
24
+ array.splice(index, 1);
25
+ if (array.length === 0)
26
+ this.dict.delete(key);
27
+ };
28
+ this.getAll = (key) => {
29
+ var _a;
30
+ return (_a = this.dict.get(key)) !== null && _a !== void 0 ? _a : [];
31
+ };
32
+ this.has = (key) => this.dict.has(key);
33
+ }
34
+ }
35
+ exports.MultiValueMap = MultiValueMap;
36
+ const useMultiValueMap = () => {
37
+ const ref = (0, react_1.useRef)(null);
38
+ return () => {
39
+ if (ref.current)
40
+ return ref.current;
41
+ ref.current = new MultiValueMap();
42
+ return ref.current;
43
+ };
44
+ };
45
+ exports.useMultiValueMap = useMultiValueMap;
@@ -1,5 +1,5 @@
1
1
  /// <reference types="react" />
2
- import { FieldErrors } from "../validation/types";
2
+ import { FieldErrors, TouchedFields } from "../validation/types";
3
3
  export declare type FormContextValue = {
4
4
  /**
5
5
  * All the errors in all the fields in the form.
@@ -21,11 +21,35 @@ export declare type FormContextValue = {
21
21
  * Whether or not the form is submitting.
22
22
  */
23
23
  isSubmitting: boolean;
24
+ /**
25
+ * Whether or not a submission has been attempted.
26
+ * This is true once the form has been submitted, even if there were validation errors.
27
+ * Resets to false when the form is reset.
28
+ */
29
+ hasBeenSubmitted: boolean;
30
+ /**
31
+ * Whether or not the form is valid.
32
+ * This is a shortcut for `Object.keys(fieldErrors).length === 0`.
33
+ */
34
+ isValid: boolean;
24
35
  /**
25
36
  * The default values of the form.
26
37
  */
27
38
  defaultValues?: {
28
39
  [fieldName: string]: any;
29
40
  };
41
+ /**
42
+ * Register a custom focus handler to be used when
43
+ * the field needs to receive focus due to a validation error.
44
+ */
45
+ registerReceiveFocus: (fieldName: string, handler: () => void) => () => void;
46
+ /**
47
+ * Any fields that have been touched by the user.
48
+ */
49
+ touchedFields: TouchedFields;
50
+ /**
51
+ * Change the touched state of the specified field.
52
+ */
53
+ setFieldTouched: (fieldName: string, touched: boolean) => void;
30
54
  };
31
55
  export declare const FormContext: import("react").Context<FormContextValue>;
@@ -7,4 +7,9 @@ exports.FormContext = (0, react_1.createContext)({
7
7
  clearError: () => { },
8
8
  validateField: () => { },
9
9
  isSubmitting: false,
10
+ hasBeenSubmitted: false,
11
+ isValid: true,
12
+ registerReceiveFocus: () => () => { },
13
+ touchedFields: {},
14
+ setFieldTouched: () => { },
10
15
  });
@@ -0,0 +1,28 @@
1
+ export declare type ValidationBehavior = "onBlur" | "onChange" | "onSubmit";
2
+ export declare type ValidationBehaviorOptions = {
3
+ initial: ValidationBehavior;
4
+ whenTouched: ValidationBehavior;
5
+ whenSubmitted: ValidationBehavior;
6
+ };
7
+ export declare type CreateGetInputPropsOptions = {
8
+ clearError: () => void;
9
+ validate: () => void;
10
+ defaultValue?: any;
11
+ touched: boolean;
12
+ setTouched: (touched: boolean) => void;
13
+ hasBeenSubmitted: boolean;
14
+ validationBehavior?: Partial<ValidationBehaviorOptions>;
15
+ name: string;
16
+ };
17
+ export declare type MinimalInputProps = {
18
+ onChange?: (...args: any[]) => void;
19
+ onBlur?: (...args: any[]) => void;
20
+ };
21
+ export declare type MinimalResult = {
22
+ name: string;
23
+ onChange: (...args: any[]) => void;
24
+ onBlur: (...args: any[]) => void;
25
+ defaultValue?: any;
26
+ };
27
+ export declare type GetInputProps = <T extends {}>(props?: T & MinimalInputProps) => T & MinimalResult;
28
+ export declare const createGetInputProps: ({ clearError, validate, defaultValue, touched, setTouched, hasBeenSubmitted, validationBehavior, name, }: CreateGetInputPropsOptions) => GetInputProps;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createGetInputProps = void 0;
4
+ const defaultValidationBehavior = {
5
+ initial: "onBlur",
6
+ whenTouched: "onChange",
7
+ whenSubmitted: "onChange",
8
+ };
9
+ const createGetInputProps = ({ clearError, validate, defaultValue, touched, setTouched, hasBeenSubmitted, validationBehavior, name, }) => {
10
+ const validationBehaviors = {
11
+ ...defaultValidationBehavior,
12
+ ...validationBehavior,
13
+ };
14
+ return (props = {}) => {
15
+ const behavior = hasBeenSubmitted
16
+ ? validationBehaviors.whenSubmitted
17
+ : touched
18
+ ? validationBehaviors.whenTouched
19
+ : validationBehaviors.initial;
20
+ return {
21
+ ...props,
22
+ onChange: (...args) => {
23
+ var _a;
24
+ if (behavior === "onChange")
25
+ validate();
26
+ else
27
+ clearError();
28
+ return (_a = props === null || props === void 0 ? void 0 : props.onChange) === null || _a === void 0 ? void 0 : _a.call(props, ...args);
29
+ },
30
+ onBlur: (...args) => {
31
+ var _a;
32
+ if (behavior === "onBlur")
33
+ validate();
34
+ setTouched(true);
35
+ return (_a = props === null || props === void 0 ? void 0 : props.onBlur) === null || _a === void 0 ? void 0 : _a.call(props, ...args);
36
+ },
37
+ defaultValue,
38
+ name,
39
+ };
40
+ };
41
+ };
42
+ exports.createGetInputProps = createGetInputProps;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const getInputProps_1 = require("./getInputProps");
5
+ const CompRequired = (props) => null;
6
+ const getProps = (0, getInputProps_1.createGetInputProps)({});
7
+ (0, jsx_runtime_1.jsx)(CompRequired, { ...getProps({
8
+ temp: 21,
9
+ bob: "ross",
10
+ onBlur: () => { },
11
+ // onChange: () => {},
12
+ }) }, void 0);
package/build/server.d.ts CHANGED
@@ -4,4 +4,4 @@ import { FieldErrors } from "./validation/types";
4
4
  * The `ValidatedForm` on the frontend will automatically display the errors
5
5
  * if this is returned from the action.
6
6
  */
7
- export declare const validationError: (errors: FieldErrors) => Response;
7
+ export declare const validationError: (errors: FieldErrors, submittedData?: unknown) => Response;
package/build/server.js CHANGED
@@ -7,5 +7,15 @@ const server_runtime_1 = require("@remix-run/server-runtime");
7
7
  * The `ValidatedForm` on the frontend will automatically display the errors
8
8
  * if this is returned from the action.
9
9
  */
10
- const validationError = (errors) => (0, server_runtime_1.json)({ fieldErrors: errors }, { status: 422 });
10
+ const validationError = (errors, submittedData) => {
11
+ if (submittedData) {
12
+ return (0, server_runtime_1.json)({
13
+ fieldErrors: {
14
+ ...errors,
15
+ _submittedData: submittedData,
16
+ },
17
+ }, { status: 422 });
18
+ }
19
+ return (0, server_runtime_1.json)({ fieldErrors: errors }, { status: 422 });
20
+ };
11
21
  exports.validationError = validationError;
@@ -1,4 +1,5 @@
1
1
  export declare type FieldErrors = Record<string, string>;
2
+ export declare type TouchedFields = Record<string, boolean>;
2
3
  export declare type FieldErrorsWithData = FieldErrors & {
3
4
  _submittedData: any;
4
5
  };
@@ -28,3 +29,4 @@ export declare type Validator<DataType> = {
28
29
  validate: (unvalidatedData: GenericObject) => ValidationResult<DataType>;
29
30
  validateField: (unvalidatedData: GenericObject, field: string) => ValidateFieldResult;
30
31
  };
32
+ export declare type ValidatorData<T extends Validator<any>> = T extends Validator<infer U> ? U : never;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remix-validated-form",
3
- "version": "3.0.0-beta.1",
3
+ "version": "3.2.0",
4
4
  "description": "Form component and utils for easy form validation in remix",
5
5
  "browser": "./browser/index.js",
6
6
  "main": "./build/index.js",
@@ -14,8 +14,6 @@
14
14
  "build": "npm run build:browser && npm run build:main",
15
15
  "build:browser": "tsc --module ESNext --outDir ./browser",
16
16
  "build:main": "tsc --module CommonJS --outDir ./build",
17
- "test": "jest src",
18
- "test:watch": "jest src --watch",
19
17
  "prepublishOnly": "cp ../../README.md ./README.md && npm run build",
20
18
  "postpublish": "rm ./README.md"
21
19
  },
@@ -14,12 +14,17 @@ import React, {
14
14
  } from "react";
15
15
  import invariant from "tiny-invariant";
16
16
  import { FormContext, FormContextValue } from "./internal/formContext";
17
+ import {
18
+ MultiValueMap,
19
+ useMultiValueMap,
20
+ } from "./internal/SingleTypeMultiValueMap";
17
21
  import { useSubmitComplete } from "./internal/submissionCallbacks";
18
22
  import { omit, mergeRefs } from "./internal/util";
19
23
  import {
20
24
  FieldErrors,
21
25
  Validator,
22
26
  FieldErrorsWithData,
27
+ TouchedFields,
23
28
  } from "./validation/types";
24
29
 
25
30
  export type FormProps<DataType> = {
@@ -59,6 +64,11 @@ export type FormProps<DataType> = {
59
64
  * and don't redirect in-between submissions.
60
65
  */
61
66
  resetAfterSubmit?: boolean;
67
+ /**
68
+ * Normally, the first invalid input will be focused when the validation fails on form submit.
69
+ * Set this to `false` to disable this behavior.
70
+ */
71
+ disableFocusOnError?: boolean;
62
72
  } & Omit<ComponentProps<typeof RemixForm>, "onSubmit">;
63
73
 
64
74
  function useFieldErrorsFromBackend(
@@ -136,6 +146,36 @@ function useDefaultValues<DataType>(
136
146
  return defaultsFromValidationError ?? defaultValues;
137
147
  }
138
148
 
149
+ const focusFirstInvalidInput = (
150
+ fieldErrors: FieldErrors,
151
+ customFocusHandlers: MultiValueMap<string, () => void>,
152
+ formElement: HTMLFormElement
153
+ ) => {
154
+ const invalidInputSelector = Object.keys(fieldErrors)
155
+ .map((fieldName) => `input[name="${fieldName}"]`)
156
+ .join(",");
157
+ const invalidInputs = formElement.querySelectorAll(invalidInputSelector);
158
+ for (const element of invalidInputs) {
159
+ const input = element as HTMLInputElement;
160
+
161
+ if (customFocusHandlers.has(input.name)) {
162
+ customFocusHandlers.getAll(input.name).forEach((handler) => {
163
+ handler();
164
+ });
165
+ break;
166
+ }
167
+
168
+ // We don't filter these out ahead of time because
169
+ // they could have a custom focus handler
170
+ if (input.type === "hidden") {
171
+ continue;
172
+ }
173
+
174
+ input.focus();
175
+ break;
176
+ }
177
+ };
178
+
139
179
  /**
140
180
  * The primary form component of `remix-validated-form`.
141
181
  */
@@ -150,18 +190,22 @@ export function ValidatedForm<DataType>({
150
190
  onReset,
151
191
  subaction,
152
192
  resetAfterSubmit,
193
+ disableFocusOnError,
153
194
  ...rest
154
195
  }: FormProps<DataType>) {
155
196
  const fieldErrorsFromBackend = useFieldErrorsFromBackend(fetcher, subaction);
156
197
  const [fieldErrors, setFieldErrors] = useFieldErrors(fieldErrorsFromBackend);
157
198
  const isSubmitting = useIsSubmitting(action, subaction, fetcher);
158
199
  const defaultsToUse = useDefaultValues(fieldErrorsFromBackend, defaultValues);
200
+ const [touchedFields, setTouchedFields] = useState<TouchedFields>({});
201
+ const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false);
159
202
  const formRef = useRef<HTMLFormElement>(null);
160
203
  useSubmitComplete(isSubmitting, () => {
161
204
  if (!fieldErrorsFromBackend && resetAfterSubmit) {
162
205
  formRef.current?.reset();
163
206
  }
164
207
  });
208
+ const customFocusHandlers = useMultiValueMap<string, () => void>();
165
209
 
166
210
  const contextValue = useMemo<FormContextValue>(
167
211
  () => ({
@@ -169,6 +213,13 @@ export function ValidatedForm<DataType>({
169
213
  action,
170
214
  defaultValues: defaultsToUse,
171
215
  isSubmitting: isSubmitting ?? false,
216
+ isValid: Object.keys(fieldErrors).length === 0,
217
+ touchedFields,
218
+ setFieldTouched: (fieldName: string, touched: boolean) =>
219
+ setTouchedFields((prev) => ({
220
+ ...prev,
221
+ [fieldName]: touched,
222
+ })),
172
223
  clearError: (fieldName) => {
173
224
  setFieldErrors((prev) => omit(prev, fieldName));
174
225
  },
@@ -183,16 +234,28 @@ export function ValidatedForm<DataType>({
183
234
  ...prev,
184
235
  [fieldName]: error,
185
236
  }));
237
+ } else {
238
+ setFieldErrors((prev) => omit(prev, fieldName));
186
239
  }
187
240
  },
241
+ registerReceiveFocus: (fieldName, handler) => {
242
+ customFocusHandlers().add(fieldName, handler);
243
+ return () => {
244
+ customFocusHandlers().remove(fieldName, handler);
245
+ };
246
+ },
247
+ hasBeenSubmitted,
188
248
  }),
189
249
  [
190
250
  fieldErrors,
191
251
  action,
192
252
  defaultsToUse,
193
253
  isSubmitting,
254
+ touchedFields,
255
+ hasBeenSubmitted,
194
256
  setFieldErrors,
195
257
  validator,
258
+ customFocusHandlers,
196
259
  ]
197
260
  );
198
261
 
@@ -204,10 +267,18 @@ export function ValidatedForm<DataType>({
204
267
  {...rest}
205
268
  action={action}
206
269
  onSubmit={(event) => {
270
+ setHasBeenSubmitted(true);
207
271
  const result = validator.validate(getDataFromForm(event.currentTarget));
208
272
  if (result.error) {
209
273
  event.preventDefault();
210
274
  setFieldErrors(result.error);
275
+ if (!disableFocusOnError) {
276
+ focusFirstInvalidInput(
277
+ result.error,
278
+ customFocusHandlers(),
279
+ formRef.current!
280
+ );
281
+ }
211
282
  } else {
212
283
  onSubmit?.(result.data, event);
213
284
  }
@@ -216,6 +287,8 @@ export function ValidatedForm<DataType>({
216
287
  onReset?.(event);
217
288
  if (event.defaultPrevented) return;
218
289
  setFieldErrors({});
290
+ setTouchedFields({});
291
+ setHasBeenSubmitted(false);
219
292
  }}
220
293
  >
221
294
  <FormContext.Provider value={contextValue}>