From e4d2a64ccf300c91bb9dcecc3748d023d285640e Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 12 Aug 2024 10:11:50 -0400 Subject: [PATCH 1/3] chore: Add changeset --- .changeset/bright-peaches-change.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bright-peaches-change.md diff --git a/.changeset/bright-peaches-change.md b/.changeset/bright-peaches-change.md new file mode 100644 index 00000000000..d02e67390a6 --- /dev/null +++ b/.changeset/bright-peaches-change.md @@ -0,0 +1,5 @@ +--- +"@clerk/elements": patch +--- + +Refactor form hooks and utils into separate files From a20f79580a7bfe5f633e1704c474226ec6b03aba Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 12 Aug 2024 12:28:37 -0400 Subject: [PATCH 2/3] chore(elements): Clean up form components [SDKI-588] --- .changeset/mighty-peas-flash.md | 5 + .../src/react/common/form/field-error.tsx | 77 +++ .../src/react/common/form/field-state.tsx | 61 ++ .../elements/src/react/common/form/field.tsx | 90 +++ .../elements/src/react/common/form/form.tsx | 32 + .../src/react/common/form/global-error.tsx | 70 +++ .../elements/src/react/common/form/index.tsx | 554 +----------------- .../elements/src/react/common/form/input.tsx | 133 +++++ .../elements/src/react/common/form/label.tsx | 18 + .../elements/src/react/common/form/submit.tsx | 17 + .../elements/src/react/common/form/types.ts | 18 + 11 files changed, 539 insertions(+), 536 deletions(-) create mode 100644 .changeset/mighty-peas-flash.md create mode 100644 packages/elements/src/react/common/form/field-error.tsx create mode 100644 packages/elements/src/react/common/form/field-state.tsx create mode 100644 packages/elements/src/react/common/form/field.tsx create mode 100644 packages/elements/src/react/common/form/form.tsx create mode 100644 packages/elements/src/react/common/form/global-error.tsx create mode 100644 packages/elements/src/react/common/form/input.tsx create mode 100644 packages/elements/src/react/common/form/label.tsx create mode 100644 packages/elements/src/react/common/form/submit.tsx diff --git a/.changeset/mighty-peas-flash.md b/.changeset/mighty-peas-flash.md new file mode 100644 index 00000000000..cb2a510abd9 --- /dev/null +++ b/.changeset/mighty-peas-flash.md @@ -0,0 +1,5 @@ +--- +"@clerk/elements": patch +--- + +Extract common Form components from single file diff --git a/packages/elements/src/react/common/form/field-error.tsx b/packages/elements/src/react/common/form/field-error.tsx new file mode 100644 index 00000000000..605312e0c11 --- /dev/null +++ b/packages/elements/src/react/common/form/field-error.tsx @@ -0,0 +1,77 @@ +import type { FormMessageProps as RadixFormMessageProps } from '@radix-ui/react-form'; +import { FormMessage as RadixFormMessage } from '@radix-ui/react-form'; +import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; + +import { ClerkElementsRuntimeError } from '~/internals/errors'; +import { isReactFragment } from '~/react/utils/is-react-fragment'; + +import { useFieldContext, useFieldFeedback } from './hooks'; +import type { FormErrorProps } from './types'; + +const DISPLAY_NAME = 'ClerkElementsFieldError'; + +export type FormFieldErrorProps = FormErrorProps; +type FormFieldErrorElement = React.ElementRef; + +/** + * FieldError renders error messages associated with a specific field. By default, the error's message will be rendered in an unstyled ``. Optionally, the `children` prop accepts a function to completely customize rendering. + * + * @param {string} [name] - Used to target a specific field by name when rendering outside of a `` component. + * @param {Function} [children] - A function that receives `message` and `code` as arguments. + * + * @example + * + * + * + * + * @example + * + * + * {({ message, code }) => ( + * {message} + * )} + * + * + */ +export const FieldError = React.forwardRef( + ({ asChild = false, children, code, name, ...rest }, forwardedRef) => { + const fieldContext = useFieldContext(); + const rawFieldName = fieldContext?.name || name; + const fieldName = rawFieldName === 'backup_code' ? 'code' : rawFieldName; + const { feedback } = useFieldFeedback({ name: fieldName }); + + if (!(feedback?.type === 'error')) { + return null; + } + + const error = feedback.message; + + if (!error) { + return null; + } + + const Comp = asChild ? Slot : 'span'; + const child = typeof children === 'function' ? children(error) : children; + + // const forceMatch = code ? error.code === code : undefined; // TODO: Re-add when Radix Form is updated + + if (isReactFragment(child)) { + throw new ClerkElementsRuntimeError(' cannot render a Fragment as a child.'); + } + + return ( + + {child || error.message} + + ); + }, +); + +FieldError.displayName = DISPLAY_NAME; diff --git a/packages/elements/src/react/common/form/field-state.tsx b/packages/elements/src/react/common/form/field-state.tsx new file mode 100644 index 00000000000..54301f26995 --- /dev/null +++ b/packages/elements/src/react/common/form/field-state.tsx @@ -0,0 +1,61 @@ +import { ClerkElementsFieldError } from '~/internals/errors'; +import type { ErrorCodeOrTuple } from '~/react/utils/generate-password-error-text'; + +import { useFieldContext, useFieldFeedback, useFieldState, useValidityStateContext } from './hooks'; +import type { FieldStates } from './types'; +import { enrichFieldState } from './utils'; + +type FieldStateRenderFn = { + children: (state: { + state: FieldStates; + message: string | undefined; + codes: ErrorCodeOrTuple[] | undefined; + }) => React.ReactNode; +}; + +const DISPLAY_NAME = 'ClerkElementsFieldState'; + +/** + * Programmatically access the state of the wrapping ``. Useful for implementing animations when direct access to the state value is necessary. + * + * @param {Function} children - A function that receives `state`, `message`, and `codes` as an argument. `state` will is a union of `"success" | "error" | "idle" | "warning" | "info"`. `message` will be the corresponding message, e.g. error message. `codes` will be an array of keys that were used to generate the password validation messages. This prop is only available when the field is of type `password` and has `validatePassword` set to `true`. + * + * @example + * + * + * Email + * + * {({ state }) => ( + * + * )} + * + * + * + * @example + * + * Password + * + * + * {({ state, message, codes }) => ( + *
Field state: {state}
+ *
Field msg: {message}
+ *
Pwd keys: {codes.join(', ')}
+ * )} + *
+ *
+ */ +export function FieldState({ children }: FieldStateRenderFn) { + const field = useFieldContext(); + const { feedback } = useFieldFeedback({ name: field?.name }); + const { state } = useFieldState({ name: field?.name }); + const validity = useValidityStateContext(); + + const message = feedback?.message instanceof ClerkElementsFieldError ? feedback.message.message : feedback?.message; + const codes = feedback?.codes; + + const fieldState = { state: enrichFieldState(validity, state), message, codes }; + + return children(fieldState); +} + +FieldState.displayName = DISPLAY_NAME; diff --git a/packages/elements/src/react/common/form/field.tsx b/packages/elements/src/react/common/form/field.tsx new file mode 100644 index 00000000000..ed85455edba --- /dev/null +++ b/packages/elements/src/react/common/form/field.tsx @@ -0,0 +1,90 @@ +import type { Autocomplete } from '@clerk/types'; +import type { FormFieldProps as RadixFormFieldProps } from '@radix-ui/react-form'; +import { Field as RadixField, ValidityState as RadixValidityState } from '@radix-ui/react-form'; +import * as React from 'react'; + +import { useFormStore } from '~/internals/machines/form/form.context'; + +import { FieldContext, useField, useFieldState, ValidityStateContext } from './hooks'; +import type { ClerkFieldId, FieldStates } from './types'; +import { enrichFieldState } from './utils'; + +const DISPLAY_NAME = 'ClerkElementsField'; +const DISPLAY_NAME_INNER = 'ClerkElementsFieldInner'; + +type FormFieldElement = React.ElementRef; +export type FormFieldProps = Omit & { + name: Autocomplete; + alwaysShow?: boolean; + children: React.ReactNode | ((state: FieldStates) => React.ReactNode); +}; + +/** + * Field is used to associate its child elements with a specific input. It automatically handles unique ID generation and associating the contained label and input elements. + * + * @param name - Give your `` a unique name inside the current form. If you choose one of the following names Clerk Elements will automatically set the correct type on the `` element: `emailAddress`, `password`, `phoneNumber`, and `code`. + * @param alwaysShow - Optional. When `true`, the field will always be renydered, regardless of its state. By default, a field is hidden if it's optional or if it's a filled-out required field. + * @param {Function} children - A function that receives `state` as an argument. `state` is a union of `"success" | "error" | "idle" | "warning" | "info"`. + * + * @example + * + * Email + * + * + * + * @example + * + * {(fieldState) => ( + * Email + * + * )} + * + */ +export const Field = React.forwardRef(({ alwaysShow, ...rest }, forwardedRef) => { + const formRef = useFormStore(); + const formCtx = formRef.getSnapshot().context; + // A field is marked as hidden if it's optional OR if it's a filled-out required field + const isHiddenField = formCtx.progressive && Boolean(formCtx.hidden?.has(rest.name)); + + // Only alwaysShow={true} should force behavior to render the field, on `undefined` or alwaysShow={false} the isHiddenField logic should take over + const shouldHide = alwaysShow ? false : isHiddenField; + + return shouldHide ? null : ( + + + + ); +}); + +Field.displayName = DISPLAY_NAME; + +const FieldInner = React.forwardRef((props, forwardedRef) => { + const { children, ...rest } = props; + const field = useField({ name: rest.name }); + const { state: fieldState } = useFieldState({ name: rest.name }); + + return ( + + + {validity => { + const enrichedFieldState = enrichFieldState(validity, fieldState); + + return ( + + {typeof children === 'function' ? children(enrichedFieldState) : children} + + ); + }} + + + ); +}); + +FieldInner.displayName = DISPLAY_NAME_INNER; diff --git a/packages/elements/src/react/common/form/form.tsx b/packages/elements/src/react/common/form/form.tsx new file mode 100644 index 00000000000..5ff09be1109 --- /dev/null +++ b/packages/elements/src/react/common/form/form.tsx @@ -0,0 +1,32 @@ +import { composeEventHandlers } from '@radix-ui/primitive'; +import type { FormProps as RadixFormProps } from '@radix-ui/react-form'; +import { Form as RadixForm } from '@radix-ui/react-form'; +import * as React from 'react'; +import type { BaseActorRef } from 'xstate'; + +import { useForm } from './hooks'; + +const DISPLAY_NAME = 'ClerkElementsForm'; + +type FormElement = React.ElementRef; +export type FormProps = Omit & { + children: React.ReactNode; + flowActor?: BaseActorRef<{ type: 'SUBMIT' }>; +}; + +export const Form = React.forwardRef(({ flowActor, onSubmit, ...rest }, forwardedRef) => { + const form = useForm({ flowActor: flowActor }); + + const { onSubmit: internalOnSubmit, ...internalFormProps } = form.props; + + return ( + + ); +}); + +Form.displayName = DISPLAY_NAME; diff --git a/packages/elements/src/react/common/form/global-error.tsx b/packages/elements/src/react/common/form/global-error.tsx new file mode 100644 index 00000000000..14d92642854 --- /dev/null +++ b/packages/elements/src/react/common/form/global-error.tsx @@ -0,0 +1,70 @@ +import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; + +import { ClerkElementsRuntimeError } from '~/internals/errors'; +import { isReactFragment } from '~/react/utils/is-react-fragment'; + +import { useGlobalErrors } from './hooks'; +import type { FormErrorProps } from './types'; + +const DISPLAY_NAME = 'ClerkElementsGlobalError'; + +type FormGlobalErrorElement = React.ElementRef<'div'>; +export type FormGlobalErrorProps = FormErrorProps>; + +/** + * Used to render errors that are returned from Clerk's API, but that are not associated with a specific form field. By default, will render the error's message wrapped in a `
`. Optionally, the `children` prop accepts a function to completely customize rendering. Must be placed **inside** components like ``/`` to have access to the underlying form state. + * + * @param {string} [code] - Forces the message with the matching code to be shown. This is useful when using server-side validation. + * @param {Function} [children] - A function that receives `message` and `code` as arguments. + * @param {boolean} [asChild] - If `true`, `` will render as its child element, passing along any necessary props. + * + * @example + * + * + * + * + * @example + * + * Your custom error message. + * + * + * @example + * + * + * {({ message, code }) => ( + * {message} + * )} + * + * + */ +export const GlobalError = React.forwardRef( + ({ asChild = false, children, code, ...rest }, forwardedRef) => { + const { errors } = useGlobalErrors(); + + const error = errors?.[0]; + + if (!error || (code && error.code !== code)) { + return null; + } + + const Comp = asChild ? Slot : 'div'; + const child = typeof children === 'function' ? children(error) : children; + + if (isReactFragment(child)) { + throw new ClerkElementsRuntimeError(' cannot render a Fragment as a child.'); + } + + return ( + + {child || error.message} + + ); + }, +); + +GlobalError.displayName = DISPLAY_NAME; diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 19c13909e59..29e8641e41a 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -1,536 +1,18 @@ -import { useClerk } from '@clerk/clerk-react'; -import { logger } from '@clerk/shared/logger'; -import { eventComponentMounted } from '@clerk/shared/telemetry'; -import type { Autocomplete } from '@clerk/types'; -import { composeEventHandlers } from '@radix-ui/primitive'; -import type { - Control as RadixControl, - FormControlProps, - FormControlProps as RadixFormControlProps, - FormFieldProps as RadixFormFieldProps, - FormMessageProps as RadixFormMessageProps, - FormProps as RadixFormProps, - FormSubmitProps as RadixFormSubmitProps, -} from '@radix-ui/react-form'; -import { - Field as RadixField, - Form as RadixForm, - FormMessage as RadixFormMessage, - Label as RadixLabel, - Submit as RadixSubmit, - ValidityState as RadixValidityState, -} from '@radix-ui/react-form'; -import { Slot } from '@radix-ui/react-slot'; -import * as React from 'react'; -import type { SetRequired } from 'type-fest'; -import type { BaseActorRef } from 'xstate'; - -import type { ClerkElementsError } from '~/internals/errors'; -import { ClerkElementsFieldError, ClerkElementsRuntimeError } from '~/internals/errors'; -import { useFormStore } from '~/internals/machines/form/form.context'; -import { SignInRouterCtx } from '~/react/sign-in/context'; -import { useSignInPasskeyAutofill } from '~/react/sign-in/context/router.context'; -import type { ErrorCodeOrTuple } from '~/react/utils/generate-password-error-text'; -import { isReactFragment } from '~/react/utils/is-react-fragment'; - -import { - FieldContext, - useField, - useFieldContext, - useFieldFeedback, - useFieldState, - useForm, - useGlobalErrors, - useInput, - useValidityStateContext, - ValidityStateContext, -} from './hooks'; -import type { OTPInputProps } from './otp'; -import type { ClerkFieldId, FieldStates } from './types'; -import { enrichFieldState } from './utils'; - -/* ------------------------------------------------------------------------------------------------- - * Form - * -----------------------------------------------------------------------------------------------*/ - -const FORM_NAME = 'ClerkElementsForm'; - -type FormElement = React.ElementRef; -type FormProps = Omit & { - children: React.ReactNode; - flowActor?: BaseActorRef<{ type: 'SUBMIT' }>; -}; - -const Form = React.forwardRef(({ flowActor, onSubmit, ...rest }, forwardedRef) => { - const form = useForm({ flowActor: flowActor }); - - const { onSubmit: internalOnSubmit, ...internalFormProps } = form.props; - - return ( - - ); -}); - -Form.displayName = FORM_NAME; - -/* ------------------------------------------------------------------------------------------------- - * Field - * -----------------------------------------------------------------------------------------------*/ - -const FIELD_NAME = 'ClerkElementsField'; -const FIELD_INNER_NAME = 'ClerkElementsFieldInner'; -const FIELD_STATE_NAME = 'ClerkElementsFieldState'; - -type FormFieldElement = React.ElementRef; -type FormFieldProps = Omit & { - name: Autocomplete; - alwaysShow?: boolean; - children: React.ReactNode | ((state: FieldStates) => React.ReactNode); -}; - -/** - * Field is used to associate its child elements with a specific input. It automatically handles unique ID generation and associating the contained label and input elements. - * - * @param name - Give your `` a unique name inside the current form. If you choose one of the following names Clerk Elements will automatically set the correct type on the `` element: `emailAddress`, `password`, `phoneNumber`, and `code`. - * @param alwaysShow - Optional. When `true`, the field will always be rendered, regardless of its state. By default, a field is hidden if it's optional or if it's a filled-out required field. - * @param {Function} children - A function that receives `state` as an argument. `state` is a union of `"success" | "error" | "idle" | "warning" | "info"`. - * - * @example - * - * Email - * - * - * - * @example - * - * {(fieldState) => ( - * Email - * - * )} - * - */ -const Field = React.forwardRef(({ alwaysShow, ...rest }, forwardedRef) => { - const formRef = useFormStore(); - const formCtx = formRef.getSnapshot().context; - // A field is marked as hidden if it's optional OR if it's a filled-out required field - const isHiddenField = formCtx.progressive && Boolean(formCtx.hidden?.has(rest.name)); - - // Only alwaysShow={true} should force behavior to render the field, on `undefined` or alwaysShow={false} the isHiddenField logic should take over - const shouldHide = alwaysShow ? false : isHiddenField; - - return shouldHide ? null : ( - - - - ); -}); - -const FieldInner = React.forwardRef((props, forwardedRef) => { - const { children, ...rest } = props; - const field = useField({ name: rest.name }); - const { state: fieldState } = useFieldState({ name: rest.name }); - - return ( - - - {validity => { - const enrichedFieldState = enrichFieldState(validity, fieldState); - - return ( - - {typeof children === 'function' ? children(enrichedFieldState) : children} - - ); - }} - - - ); -}); - -Field.displayName = FIELD_NAME; -FieldInner.displayName = FIELD_INNER_NAME; - -type FieldStateRenderFn = { - children: (state: { - state: FieldStates; - message: string | undefined; - codes: ErrorCodeOrTuple[] | undefined; - }) => React.ReactNode; -}; - -/** - * Programmatically access the state of the wrapping ``. Useful for implementing animations when direct access to the state value is necessary. - * - * @param {Function} children - A function that receives `state`, `message`, and `codes` as an argument. `state` will is a union of `"success" | "error" | "idle" | "warning" | "info"`. `message` will be the corresponding message, e.g. error message. `codes` will be an array of keys that were used to generate the password validation messages. This prop is only available when the field is of type `password` and has `validatePassword` set to `true`. - * - * @example - * - * - * Email - * - * {({ state }) => ( - * - * )} - * - * - * - * @example - * - * Password - * - * - * {({ state, message, codes }) => ( - *
Field state: {state}
- *
Field msg: {message}
- *
Pwd keys: {codes.join(', ')}
- * )} - *
- *
- */ -function FieldState({ children }: FieldStateRenderFn) { - const field = useFieldContext(); - const { feedback } = useFieldFeedback({ name: field?.name }); - const { state } = useFieldState({ name: field?.name }); - const validity = useValidityStateContext(); - - const message = feedback?.message instanceof ClerkElementsFieldError ? feedback.message.message : feedback?.message; - const codes = feedback?.codes; - - const fieldState = { state: enrichFieldState(validity, state), message, codes }; - - return children(fieldState); -} - -FieldState.displayName = FIELD_STATE_NAME; - -/* ------------------------------------------------------------------------------------------------- - * Input - * -----------------------------------------------------------------------------------------------*/ - -const INPUT_NAME = 'ClerkElementsInput'; - -type PasswordInputProps = Exclude & { - validatePassword?: boolean; -}; - -type FormInputProps = - | RadixFormControlProps - | ({ type: 'otp'; render: OTPInputProps['render'] } & Omit) - | ({ type: 'otp'; render?: undefined } & OTPInputProps) - // Usecase: Toggle the visibility of the password input, therefore 'password' and 'text' are allowed - | ({ type: 'password' | 'text' } & PasswordInputProps); - -/** - * Handles rendering of `` elements within Clerk's flows. Supports special `type` prop values to render input types that are unique to authentication and user management flows. Additional props will be passed through to the `` element. - * - * @param {boolean} [asChild] - If true, `` will render as its child element, passing along any necessary props. - * @param {string} [name] - Used to target a specific field by name when rendering outside of a `` component. - * - * @example - * - * Email - * - * - * - * @param {Number} [length] - The length of the OTP input. Defaults to 6. - * @param {Number} [passwordManagerOffset] - Password managers place their icon inside an ``. This default behaviour is not desirable when you use the render prop to display N distinct element. With this prop you can increase the width of the `` so that the icon is rendered outside the OTP inputs. - * @param {string} [type] - Type of control to render. Supports a special `'otp'` type for one-time password inputs. If the wrapping `` component has `name='code'`, the type will default to `'otp'`. With the `'otp'` type, the input will have a pattern and length set to 6 by default and render a single `` element. - * - * @example - * - * Email code - * - * - * - * @param {Function} [render] - Optionally, you can use a render prop that controls how each individual character is rendered. If no `render` prop is provided, a single text `` will be rendered. - * - * @example - * - * Email code - * {value}} - * /> - * - */ -const Input = React.forwardRef, FormInputProps>( - (props: FormInputProps, forwardedRef) => { - const clerk = useClerk(); - const field = useInput(props); - - const hasPasskeyAutofillProp = Boolean(field.props.autoComplete?.includes('webauthn')); - const allowedTypeForPasskey = (['text', 'email', 'tel'] as FormInputProps['type'][]).includes(field.props.type); - const signInRouterRef = SignInRouterCtx.useActorRef(true); - - clerk.telemetry?.record( - eventComponentMounted('Elements_Input', { - type: props.type ?? false, - // @ts-expect-error - Depending on type the props can be different - render: Boolean(props?.render), - // @ts-expect-error - Depending on type the props can be different - asChild: Boolean(props?.asChild), - // @ts-expect-error - Depending on type the props can be different - validatePassword: Boolean(props?.validatePassword), - }), - ); - - if (signInRouterRef && hasPasskeyAutofillProp && allowedTypeForPasskey) { - return ( - - ); - } - - if (hasPasskeyAutofillProp && !allowedTypeForPasskey) { - logger.warnOnce( - ` can only be used with or `, - ); - } else if (hasPasskeyAutofillProp) { - logger.warnOnce( - ` can only be used inside in order to trigger a sign-in attempt, otherwise it will be ignored.`, - ); - } - - return ( - - ); - }, -); - -Input.displayName = INPUT_NAME; - -const InputWithPasskeyAutofill = React.forwardRef, FormInputProps>( - (props: FormInputProps, forwardedRef) => { - const signInRouterRef = SignInRouterCtx.useActorRef(true); - const passkeyAutofillSupported = useSignInPasskeyAutofill(); - - React.useEffect(() => { - if (passkeyAutofillSupported) { - signInRouterRef?.send({ type: 'AUTHENTICATE.PASSKEY.AUTOFILL' }); - } - }, [passkeyAutofillSupported, signInRouterRef]); - - const field = useInput(props); - return ( - - ); - }, -); - -/* ------------------------------------------------------------------------------------------------- - * Label - * -----------------------------------------------------------------------------------------------*/ - -const LABEL_NAME = 'ClerkElementsLabel'; - -/** - * Renders a `