remix-validated-form 5.0.2 → 5.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remix-validated-form",
3
- "version": "5.0.2",
3
+ "version": "5.1.0",
4
4
  "description": "Form component and utils for easy form validation in remix",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "module": "./dist/index.esm.js",
@@ -1,427 +0,0 @@
1
- import {
2
- FetcherWithComponents,
3
- Form as RemixForm,
4
- FormMethod,
5
- useSubmit,
6
- SubmitOptions,
7
- FormEncType,
8
- } from "@remix-run/react";
9
- import React, {
10
- ComponentProps,
11
- FormEvent,
12
- RefObject,
13
- useCallback,
14
- useEffect,
15
- useMemo,
16
- useRef,
17
- useState,
18
- } from "react";
19
- import * as R from "remeda";
20
- import { useIsSubmitting, useIsValid } from "./hooks";
21
- import { FORM_ID_FIELD } from "./internal/constants";
22
- import {
23
- InternalFormContext,
24
- InternalFormContextValue,
25
- } from "./internal/formContext";
26
- import {
27
- useDefaultValuesFromLoader,
28
- useErrorResponseForForm,
29
- useHasActiveFormSubmit,
30
- useSetFieldErrors,
31
- } from "./internal/hooks";
32
- import { MultiValueMap, useMultiValueMap } from "./internal/MultiValueMap";
33
- import {
34
- SyncedFormProps,
35
- useRootFormStore,
36
- } from "./internal/state/createFormStore";
37
- import { useFormStore } from "./internal/state/storeHooks";
38
- import { useSubmitComplete } from "./internal/submissionCallbacks";
39
- import {
40
- mergeRefs,
41
- useDeepEqualsMemo,
42
- useIsomorphicLayoutEffect as useLayoutEffect,
43
- } from "./internal/util";
44
- import { FieldErrors, Validator } from "./validation/types";
45
-
46
- type SubactionData<
47
- DataType,
48
- Subaction extends string | undefined
49
- > = DataType & { subaction: Subaction };
50
-
51
- // Not all validation libraries support encoding a literal value in the schema type (e.g. yup).
52
- // This condition here allows us to provide strictness for users who are using a validation library that does support it,
53
- // but also allows us to support users who are using a validation library that doesn't support it.
54
- type DataForSubaction<
55
- DataType,
56
- Subaction extends string | undefined
57
- > = Subaction extends string // Not all validation libraries support encoding a literal value in the schema type.
58
- ? SubactionData<DataType, Subaction> extends undefined
59
- ? DataType
60
- : SubactionData<DataType, Subaction>
61
- : DataType;
62
-
63
- export type FormProps<DataType, Subaction extends string | undefined> = {
64
- /**
65
- * A `Validator` object that describes how to validate the form.
66
- */
67
- validator: Validator<DataType>;
68
- /**
69
- * A submit callback that gets called when the form is submitted
70
- * after all validations have been run.
71
- */
72
- onSubmit?: (
73
- data: DataForSubaction<DataType, Subaction>,
74
- event: React.FormEvent<HTMLFormElement>
75
- ) => void | Promise<void>;
76
- /**
77
- * Allows you to provide a `fetcher` from Remix's `useFetcher` hook.
78
- * The form will use the fetcher for loading states, action data, etc
79
- * instead of the default form action.
80
- */
81
- fetcher?: FetcherWithComponents<any>;
82
- /**
83
- * Accepts an object of default values for the form
84
- * that will automatically be propagated to the form fields via `useField`.
85
- */
86
- defaultValues?: Partial<DataForSubaction<DataType, Subaction>>;
87
- /**
88
- * A ref to the form element.
89
- */
90
- formRef?: React.RefObject<HTMLFormElement>;
91
- /**
92
- * An optional sub-action to use for the form.
93
- * Setting a value here will cause the form to be submitted with an extra `subaction` value.
94
- * This can be useful when there are multiple forms on the screen handled by the same action.
95
- */
96
- subaction?: Subaction;
97
- /**
98
- * Reset the form to the default values after the form has been successfully submitted.
99
- * This is useful if you want to submit the same form multiple times,
100
- * and don't redirect in-between submissions.
101
- */
102
- resetAfterSubmit?: boolean;
103
- /**
104
- * Normally, the first invalid input will be focused when the validation fails on form submit.
105
- * Set this to `false` to disable this behavior.
106
- */
107
- disableFocusOnError?: boolean;
108
- } & Omit<ComponentProps<typeof RemixForm>, "onSubmit">;
109
-
110
- const getDataFromForm = (el: HTMLFormElement) => new FormData(el);
111
-
112
- function nonNull<T>(value: T | null | undefined): value is T {
113
- return value !== null;
114
- }
115
-
116
- const focusFirstInvalidInput = (
117
- fieldErrors: FieldErrors,
118
- customFocusHandlers: MultiValueMap<string, () => void>,
119
- formElement: HTMLFormElement
120
- ) => {
121
- const namesInOrder = [...formElement.elements]
122
- .map((el) => {
123
- const input = el instanceof RadioNodeList ? el[0] : el;
124
- if (input instanceof HTMLElement && "name" in input)
125
- return (input as any).name;
126
- return null;
127
- })
128
- .filter(nonNull)
129
- .filter((name) => name in fieldErrors);
130
- const uniqueNamesInOrder = R.uniq(namesInOrder);
131
-
132
- for (const fieldName of uniqueNamesInOrder) {
133
- if (customFocusHandlers.has(fieldName)) {
134
- customFocusHandlers.getAll(fieldName).forEach((handler) => {
135
- handler();
136
- });
137
- break;
138
- }
139
-
140
- const elem = formElement.elements.namedItem(fieldName);
141
- if (!elem) continue;
142
-
143
- if (elem instanceof RadioNodeList) {
144
- const selectedRadio =
145
- [...elem]
146
- .filter(
147
- (item): item is HTMLInputElement => item instanceof HTMLInputElement
148
- )
149
- .find((item) => item.value === elem.value) ?? elem[0];
150
- if (selectedRadio && selectedRadio instanceof HTMLInputElement) {
151
- selectedRadio.focus();
152
- break;
153
- }
154
- }
155
-
156
- if (elem instanceof HTMLElement) {
157
- if (elem instanceof HTMLInputElement && elem.type === "hidden") {
158
- continue;
159
- }
160
-
161
- elem.focus();
162
- break;
163
- }
164
- }
165
- };
166
-
167
- const useFormId = (providedId?: string): string | symbol => {
168
- // We can use a `Symbol` here because we only use it after hydration
169
- const [symbolId] = useState(() => Symbol("remix-validated-form-id"));
170
- return providedId ?? symbolId;
171
- };
172
-
173
- /**
174
- * Use a component to access the state so we don't cause
175
- * any extra rerenders of the whole form.
176
- */
177
- const FormResetter = ({
178
- resetAfterSubmit,
179
- formRef,
180
- }: {
181
- resetAfterSubmit: boolean;
182
- formRef: RefObject<HTMLFormElement>;
183
- }) => {
184
- const isSubmitting = useIsSubmitting();
185
- const isValid = useIsValid();
186
- useSubmitComplete(isSubmitting, () => {
187
- if (isValid && resetAfterSubmit) {
188
- formRef.current?.reset();
189
- }
190
- });
191
- return null;
192
- };
193
-
194
- function formEventProxy<T extends object>(event: T): T {
195
- let defaultPrevented = false;
196
- return new Proxy(event, {
197
- get: (target, prop) => {
198
- if (prop === "preventDefault") {
199
- return () => {
200
- defaultPrevented = true;
201
- };
202
- }
203
-
204
- if (prop === "defaultPrevented") {
205
- return defaultPrevented;
206
- }
207
-
208
- return target[prop as keyof T];
209
- },
210
- }) as T;
211
- }
212
-
213
- type HTMLSubmitEvent = React.BaseSyntheticEvent<
214
- SubmitEvent,
215
- Event,
216
- HTMLFormElement
217
- >;
218
-
219
- type HTMLFormSubmitter = HTMLButtonElement | HTMLInputElement;
220
-
221
- /**
222
- * The primary form component of `remix-validated-form`.
223
- */
224
- export function ValidatedForm<DataType, Subaction extends string | undefined>({
225
- validator,
226
- onSubmit,
227
- children,
228
- fetcher,
229
- action,
230
- defaultValues: unMemoizedDefaults,
231
- formRef: formRefProp,
232
- onReset,
233
- subaction,
234
- resetAfterSubmit = false,
235
- disableFocusOnError,
236
- method,
237
- replace,
238
- id,
239
- preventScrollReset,
240
- relative,
241
- encType,
242
- ...rest
243
- }: FormProps<DataType, Subaction>) {
244
- const formId = useFormId(id);
245
- const providedDefaultValues = useDeepEqualsMemo(unMemoizedDefaults);
246
- const contextValue = useMemo<InternalFormContextValue>(
247
- () => ({
248
- formId,
249
- action,
250
- subaction,
251
- defaultValuesProp: providedDefaultValues,
252
- fetcher,
253
- }),
254
- [action, fetcher, formId, providedDefaultValues, subaction]
255
- );
256
- const backendError = useErrorResponseForForm(contextValue);
257
- const backendDefaultValues = useDefaultValuesFromLoader(contextValue);
258
- const hasActiveSubmission = useHasActiveFormSubmit(contextValue);
259
- const formRef = useRef<HTMLFormElement>(null);
260
- const Form = fetcher?.Form ?? RemixForm;
261
-
262
- const submit = useSubmit();
263
- const setFieldErrors = useSetFieldErrors(formId);
264
- const setFieldError = useFormStore(formId, (state) => state.setFieldError);
265
- const reset = useFormStore(formId, (state) => state.reset);
266
- const startSubmit = useFormStore(formId, (state) => state.startSubmit);
267
- const endSubmit = useFormStore(formId, (state) => state.endSubmit);
268
- const syncFormProps = useFormStore(formId, (state) => state.syncFormProps);
269
- const setFormElementInState = useFormStore(
270
- formId,
271
- (state) => state.setFormElement
272
- );
273
- const cleanupForm = useRootFormStore((state) => state.cleanupForm);
274
- const registerForm = useRootFormStore((state) => state.registerForm);
275
-
276
- const customFocusHandlers = useMultiValueMap<string, () => void>();
277
- const registerReceiveFocus: SyncedFormProps["registerReceiveFocus"] =
278
- useCallback(
279
- (fieldName, handler) => {
280
- customFocusHandlers().add(fieldName, handler);
281
- return () => {
282
- customFocusHandlers().remove(fieldName, handler);
283
- };
284
- },
285
- [customFocusHandlers]
286
- );
287
-
288
- // TODO: all these hooks running at startup cause extra, unnecessary renders
289
- // There must be a nice way to avoid this.
290
- useLayoutEffect(() => {
291
- registerForm(formId);
292
- return () => cleanupForm(formId);
293
- }, [cleanupForm, formId, registerForm]);
294
-
295
- useLayoutEffect(() => {
296
- syncFormProps({
297
- action,
298
- defaultValues: providedDefaultValues ?? backendDefaultValues ?? {},
299
- subaction,
300
- registerReceiveFocus,
301
- validator,
302
- });
303
- }, [
304
- action,
305
- providedDefaultValues,
306
- registerReceiveFocus,
307
- subaction,
308
- syncFormProps,
309
- backendDefaultValues,
310
- validator,
311
- ]);
312
-
313
- useLayoutEffect(() => {
314
- setFormElementInState(formRef.current);
315
- }, [setFormElementInState]);
316
-
317
- useEffect(() => {
318
- setFieldErrors(backendError?.fieldErrors ?? {});
319
- if (!disableFocusOnError && backendError?.fieldErrors) {
320
- focusFirstInvalidInput(
321
- backendError.fieldErrors,
322
- customFocusHandlers(),
323
- formRef.current!
324
- );
325
- }
326
- }, [
327
- backendError?.fieldErrors,
328
- customFocusHandlers,
329
- disableFocusOnError,
330
- setFieldErrors,
331
- setFieldError,
332
- ]);
333
-
334
- useSubmitComplete(hasActiveSubmission, () => {
335
- endSubmit();
336
- });
337
-
338
- const handleSubmit = async (
339
- e: FormEvent<HTMLFormElement>,
340
- target: typeof e.currentTarget,
341
- nativeEvent: HTMLSubmitEvent["nativeEvent"]
342
- ) => {
343
- startSubmit();
344
- const submitter = nativeEvent.submitter as HTMLFormSubmitter | null;
345
- const formMethod = (submitter?.formMethod as FormMethod) || method;
346
- const formData = getDataFromForm(target);
347
- if (submitter?.name) {
348
- formData.append(submitter.name, submitter.value);
349
- }
350
-
351
- const result = await validator.validate(formData);
352
- if (result.error) {
353
- setFieldErrors(result.error.fieldErrors);
354
- endSubmit();
355
- if (!disableFocusOnError) {
356
- focusFirstInvalidInput(
357
- result.error.fieldErrors,
358
- customFocusHandlers(),
359
- formRef.current!
360
- );
361
- }
362
- } else {
363
- setFieldErrors({});
364
- const eventProxy = formEventProxy(e);
365
- await onSubmit?.(result.data as any, eventProxy);
366
- if (eventProxy.defaultPrevented) {
367
- endSubmit();
368
- return;
369
- }
370
-
371
- const opts: SubmitOptions = {
372
- method: formMethod,
373
- replace,
374
- preventScrollReset,
375
- relative,
376
- action,
377
- encType: encType as FormEncType | undefined,
378
- };
379
-
380
- // We deviate from the Remix code here a bit because of our async submit.
381
- // In Remix's `FormImpl`, they use `event.currentTarget` to get the form,
382
- // but we already have the form in `formRef.current` so we can just use that.
383
- // If we use `event.currentTarget` here, it will break because `currentTarget`
384
- // will have changed since the start of the submission.
385
- if (fetcher) fetcher.submit(formData, opts);
386
- else submit(formData, opts);
387
- }
388
- };
389
-
390
- return (
391
- <Form
392
- ref={mergeRefs([formRef, formRefProp])}
393
- {...rest}
394
- id={id}
395
- action={action}
396
- method={method}
397
- encType={encType}
398
- replace={replace}
399
- preventScrollReset={preventScrollReset}
400
- relative={relative}
401
- onSubmit={(e) => {
402
- e.preventDefault();
403
- handleSubmit(
404
- e,
405
- e.currentTarget,
406
- (e as unknown as HTMLSubmitEvent).nativeEvent
407
- );
408
- }}
409
- onReset={(event) => {
410
- onReset?.(event);
411
- if (event.defaultPrevented) return;
412
- reset();
413
- }}
414
- >
415
- <InternalFormContext.Provider value={contextValue}>
416
- <>
417
- <FormResetter formRef={formRef} resetAfterSubmit={resetAfterSubmit} />
418
- {subaction && (
419
- <input type="hidden" value={subaction} name="subaction" />
420
- )}
421
- {id && <input type="hidden" value={id} name={FORM_ID_FIELD} />}
422
- {children}
423
- </>
424
- </InternalFormContext.Provider>
425
- </Form>
426
- );
427
- }
package/src/hooks.ts DELETED
@@ -1,160 +0,0 @@
1
- import { useEffect, useMemo } from "react";
2
- import {
3
- createGetInputProps,
4
- GetInputProps,
5
- ValidationBehaviorOptions,
6
- } from "./internal/getInputProps";
7
- import {
8
- useInternalFormContext,
9
- useFieldTouched,
10
- useFieldError,
11
- useFieldDefaultValue,
12
- useClearError,
13
- useInternalIsSubmitting,
14
- useInternalIsValid,
15
- useInternalHasBeenSubmitted,
16
- useRegisterReceiveFocus,
17
- useSmartValidate,
18
- } from "./internal/hooks";
19
- import {
20
- useControllableValue,
21
- useUpdateControllableValue,
22
- } from "./internal/state/controlledFields";
23
-
24
- /**
25
- * Returns whether or not the parent form is currently being submitted.
26
- * This is different from Remix's `useNavigation()` in that it
27
- * is aware of what form it's in and when _that_ form is being submitted.
28
- *
29
- * @param formId
30
- */
31
- export const useIsSubmitting = (formId?: string) => {
32
- const formContext = useInternalFormContext(formId, "useIsSubmitting");
33
- return useInternalIsSubmitting(formContext.formId);
34
- };
35
-
36
- /**
37
- * Returns whether or not the current form is valid.
38
- *
39
- * @param formId the id of the form. Only necessary if being used outside a ValidatedForm.
40
- */
41
- export const useIsValid = (formId?: string) => {
42
- const formContext = useInternalFormContext(formId, "useIsValid");
43
- return useInternalIsValid(formContext.formId);
44
- };
45
-
46
- export type FieldProps = {
47
- /**
48
- * The validation error message if there is one.
49
- */
50
- error?: string;
51
- /**
52
- * Clears the error message.
53
- */
54
- clearError: () => void;
55
- /**
56
- * Validates the field.
57
- */
58
- validate: () => void;
59
- /**
60
- * The default value of the field, if there is one.
61
- */
62
- defaultValue?: any;
63
- /**
64
- * Whether or not the field has been touched.
65
- */
66
- touched: boolean;
67
- /**
68
- * Helper to set the touched state of the field.
69
- */
70
- setTouched: (touched: boolean) => void;
71
- /**
72
- * Helper to get all the props necessary for a regular input.
73
- */
74
- getInputProps: GetInputProps;
75
- };
76
-
77
- /**
78
- * Provides the data and helpers necessary to set up a field.
79
- */
80
- export const useField = (
81
- name: string,
82
- options?: {
83
- /**
84
- * Allows you to configure a custom function that will be called
85
- * when the input needs to receive focus due to a validation error.
86
- * This is useful for custom components that use a hidden input.
87
- */
88
- handleReceiveFocus?: () => void;
89
- /**
90
- * Allows you to specify when a field gets validated (when using getInputProps)
91
- */
92
- validationBehavior?: Partial<ValidationBehaviorOptions>;
93
- /**
94
- * The formId of the form you want to use.
95
- * This is not necesary if the input is used inside a form.
96
- */
97
- formId?: string;
98
- }
99
- ): FieldProps => {
100
- const { formId: providedFormId, handleReceiveFocus } = options ?? {};
101
- const formContext = useInternalFormContext(providedFormId, "useField");
102
-
103
- const defaultValue = useFieldDefaultValue(name, formContext);
104
- const [touched, setTouched] = useFieldTouched(name, formContext);
105
- const error = useFieldError(name, formContext);
106
- const clearError = useClearError(formContext);
107
-
108
- const hasBeenSubmitted = useInternalHasBeenSubmitted(formContext.formId);
109
- const smartValidate = useSmartValidate(formContext.formId);
110
- const registerReceiveFocus = useRegisterReceiveFocus(formContext.formId);
111
-
112
- useEffect(() => {
113
- if (handleReceiveFocus)
114
- return registerReceiveFocus(name, handleReceiveFocus);
115
- }, [handleReceiveFocus, name, registerReceiveFocus]);
116
-
117
- const field = useMemo<FieldProps>(() => {
118
- const helpers = {
119
- error,
120
- clearError: () => clearError(name),
121
- validate: () => smartValidate({ alwaysIncludeErrorsFromFields: [name] }),
122
- defaultValue,
123
- touched,
124
- setTouched,
125
- };
126
- const getInputProps = createGetInputProps({
127
- ...helpers,
128
- name,
129
- hasBeenSubmitted,
130
- validationBehavior: options?.validationBehavior,
131
- });
132
- return {
133
- ...helpers,
134
- getInputProps,
135
- };
136
- }, [
137
- error,
138
- clearError,
139
- defaultValue,
140
- touched,
141
- setTouched,
142
- name,
143
- hasBeenSubmitted,
144
- options?.validationBehavior,
145
- smartValidate,
146
- ]);
147
-
148
- return field;
149
- };
150
-
151
- export const useControlField = <T>(name: string, formId?: string) => {
152
- const context = useInternalFormContext(formId, "useControlField");
153
- const [value, setValue] = useControllableValue(context, name);
154
- return [value as T, setValue as (value: T) => void] as const;
155
- };
156
-
157
- export const useUpdateControlledField = (formId?: string) => {
158
- const context = useInternalFormContext(formId, "useControlField");
159
- return useUpdateControllableValue(context.formId);
160
- };
package/src/index.ts DELETED
@@ -1,12 +0,0 @@
1
- export * from "./hooks";
2
- export * from "./server";
3
- export * from "./ValidatedForm";
4
- export * from "./validation/types";
5
- export * from "./validation/createValidator";
6
- export * from "./userFacingFormContext";
7
- export {
8
- FieldArray,
9
- useFieldArray,
10
- type FieldArrayProps,
11
- type FieldArrayHelpers,
12
- } from "./internal/state/fieldArray";
@@ -1,44 +0,0 @@
1
- import { useCallback, useRef } from "react";
2
-
3
- export class MultiValueMap<Key, Value> {
4
- private dict: Map<Key, Value[]> = new Map();
5
-
6
- add = (key: Key, value: Value) => {
7
- if (this.dict.has(key)) {
8
- this.dict.get(key)!.push(value);
9
- } else {
10
- this.dict.set(key, [value]);
11
- }
12
- };
13
-
14
- delete = (key: Key) => {
15
- this.dict.delete(key);
16
- };
17
-
18
- remove = (key: Key, value: Value) => {
19
- if (!this.dict.has(key)) return;
20
- const array = this.dict.get(key)!;
21
- const index = array.indexOf(value);
22
- if (index !== -1) array.splice(index, 1);
23
- if (array.length === 0) this.dict.delete(key);
24
- };
25
-
26
- getAll = (key: Key): Value[] => {
27
- return this.dict.get(key) ?? [];
28
- };
29
-
30
- entries = (): IterableIterator<[Key, Value[]]> => this.dict.entries();
31
-
32
- values = (): IterableIterator<Value[]> => this.dict.values();
33
-
34
- has = (key: Key): boolean => this.dict.has(key);
35
- }
36
-
37
- export const useMultiValueMap = <Key, Value>() => {
38
- const ref = useRef<MultiValueMap<Key, Value> | null>(null);
39
- return useCallback(() => {
40
- if (ref.current) return ref.current;
41
- ref.current = new MultiValueMap();
42
- return ref.current;
43
- }, []);
44
- };
@@ -1,4 +0,0 @@
1
- export const FORM_ID_FIELD = "__rvfInternalFormId" as const;
2
- export const FORM_DEFAULTS_FIELD = "__rvfInternalFormDefaults" as const;
3
- export const formDefaultValuesKey = (formId: string) =>
4
- `${FORM_DEFAULTS_FIELD}_${formId}`;
@@ -1,12 +0,0 @@
1
- import { setPath } from "set-get";
2
- import { MultiValueMap } from "./MultiValueMap";
3
-
4
- export const objectFromPathEntries = (entries: [string, any][]) => {
5
- const map = new MultiValueMap<string, any>();
6
- entries.forEach(([key, value]) => map.add(key, value));
7
- return [...map.entries()].reduce(
8
- (acc, [key, value]) =>
9
- setPath(acc, key, value.length === 1 ? value[0] : value),
10
- {} as Record<string, any>
11
- );
12
- };
@@ -1,13 +0,0 @@
1
- import { FetcherWithComponents } from "@remix-run/react";
2
- import { createContext } from "react";
3
-
4
- export type InternalFormContextValue = {
5
- formId: string | symbol;
6
- action?: string;
7
- subaction?: string;
8
- defaultValuesProp?: { [fieldName: string]: any };
9
- fetcher?: FetcherWithComponents<unknown>;
10
- };
11
-
12
- export const InternalFormContext =
13
- createContext<InternalFormContextValue | null>(null);