UNPKG

@conform-to/react

Version:

Conform view adapter for react

744 lines (719 loc) 30 kB
'use client'; import { objectSpread2 as _objectSpread2 } from '../_virtual/_rollupPluginBabelHelpers.mjs'; import { DEFAULT_INTENT_NAME, createGlobalFormsObserver, serialize, isFieldElement, deepEqual, change, focus, blur, getFormData, parseSubmission, report, createSubmitEvent } from '@conform-to/dom/future'; import { createContext, useContext, useMemo, useId, useRef, useEffect, useSyncExternalStore, useCallback, useState, useLayoutEffect } from 'react'; import { resolveStandardSchemaResult, resolveValidateResult, appendUniqueItem } from './util.mjs'; import { isTouched, getFormMetadata, getFieldset, getField, initializeState, updateState } from './state.mjs'; import { deserializeIntent, actionHandlers, applyIntent } from './intent.mjs'; import { focusFirstInvalidField, getFormElement, createIntentDispatcher, createDefaultSnapshot, getRadioGroupValue, getCheckboxGroupValue, getInputSnapshot, makeInputFocusable, initializeField, updateFormValue, getSubmitEvent } from './dom.mjs'; import { jsx } from 'react/jsx-runtime'; var INITIAL_KEY = 'INITIAL_KEY'; var FormConfig = /*#__PURE__*/createContext({ intentName: DEFAULT_INTENT_NAME, observer: createGlobalFormsObserver(), serialize }); var Form = /*#__PURE__*/createContext([]); /** * Provides form context to child components. * Stacks contexts to support nested forms, with latest context taking priority. */ function FormProvider(props) { var stack = useContext(Form); var value = useMemo( // Put the latest form context first to ensure that to be the first one found () => [props.context].concat(stack), [stack, props.context]); return /*#__PURE__*/jsx(Form.Provider, { value: value, children: props.children }); } function useFormContext(formId) { var contexts = useContext(Form); var context = formId ? contexts.find(context => formId === context.formId) : contexts[0]; if (!context) { throw new Error('No form context found; Have you render a <FormProvider /> with the corresponding form context?'); } return context; } /** * Core form hook that manages form state, validation, and submission. * Handles both sync and async validation, intent dispatching, and DOM updates. */ function useConform(formRef, options) { var { lastResult } = options; var [state, setState] = useState(() => { var state = initializeState(INITIAL_KEY); if (lastResult) { state = updateState(state, _objectSpread2(_objectSpread2({}, lastResult), {}, { type: 'initialize', intent: lastResult.submission.intent ? deserializeIntent(lastResult.submission.intent) : null, ctx: { handlers: actionHandlers, reset: () => state } })); } return state; }); var keyRef = useRef(options.key); var resetKeyRef = useRef(state.resetKey); var optionsRef = useLatest(options); var lastResultRef = useRef(lastResult); var lastIntentedValueRef = useRef(); var lastAsyncResultRef = useRef(null); var abortControllerRef = useRef(null); var handleSubmission = useCallback((result, options) => { var _optionsRef$current$o, _optionsRef$current; var intent = result.submission.intent ? deserializeIntent(result.submission.intent) : null; setState(state => updateState(state, _objectSpread2(_objectSpread2({}, result), {}, { type: options.type, intent, ctx: { handlers: actionHandlers, reset() { return initializeState(); } } }))); var formElement = getFormElement(formRef); if (!formElement || !result.error) { return; } (_optionsRef$current$o = (_optionsRef$current = optionsRef.current).onError) === null || _optionsRef$current$o === void 0 || _optionsRef$current$o.call(_optionsRef$current, { formElement, error: result.error, intent }); }, [formRef, optionsRef]); useEffect(() => { return () => { var _abortControllerRef$c; // Cancel pending validation request (_abortControllerRef$c = abortControllerRef.current) === null || _abortControllerRef$c === void 0 || _abortControllerRef$c.abort('The component is unmounted'); }; }, []); useEffect(() => { // To avoid re-applying the same result twice if (lastResult && lastResult !== lastResultRef.current) { handleSubmission(lastResult, { type: 'server' }); lastResultRef.current = lastResult; } }, [lastResult, handleSubmission]); useEffect(() => { // Reset the form state if the form key changes if (options.key !== keyRef.current) { keyRef.current = options.key; setState(initializeState()); } }, [options.key]); useEffect(() => { var formElement = getFormElement(formRef); // Reset the form values if the reset key changes if (formElement && state.resetKey !== resetKeyRef.current) { resetKeyRef.current = state.resetKey; formElement.reset(); } }, [formRef, state.resetKey]); useEffect(() => { if (!state.clientIntendedValue) { return; } var formElement = getFormElement(formRef); if (!formElement) { // eslint-disable-next-line no-console console.error('Failed to update form value; No form element found'); return; } updateFormValue(formElement, state.clientIntendedValue, optionsRef.current.serialize); lastIntentedValueRef.current = undefined; }, [formRef, state.clientIntendedValue, optionsRef]); var handleSubmit = useCallback(event => { var _abortControllerRef$c2, _lastAsyncResultRef$c; var abortController = new AbortController(); // Keep track of the abort controller so we can cancel the previous request if a new one is made (_abortControllerRef$c2 = abortControllerRef.current) === null || _abortControllerRef$c2 === void 0 || _abortControllerRef$c2.abort('A new submission is made'); abortControllerRef.current = abortController; var formData; var result; var resolvedValue; // The form might be re-submitted manually if there was an async validation if (event.nativeEvent === ((_lastAsyncResultRef$c = lastAsyncResultRef.current) === null || _lastAsyncResultRef$c === void 0 ? void 0 : _lastAsyncResultRef$c.event)) { formData = lastAsyncResultRef.current.formData; result = lastAsyncResultRef.current.result; resolvedValue = lastAsyncResultRef.current.resolvedValue; } else { var _optionsRef$current$o2, _optionsRef$current2; var formElement = event.currentTarget; var submitEvent = getSubmitEvent(event); formData = getFormData(formElement, submitEvent.submitter); var submission = parseSubmission(formData, { intentName: optionsRef.current.intentName }); // Patch missing fields in the submission object for (var element of formElement.elements) { if (isFieldElement(element) && element.name) { appendUniqueItem(submission.fields, element.name); } } // Override submission value if the last intended value is not applied yet (i.e. batch updates) if (lastIntentedValueRef.current != null) { submission.payload = lastIntentedValueRef.current; } var intendedValue = applyIntent(submission); // Update the last intended value in case there will be another intent dispatched lastIntentedValueRef.current = intendedValue === submission.payload ? undefined : intendedValue; var submissionResult = report(submission, { keepFiles: true, intendedValue }); var validateResult = // Skip validation on form reset intendedValue !== null ? (_optionsRef$current$o2 = (_optionsRef$current2 = optionsRef.current).onValidate) === null || _optionsRef$current$o2 === void 0 ? void 0 : _optionsRef$current$o2.call(_optionsRef$current2, { payload: intendedValue, error: { formErrors: [], fieldErrors: {} }, intent: submission.intent ? deserializeIntent(submission.intent) : null, formElement, submitter: submitEvent.submitter, formData }) : { error: null }; var { syncResult, asyncResult } = resolveValidateResult(validateResult); if (typeof syncResult !== 'undefined') { submissionResult.error = syncResult.error; resolvedValue = syncResult.value; } if (typeof asyncResult !== 'undefined') { // Update the form when the validation result is resolved asyncResult.then(_ref => { var { error, value } = _ref; // Update the form with the validation result // There is no need to flush the update in this case if (!abortController.signal.aborted) { submissionResult.error = error; handleSubmission(submissionResult, { type: 'server' }); // If the form is meant to be submitted and there is no error if (error === null && !submission.intent) { var _event = createSubmitEvent(submitEvent.submitter); // Keep track of the submit event so we can skip validation on the next submit lastAsyncResultRef.current = { event: _event, formData, resolvedValue: value, result: submissionResult }; formElement.dispatchEvent(_event); } } }); } handleSubmission(submissionResult, { type: 'client' }); if ( // If client validation happens (typeof syncResult !== 'undefined' || typeof asyncResult !== 'undefined') && ( // Either the form is not meant to be submitted (i.e. intent is present) or there is an error / pending validation submissionResult.submission.intent || submissionResult.error !== null)) { event.preventDefault(); } result = submissionResult; } // We might not prevent form submission if server validation is required // But the `onSubmit` handler should be triggered only if there is no intent if (!event.isDefaultPrevented() && result.submission.intent === null) { var _optionsRef$current$o3, _optionsRef$current3; (_optionsRef$current$o3 = (_optionsRef$current3 = optionsRef.current).onSubmit) === null || _optionsRef$current$o3 === void 0 || _optionsRef$current$o3.call(_optionsRef$current3, event, { formData, get value() { if (typeof resolvedValue === 'undefined') { throw new Error('`value` is not available; Please make sure you have included the value in the `onValidate` result.'); } return resolvedValue; }, update(options) { if (!abortController.signal.aborted) { var _submissionResult = report(result.submission, _objectSpread2(_objectSpread2({}, options), {}, { keepFiles: true })); handleSubmission(_submissionResult, { type: 'server' }); } } }); } }, [handleSubmission, optionsRef]); return [state, handleSubmit]; } /** * The main React hook for form management. Handles form state, validation, and submission * while providing access to form metadata, field objects, and form actions. * * @see https://conform.guide/api/react/future/useForm * @example * ```tsx * const { form, fields } = useForm({ * onValidate({ payload, error }) { * if (!payload.email) { * error.fieldErrors.email = ['Required']; * } * return error; * } * }); * * return ( * <form {...form.props}> * <input name={fields.email.name} defaultValue={fields.email.defaultValue} /> * <div>{fields.email.errors}</div> * </form> * ); * ``` */ function useForm(options) { var _optionsRef$current$o4; var { id, defaultValue, constraint } = options; var config = useContext(FormConfig); var optionsRef = useLatest(options); var fallbackId = useId(); var formId = id !== null && id !== void 0 ? id : "form-".concat(fallbackId); var [state, handleSubmit] = useConform(formId, _objectSpread2(_objectSpread2({}, options), {}, { serialize: config.serialize, intentName: config.intentName, onError: (_optionsRef$current$o4 = optionsRef.current.onError) !== null && _optionsRef$current$o4 !== void 0 ? _optionsRef$current$o4 : focusFirstInvalidField, onValidate(ctx) { var _options$onValidate, _options$onValidate2; if (options.schema) { var standardResult = options.schema['~standard'].validate(ctx.payload); if (standardResult instanceof Promise) { return standardResult.then(actualStandardResult => { if (typeof options.onValidate === 'function') { throw new Error('The "onValidate" handler is not supported when used with asynchronous schema validation.'); } return resolveStandardSchemaResult(actualStandardResult); }); } var resolvedResult = resolveStandardSchemaResult(standardResult); if (!options.onValidate) { return resolvedResult; } // Update the schema error in the context if (resolvedResult.error) { ctx.error = resolvedResult.error; } var validateResult = resolveValidateResult(options.onValidate(ctx)); if (validateResult.syncResult) { var _validateResult$syncR, _validateResult$syncR2; (_validateResult$syncR2 = (_validateResult$syncR = validateResult.syncResult).value) !== null && _validateResult$syncR2 !== void 0 ? _validateResult$syncR2 : _validateResult$syncR.value = resolvedResult.value; } if (validateResult.asyncResult) { validateResult.asyncResult = validateResult.asyncResult.then(result => { var _result$value; (_result$value = result.value) !== null && _result$value !== void 0 ? _result$value : result.value = resolvedResult.value; return result; }); } return [validateResult.syncResult, validateResult.asyncResult]; } return (_options$onValidate = (_options$onValidate2 = options.onValidate) === null || _options$onValidate2 === void 0 ? void 0 : _options$onValidate2.call(options, ctx)) !== null && _options$onValidate !== void 0 ? _options$onValidate : { // To avoid conform falling back to server validation, // if neither schema nor validation handler is provided, // we just treat it as a valid client submission error: null }; } })); var intent = useIntent(formId); var context = useMemo(() => ({ formId, state, defaultValue: defaultValue !== null && defaultValue !== void 0 ? defaultValue : null, constraint: constraint !== null && constraint !== void 0 ? constraint : null, handleSubmit: handleSubmit, handleInput(event) { var _optionsRef$current$o5, _optionsRef$current4; if (!isFieldElement(event.target) || event.target.name === '' || event.target.form === null || event.target.form !== getFormElement(formId)) { return; } (_optionsRef$current$o5 = (_optionsRef$current4 = optionsRef.current).onInput) === null || _optionsRef$current$o5 === void 0 || _optionsRef$current$o5.call(_optionsRef$current4, _objectSpread2(_objectSpread2({}, event), {}, { target: event.target, currentTarget: event.target.form })); if (event.defaultPrevented) { return; } var { shouldValidate = 'onSubmit', shouldRevalidate = shouldValidate } = optionsRef.current; if (isTouched(state, event.target.name) ? shouldRevalidate === 'onInput' : shouldValidate === 'onInput') { intent.validate(event.target.name); } }, handleBlur(event) { var _optionsRef$current$o6, _optionsRef$current5; if (!isFieldElement(event.target) || event.target.name === '' || event.target.form === null || event.target.form !== getFormElement(formId)) { return; } (_optionsRef$current$o6 = (_optionsRef$current5 = optionsRef.current).onBlur) === null || _optionsRef$current$o6 === void 0 || _optionsRef$current$o6.call(_optionsRef$current5, _objectSpread2(_objectSpread2({}, event), {}, { target: event.target, currentTarget: event.target.form })); if (event.defaultPrevented) { return; } var { shouldValidate = 'onSubmit', shouldRevalidate = shouldValidate } = optionsRef.current; if (isTouched(state, event.target.name) ? shouldRevalidate === 'onBlur' : shouldValidate === 'onBlur') { intent.validate(event.target.name); } } }), [formId, state, defaultValue, constraint, handleSubmit, intent, optionsRef]); var form = useMemo(() => getFormMetadata(context, { serialize: config.serialize }), [context, config.serialize]); var fields = useMemo(() => getFieldset(context, { serialize: config.serialize }), [context, config.serialize]); return { form, fields, intent }; } /** * A React hook that provides access to form-level metadata and state. * Requires `FormProvider` context when used in child components. * * @see https://conform.guide/api/react/future/useFormMetadata * @example * ```tsx * function ErrorSummary() { * const form = useFormMetadata(); * * if (form.valid) return null; * * return ( * <div>Please fix {Object.keys(form.fieldErrors).length} errors</div> * ); * } * ``` */ function useFormMetadata() { var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var config = useContext(FormConfig); var context = useFormContext(options.formId); var formMetadata = useMemo(() => getFormMetadata(context, { serialize: config.serialize }), [context, config.serialize]); return formMetadata; } /** * A React hook that provides access to a specific field's metadata and state. * Requires `FormProvider` context when used in child components. * * @see https://conform.guide/api/react/future/useField * @example * ```tsx * function FormField({ name, label }) { * const field = useField(name); * * return ( * <div> * <label htmlFor={field.id}>{label}</label> * <input id={field.id} name={field.name} defaultValue={field.defaultValue} /> * {field.errors && <div>{field.errors.join(', ')}</div>} * </div> * ); * } * ``` */ function useField(name) { var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; var config = useContext(FormConfig); var context = useFormContext(options.formId); var field = useMemo(() => getField(context, { name, serialize: config.serialize }), [context, name, config.serialize]); return field; } /** * A React hook that provides an intent dispatcher for programmatic form actions. * Intent dispatchers allow you to trigger form operations like validation, field updates, * and array manipulations without manual form submission. * * @see https://conform.guide/api/react/future/useIntent * @example * ```tsx * function ResetButton() { * const buttonRef = useRef<HTMLButtonElement>(null); * const intent = useIntent(buttonRef); * * return ( * <button type="button" ref={buttonRef} onClick={() => intent.reset()}> * Reset Form * </button> * ); * } * ``` */ function useIntent(formRef) { var config = useContext(FormConfig); return useMemo(() => createIntentDispatcher(() => getFormElement(formRef), config.intentName), [formRef, config.intentName]); } /** * A React hook that lets you sync the state of an input and dispatch native form events from it. * This is useful when emulating native input behavior — typically by rendering a hidden base input * and syncing it with a custom input. * * @example * ```ts * const control = useControl(options); * ``` */ function useControl(options) { var { observer } = useContext(FormConfig); var inputRef = useRef(null); var eventDispatched = useRef({}); var defaultSnapshot = createDefaultSnapshot(options === null || options === void 0 ? void 0 : options.defaultValue, options === null || options === void 0 ? void 0 : options.defaultChecked, options === null || options === void 0 ? void 0 : options.value); var snapshotRef = useRef(defaultSnapshot); var optionsRef = useRef(options); useEffect(() => { optionsRef.current = options; }); // This is necessary to ensure that input is re-registered // if the onFocus handler changes var shouldHandleFocus = typeof (options === null || options === void 0 ? void 0 : options.onFocus) === 'function'; var snapshot = useSyncExternalStore(useCallback(callback => observer.onFieldUpdate(event => { var input = event.target; if (Array.isArray(inputRef.current) ? inputRef.current.some(item => item === input) : inputRef.current === input) { callback(); } }), [observer]), () => { var input = inputRef.current; var prev = snapshotRef.current; var next = !input ? defaultSnapshot : Array.isArray(input) ? { value: getRadioGroupValue(input), options: getCheckboxGroupValue(input) } : getInputSnapshot(input); if (deepEqual(prev, next)) { return prev; } snapshotRef.current = next; return next; }, () => snapshotRef.current); useEffect(() => { var createEventListener = listener => { return event => { if (Array.isArray(inputRef.current) ? inputRef.current.some(item => item === event.target) : inputRef.current === event.target) { var timer = eventDispatched.current[listener]; if (timer) { clearTimeout(timer); } eventDispatched.current[listener] = window.setTimeout(() => { eventDispatched.current[listener] = undefined; }); if (listener === 'focus') { var _optionsRef$current6, _optionsRef$current6$; (_optionsRef$current6 = optionsRef.current) === null || _optionsRef$current6 === void 0 || (_optionsRef$current6$ = _optionsRef$current6.onFocus) === null || _optionsRef$current6$ === void 0 || _optionsRef$current6$.call(_optionsRef$current6); } } }; }; var inputHandler = createEventListener('change'); var focusHandler = createEventListener('focus'); var blurHandler = createEventListener('blur'); document.addEventListener('input', inputHandler, true); document.addEventListener('focusin', focusHandler, true); document.addEventListener('focusout', blurHandler, true); return () => { document.removeEventListener('input', inputHandler, true); document.removeEventListener('focusin', focusHandler, true); document.removeEventListener('focusout', blurHandler, true); }; }, []); return { value: snapshot.value, checked: snapshot.checked, options: snapshot.options, files: snapshot.files, register: useCallback(element => { if (!element) { inputRef.current = null; } else if (isFieldElement(element)) { inputRef.current = element; if (shouldHandleFocus) { makeInputFocusable(element); } if (element.type === 'checkbox' || element.type === 'radio') { var _optionsRef$current$v, _optionsRef$current7; // React set the value as empty string incorrectly when the value is undefined // This make sure the checkbox value falls back to the default value "on" properly // @see https://github.com/facebook/react/issues/17590 element.value = (_optionsRef$current$v = (_optionsRef$current7 = optionsRef.current) === null || _optionsRef$current7 === void 0 ? void 0 : _optionsRef$current7.value) !== null && _optionsRef$current$v !== void 0 ? _optionsRef$current$v : 'on'; } initializeField(element, optionsRef.current); } else { var _inputs$0$name, _inputs$, _inputs$0$type, _inputs$2; var inputs = Array.from(element); var name = (_inputs$0$name = (_inputs$ = inputs[0]) === null || _inputs$ === void 0 ? void 0 : _inputs$.name) !== null && _inputs$0$name !== void 0 ? _inputs$0$name : ''; var type = (_inputs$0$type = (_inputs$2 = inputs[0]) === null || _inputs$2 === void 0 ? void 0 : _inputs$2.type) !== null && _inputs$0$type !== void 0 ? _inputs$0$type : ''; if (!name || !(type === 'checkbox' || type === 'radio') || !inputs.every(input => input.name === name && input.type === type)) { throw new Error('You can only register a checkbox or radio group with the same name'); } inputRef.current = inputs; for (var input of inputs) { var _optionsRef$current8; if (shouldHandleFocus) { makeInputFocusable(input); } initializeField(input, { // We will not be uitlizing defaultChecked / value on checkbox / radio group defaultValue: (_optionsRef$current8 = optionsRef.current) === null || _optionsRef$current8 === void 0 ? void 0 : _optionsRef$current8.defaultValue }); } } }, [shouldHandleFocus]), change: useCallback(value => { if (!eventDispatched.current.change) { var _inputRef$current; var element = Array.isArray(inputRef.current) ? (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 ? void 0 : _inputRef$current.find(input => { var wasChecked = input.checked; var isChecked = Array.isArray(value) ? value.some(item => item === input.value) : input.value === value; switch (input.type) { case 'checkbox': // We assume that only one checkbox can be checked at a time // So we will pick the first element with checked state changed return wasChecked !== isChecked; case 'radio': // We cannot uncheck a radio button // So we will pick the first element that should be checked return isChecked; default: return false; } }) : inputRef.current; if (element) { change(element, typeof value === 'boolean' ? value ? element.value : null : value); } } if (eventDispatched.current.change) { clearTimeout(eventDispatched.current.change); } eventDispatched.current.change = undefined; }, []), focus: useCallback(() => { if (!eventDispatched.current.focus) { var element = Array.isArray(inputRef.current) ? inputRef.current[0] : inputRef.current; if (element) { focus(element); } } if (eventDispatched.current.focus) { clearTimeout(eventDispatched.current.focus); } eventDispatched.current.focus = undefined; }, []), blur: useCallback(() => { if (!eventDispatched.current.blur) { var element = Array.isArray(inputRef.current) ? inputRef.current[0] : inputRef.current; if (element) { blur(element); } } if (eventDispatched.current.blur) { clearTimeout(eventDispatched.current.blur); } eventDispatched.current.blur = undefined; }, []) }; } /** * A React hook that lets you subscribe to the current `FormData` of a form and derive a custom value from it. * The selector runs whenever the form's structure or data changes, and the hook re-renders only when the result is deeply different. * * @see https://conform.guide/api/react/future/useFormData * @example * ```ts * const value = useFormData(formRef, formData => formData?.get('fieldName').toString() ?? ''); * ``` */ function useFormData(formRef, select, options) { var { observer } = useContext(FormConfig); var valueRef = useRef(); var formDataRef = useRef(null); var value = useSyncExternalStore(useCallback(callback => { var formElement = getFormElement(formRef); if (formElement) { var formData = getFormData(formElement); formDataRef.current = options !== null && options !== void 0 && options.acceptFiles ? formData : new URLSearchParams(Array.from(formData).map(_ref2 => { var [key, value] = _ref2; return [key, value.toString()]; })); } var unsubscribe = observer.onFormUpdate(event => { if (event.target === getFormElement(formRef)) { var _formData = getFormData(event.target, event.submitter); formDataRef.current = options !== null && options !== void 0 && options.acceptFiles ? _formData : new URLSearchParams(Array.from(_formData).map(_ref3 => { var [key, value] = _ref3; return [key, value.toString()]; })); callback(); } }); return unsubscribe; }, [observer, formRef, options === null || options === void 0 ? void 0 : options.acceptFiles]), () => { // @ts-expect-error FIXME var result = select(formDataRef.current, valueRef.current); if (typeof valueRef.current !== 'undefined' && deepEqual(result, valueRef.current)) { return valueRef.current; } valueRef.current = result; return result; }, () => select(null, undefined)); return value; } /** * useLayoutEffect is client-only. * This basically makes it a no-op on server */ var useSafeLayoutEffect = typeof document === 'undefined' ? useEffect : useLayoutEffect; /** * Keep a mutable ref in sync with the latest value. * Useful to avoid stale closures in event handlers or async callbacks. */ function useLatest(value) { var ref = useRef(value); useSafeLayoutEffect(() => { ref.current = value; }, [value]); return ref; } export { Form, FormConfig, FormProvider, INITIAL_KEY, useConform, useControl, useField, useForm, useFormContext, useFormData, useFormMetadata, useIntent, useLatest, useSafeLayoutEffect };