Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-peaches-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/elements": patch
---

Refactor form hooks and utils into separate files
5 changes: 5 additions & 0 deletions .changeset/mighty-peas-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/elements": patch
---

Extract common Form components from single file
77 changes: 77 additions & 0 deletions packages/elements/src/react/common/form/field-error.tsx
Original file line number Diff line number Diff line change
@@ -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<RadixFormMessageProps & { name?: string }>;
type FormFieldErrorElement = React.ElementRef<typeof RadixFormMessage>;

/**
* FieldError renders error messages associated with a specific field. By default, the error's message will be rendered in an unstyled `<span>`. 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 `<Field>` component.
* @param {Function} [children] - A function that receives `message` and `code` as arguments.
*
* @example
* <Clerk.Field name="email">
* <Clerk.FieldError />
* </Clerk.Field>
*
* @example
* <Clerk.Field name="email">
* <Clerk.FieldError>
* {({ message, code }) => (
* <span data-error-code={code}>{message}</span>
* )}
* </Clerk.FieldError>
* </Clerk.Field>
*/
export const FieldError = React.forwardRef<FormFieldErrorElement, FormFieldErrorProps>(
({ 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('<FieldError /> cannot render a Fragment as a child.');
}

return (
<RadixFormMessage
data-error-code={error.code}
// forceMatch={forceMatch}
{...rest}
ref={forwardedRef}
asChild
>
<Comp>{child || error.message}</Comp>
</RadixFormMessage>
);
},
);

FieldError.displayName = DISPLAY_NAME;
61 changes: 61 additions & 0 deletions packages/elements/src/react/common/form/field-state.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Field>`. 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
*
* <Clerk.Field name="email">
* <Clerk.Label>Email</Clerk.Label>
* <Clerk.FieldState>
* {({ state }) => (
* <Clerk.Input className={`text-${state}`} />
* )}
* </Clerk.FieldState>
* </Clerk.Field>
*
* @example
* <Clerk.Field name="password">
* <Clerk.Label>Password</Clerk.Label>
* <Clerk.Input validatePassword />
* <Clerk.FieldState>
* {({ state, message, codes }) => (
* <pre>Field state: {state}</pre>
* <pre>Field msg: {message}</pre>
* <pre>Pwd keys: {codes.join(', ')}</pre>
* )}
* </Clerk.FieldState>
* </Clerk.Field>
*/
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;
90 changes: 90 additions & 0 deletions packages/elements/src/react/common/form/field.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RadixField>;
export type FormFieldProps = Omit<RadixFormFieldProps, 'children'> & {
name: Autocomplete<ClerkFieldId>;
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 `<Field>` 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 `<input />` 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
* <Clerk.Field name="emailAddress">
* <Clerk.Label>Email</Clerk.Label>
* <Clerk.Input />
* </Clerk.Field>
*
* @example
* <Clerk.Field name="emailAddress">
* {(fieldState) => (
* <Clerk.Label>Email</Clerk.Label>
* <Clerk.Input className={`text-${fieldState}`} />
* )}
* </Clerk.Field>
*/
export const Field = React.forwardRef<FormFieldElement, FormFieldProps>(({ 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 : (
<FieldContext.Provider value={{ name: rest.name }}>
<FieldInner
{...rest}
ref={forwardedRef}
/>
</FieldContext.Provider>
);
});

Field.displayName = DISPLAY_NAME;

const FieldInner = React.forwardRef<FormFieldElement, FormFieldProps>((props, forwardedRef) => {
const { children, ...rest } = props;
const field = useField({ name: rest.name });
const { state: fieldState } = useFieldState({ name: rest.name });

return (
<RadixField
{...field.props}
{...rest}
ref={forwardedRef}
>
<RadixValidityState>
{validity => {
const enrichedFieldState = enrichFieldState(validity, fieldState);

return (
<ValidityStateContext.Provider value={validity}>
{typeof children === 'function' ? children(enrichedFieldState) : children}
</ValidityStateContext.Provider>
);
}}
</RadixValidityState>
</RadixField>
);
});

FieldInner.displayName = DISPLAY_NAME_INNER;
32 changes: 32 additions & 0 deletions packages/elements/src/react/common/form/form.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RadixForm>;
export type FormProps = Omit<RadixFormProps, 'children'> & {
children: React.ReactNode;
flowActor?: BaseActorRef<{ type: 'SUBMIT' }>;
};

export const Form = React.forwardRef<FormElement, FormProps>(({ flowActor, onSubmit, ...rest }, forwardedRef) => {
const form = useForm({ flowActor: flowActor });

const { onSubmit: internalOnSubmit, ...internalFormProps } = form.props;

return (
<RadixForm
{...internalFormProps}
{...rest}
onSubmit={composeEventHandlers(internalOnSubmit, onSubmit)}
ref={forwardedRef}
/>
);
});

Form.displayName = DISPLAY_NAME;
70 changes: 70 additions & 0 deletions packages/elements/src/react/common/form/global-error.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentPropsWithoutRef<'div'>>;

/**
* 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 `<div>`. Optionally, the `children` prop accepts a function to completely customize rendering. Must be placed **inside** components like `<SignIn>`/`<SignUp>` 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`, `<GlobalError>` will render as its child element, passing along any necessary props.
*
* @example
* <SignIn.Root>
* <Clerk.GlobalError />
* </SignIn.Root>
*
* @example
* <SignIn.Root>
* <Clerk.GlobalError code="user_locked">Your custom error message.</Clerk.GlobalError>
* </SignIn.Root>
*
* @example
* <SignIn.Root>
* <Clerk.GlobalError>
* {({ message, code }) => (
* <span data-error-code={code}>{message}</span>
* )}
* </Clerk.GlobalError>
* </SignIn.Root>
*/
export const GlobalError = React.forwardRef<FormGlobalErrorElement, FormGlobalErrorProps>(
({ 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('<GlobalError /> cannot render a Fragment as a child.');
}

return (
<Comp
role='alert'
{...rest}
ref={forwardedRef}
>
{child || error.message}
</Comp>
);
},
);

GlobalError.displayName = DISPLAY_NAME;
Loading