diff --git a/.changeset/bright-trainers-sort.md b/.changeset/bright-trainers-sort.md
new file mode 100644
index 00000000000..976c66fdc0e
--- /dev/null
+++ b/.changeset/bright-trainers-sort.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Internal refactoring of form fields, deprecation of Form.Control and introduction of Form.PlainInput.
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx
index 472b12fd97c..af8c2b502b0 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx
@@ -53,10 +53,10 @@ export const AddDomainPage = withCardStateProvider(() => {
>
-
diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
index a0c584b0489..9bdde9e8f3b 100644
--- a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
+++ b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx
@@ -32,44 +32,36 @@ export const SignUpForm = (props: SignUpFormProps) => {
{(shouldShow('firstName') || shouldShow('lastName')) && (
{shouldShow('firstName') && (
-
)}
{shouldShow('lastName') && (
-
)}
)}
{shouldShow('username') && (
-
)}
{shouldShow('emailAddress') && (
- handleEmailPhoneToggle('phoneNumber') : undefined}
/>
diff --git a/packages/clerk-js/src/ui/components/UserProfile/UsernamePage.tsx b/packages/clerk-js/src/ui/components/UserProfile/UsernamePage.tsx
index 0024f4d96c9..b2aecc589f5 100644
--- a/packages/clerk-js/src/ui/components/UserProfile/UsernamePage.tsx
+++ b/packages/clerk-js/src/ui/components/UserProfile/UsernamePage.tsx
@@ -38,10 +38,10 @@ export const UsernamePage = withCardStateProvider(() => {
>
-
diff --git a/packages/clerk-js/src/ui/elements/FieldControl.tsx b/packages/clerk-js/src/ui/elements/FieldControl.tsx
new file mode 100644
index 00000000000..55d293dcaf6
--- /dev/null
+++ b/packages/clerk-js/src/ui/elements/FieldControl.tsx
@@ -0,0 +1,225 @@
+import type { FieldId } from '@clerk/types';
+import type { PropsWithChildren } from 'react';
+import React, { forwardRef } from 'react';
+
+import type { LocalizationKey } from '../customizables';
+import {
+ descriptors,
+ Flex,
+ FormControl as FormControlPrim,
+ FormLabel,
+ Icon as IconCustomizable,
+ Input,
+ Link,
+ localizationKeys,
+ Text,
+ useLocalizations,
+} from '../customizables';
+import { FormFieldContextProvider, sanitizeInputProps, useFormField } from '../primitives/hooks';
+import type { PropsOfComponent } from '../styledSystem';
+import type { useFormControl as useFormControlUtil } from '../utils';
+import { useFormControlFeedback } from '../utils';
+import { useCardState } from './contexts';
+import type { FormFeedbackProps } from './FormControl';
+import { FormFeedback } from './FormControl';
+
+type FormControlProps = Omit, 'label' | 'placeholder' | 'disabled' | 'required'> &
+ ReturnType>['props'];
+
+const Root = (props: PropsWithChildren) => {
+ const card = useCardState();
+ const {
+ id,
+ isRequired,
+ sx,
+ setError,
+ setInfo,
+ setSuccess,
+ setWarning,
+ setHasPassedComplexity,
+ clearFeedback,
+ feedbackType,
+ feedback,
+ isFocused,
+ } = props;
+
+ const { debounced: debouncedState } = useFormControlFeedback({ feedback, feedbackType, isFocused });
+
+ const isDisabled = props.isDisabled || card.isLoading;
+
+ return (
+
+ {/*Most of our primitives still depend on this provider.*/}
+ {/*TODO: In follow-up PRs these will be removed*/}
+
+ {props.children}
+
+
+ );
+};
+
+const FieldAction = (
+ props: PropsWithChildren<{ localizationKey?: LocalizationKey | string; onClick?: React.MouseEventHandler }>,
+) => {
+ const { fieldId, isDisabled } = useFormField();
+
+ if (!props.localizationKey && !props.children) {
+ return null;
+ }
+
+ return (
+ {
+ e.preventDefault();
+ props.onClick?.(e);
+ }}
+ >
+ {props.children}
+
+ );
+};
+
+const FieldOptionalLabel = () => {
+ const { fieldId, isDisabled } = useFormField();
+ return (
+
+ );
+};
+
+const FieldLabelIcon = (props: { icon?: React.ComponentType }) => {
+ const { t } = useLocalizations();
+ if (!props.icon) {
+ return null;
+ }
+
+ return (
+
+ ({
+ marginLeft: theme.space.$0x5,
+ color: theme.colors.$blackAlpha400,
+ width: theme.sizes.$4,
+ height: theme.sizes.$4,
+ })}
+ />
+
+ );
+};
+
+const FieldLabel = (props: PropsWithChildren<{ localizationKey?: LocalizationKey | string }>) => {
+ const { isRequired, id, label, isDisabled, hasError } = useFormField();
+
+ if (!(props.localizationKey || label) && !props.children) {
+ return null;
+ }
+
+ return (
+
+ {props.children}
+
+ );
+};
+
+const FieldLabelRow = (props: PropsWithChildren) => {
+ const { fieldId } = useFormField();
+ return (
+ ({
+ marginBottom: theme.space.$1,
+ marginLeft: 0,
+ })}
+ >
+ {props.children}
+
+ );
+};
+
+const FieldFeedback = (props: Pick) => {
+ const { feedback, feedbackType, isFocused, fieldId } = useFormField();
+ const { debounced } = useFormControlFeedback({ feedback, feedbackType, isFocused });
+
+ return (
+
+ );
+};
+
+const InputElement = forwardRef((_, ref) => {
+ const { t } = useLocalizations();
+ const formField = useFormField();
+ const { placeholder, ...inputProps } = sanitizeInputProps(formField);
+ return (
+
+ );
+});
+
+export const Field = {
+ Root: Root,
+ Label: FieldLabel,
+ LabelRow: FieldLabelRow,
+ Input: InputElement,
+ Action: FieldAction,
+ AsOptional: FieldOptionalLabel,
+ LabelIcon: FieldLabelIcon,
+ Feedback: FieldFeedback,
+};
diff --git a/packages/clerk-js/src/ui/elements/Form.tsx b/packages/clerk-js/src/ui/elements/Form.tsx
index 012b02482b8..90f6d433838 100644
--- a/packages/clerk-js/src/ui/elements/Form.tsx
+++ b/packages/clerk-js/src/ui/elements/Form.tsx
@@ -1,11 +1,14 @@
import { createContextAndHook } from '@clerk/shared/react';
import type { FieldId } from '@clerk/types';
+import type { PropsWithChildren } from 'react';
import React, { useState } from 'react';
+import type { LocalizationKey } from '../customizables';
import { Button, descriptors, Flex, Form as FormPrim, localizationKeys } from '../customizables';
import { useLoadingStatus } from '../hooks';
import type { PropsOfComponent } from '../styledSystem';
import { useCardState } from './contexts';
+import { Field } from './FieldControl';
import { FormControl } from './FormControl';
const [FormState, useFormState] = createContextAndHook<{
@@ -118,10 +121,53 @@ const FormControlRow = (props: Omit, 'elementId'>
);
};
+type CommonFieldRootProps = Omit, 'children'>;
+
+type CommonInputProps = CommonFieldRootProps & {
+ isOptional?: boolean;
+ actionLabel?: string | LocalizationKey;
+ onActionClicked?: React.MouseEventHandler;
+ icon?: React.ComponentType;
+};
+
+const CommonInputWrapper = (props: PropsWithChildren) => {
+ const { isOptional, icon, actionLabel, children, onActionClicked, ...fieldProps } = props;
+ return (
+
+
+
+
+ {!actionLabel && isOptional && }
+ {actionLabel && (
+
+ )}
+
+
+ {children}
+
+
+ );
+};
+
+const PlainInput = (props: CommonInputProps) => {
+ return (
+
+
+
+ );
+};
+
export const Form = {
Root: FormRoot,
ControlRow: FormControlRow,
+ /**
+ * @deprecated Use Form.PlainInput
+ */
Control: FormControl,
+ PlainInput,
SubmitButton: FormSubmit,
ResetButton: FormReset,
};
diff --git a/packages/clerk-js/src/ui/elements/FormControl.tsx b/packages/clerk-js/src/ui/elements/FormControl.tsx
index 2c4546a4a90..82d95f028ee 100644
--- a/packages/clerk-js/src/ui/elements/FormControl.tsx
+++ b/packages/clerk-js/src/ui/elements/FormControl.tsx
@@ -89,7 +89,7 @@ const getInputElementForType = ({
return CustomInputs[customInput] || Input;
};
-function useFormTextAnimation() {
+export function useFormTextAnimation() {
const prefersReducedMotion = usePrefersReducedMotion();
const getFormTextAnimation = useCallback(
@@ -117,7 +117,7 @@ function useFormTextAnimation() {
};
}
-const useCalculateErrorTextHeight = ({ feedback }: { feedback: string }) => {
+export const useCalculateErrorTextHeight = ({ feedback }: { feedback: string }) => {
const [height, setHeight] = useState(0);
const calculateHeight = useCallback(
@@ -136,11 +136,11 @@ const useCalculateErrorTextHeight = ({ feedback }: { feedback: string }) => {
};
};
-type FormFeedbackDescriptorsKeys = 'error' | 'warning' | 'info' | 'success';
+export type FormFeedbackDescriptorsKeys = 'error' | 'warning' | 'info' | 'success';
type Feedback = { feedback?: string; feedbackType?: FeedbackType; shouldEnter: boolean };
-type FormFeedbackProps = Partial['debounced'] & { id: FieldId }> & {
+export type FormFeedbackProps = Partial['debounced'] & { id: FieldId }> & {
elementDescriptors?: Partial>;
};
diff --git a/packages/clerk-js/src/ui/primitives/FormControl.tsx b/packages/clerk-js/src/ui/primitives/FormControl.tsx
index c62d70a56f1..9056e551f81 100644
--- a/packages/clerk-js/src/ui/primitives/FormControl.tsx
+++ b/packages/clerk-js/src/ui/primitives/FormControl.tsx
@@ -4,6 +4,11 @@ import { Flex } from './Flex';
import type { FormControlProps } from './hooks';
import { FormControlContextProvider } from './hooks';
+/**
+ * @deprecated Use Field.Root
+ * Each controlled field should have their own UI wrapper.
+ * Field.Root is just a Provider
+ */
export const FormControl = (props: React.PropsWithChildren) => {
const {
hasError,
diff --git a/packages/clerk-js/src/ui/primitives/Input.tsx b/packages/clerk-js/src/ui/primitives/Input.tsx
index 1d6788e6a29..9967fc30b06 100644
--- a/packages/clerk-js/src/ui/primitives/Input.tsx
+++ b/packages/clerk-js/src/ui/primitives/Input.tsx
@@ -49,6 +49,9 @@ export const Input = React.forwardRef((props, ref)
});
const { onChange } = useInput(propsWithoutVariants.onChange);
const { isDisabled, hasError, focusRing, isRequired, ...rest } = propsWithoutVariants;
+ const _disabled = isDisabled || formControlProps.isDisabled;
+ const _required = isRequired || formControlProps.isRequired;
+ const _hasError = hasError || formControlProps.hasError;
return (
((props, ref)
ref={ref}
onChange={onChange}
disabled={isDisabled}
- required={isRequired || formControlProps.isRequired}
+ required={_required}
id={props.id || formControlProps.id}
- aria-invalid={hasError || formControlProps.hasError}
+ aria-invalid={_hasError}
aria-describedby={formControlProps.errorMessageId}
- aria-required={formControlProps.isRequired}
+ aria-required={_required}
+ aria-disabled={_disabled}
css={applyVariants(propsWithoutVariants)}
/>
);
diff --git a/packages/clerk-js/src/ui/primitives/hooks/useFormControl.tsx b/packages/clerk-js/src/ui/primitives/hooks/useFormControl.tsx
index ae03afec60c..461d11a3e18 100644
--- a/packages/clerk-js/src/ui/primitives/hooks/useFormControl.tsx
+++ b/packages/clerk-js/src/ui/primitives/hooks/useFormControl.tsx
@@ -1,7 +1,12 @@
import { createContextAndHook } from '@clerk/shared/react';
-import type { ClerkAPIError } from '@clerk/types';
+import type { ClerkAPIError, FieldId } from '@clerk/types';
import React from 'react';
+import type { useFormControl as useFormControlUtil } from '../../utils/useFormControl';
+
+/**
+ * @deprecated
+ */
export type FormControlProps = {
/**
* The custom `id` to use for the form control. This is passed directly to the form element (e.g, Input).
@@ -19,11 +24,20 @@ export type FormControlProps = {
clearFeedback: () => void;
};
+/**
+ * @deprecated
+ */
type FormControlContextValue = Required & { errorMessageId: string };
+/**
+ * @deprecated Use FormFieldContextProvider
+ */
export const [FormControlContext, , useFormControl] =
createContextAndHook('FormControlContext');
+/**
+ * @deprecated Use FormFieldContextProvider
+ */
export const FormControlContextProvider = (props: React.PropsWithChildren) => {
const {
id: propsId,
@@ -76,3 +90,117 @@ export const FormControlContextProvider = (props: React.PropsWithChildren{props.children};
};
+
+type FormFieldProviderProps = ReturnType>['props'] & {
+ hasError?: boolean;
+ isDisabled?: boolean;
+};
+
+type FormFieldContextValue = Omit & {
+ errorMessageId?: string;
+ id?: string;
+ fieldId?: FieldId;
+};
+export const [FormFieldContext, useFormField] = createContextAndHook('FormFieldContext');
+
+export const FormFieldContextProvider = (props: React.PropsWithChildren) => {
+ const {
+ id: propsId,
+ isRequired = false,
+ isDisabled = false,
+ hasError = false,
+ setError,
+ setSuccess,
+ setWarning,
+ setHasPassedComplexity,
+ setInfo,
+ clearFeedback,
+ children,
+ ...rest
+ } = props;
+ // TODO: This shouldnt be targettable
+ const id = `${propsId}-field`;
+
+ /**
+ * Track whether the `FormErrorText` has been rendered.
+ * We use this to append its id the `aria-describedby` of the `input`.
+ */
+ const errorMessageId = hasError ? `error-${propsId}` : '';
+ const value = React.useMemo(
+ () => ({
+ isRequired,
+ isDisabled,
+ hasError,
+ id,
+ fieldId: propsId,
+ errorMessageId,
+ setError,
+ setSuccess,
+ setWarning,
+ setInfo,
+ clearFeedback,
+ setHasPassedComplexity,
+ }),
+ [
+ isRequired,
+ hasError,
+ id,
+ propsId,
+ errorMessageId,
+ isDisabled,
+ setError,
+ setSuccess,
+ setWarning,
+ setInfo,
+ clearFeedback,
+ setHasPassedComplexity,
+ ],
+ );
+ return (
+
+ {props.children}
+
+ );
+};
+
+export const sanitizeInputProps = (
+ obj: ReturnType,
+ keep?: (keyof ReturnType)[],
+) => {
+ /* eslint-disable */
+ const {
+ radioOptions,
+ validatePassword,
+ hasPassedComplexity,
+ isFocused,
+ feedback,
+ feedbackType,
+ setHasPassedComplexity,
+ setWarning,
+ setSuccess,
+ setError,
+ setInfo,
+ errorMessageId,
+ fieldId,
+ label,
+ ...inputProps
+ } = obj;
+ /* eslint-enable */
+
+ keep?.forEach(key => {
+ /**
+ * Ignore error for the index type as we have defined it explicitly above
+ */
+ // @ts-ignore
+ inputProps[key] = obj[key];
+ });
+
+ return inputProps;
+};
diff --git a/packages/clerk-js/src/ui/utils/useFormControl.ts b/packages/clerk-js/src/ui/utils/useFormControl.ts
index 8e043f77be8..8fd9cd98948 100644
--- a/packages/clerk-js/src/ui/utils/useFormControl.ts
+++ b/packages/clerk-js/src/ui/utils/useFormControl.ts
@@ -48,6 +48,8 @@ type FieldStateProps = {
value: string;
checked?: boolean;
onChange: React.ChangeEventHandler;
+ onBlur: React.FocusEventHandler;
+ onFocus: React.FocusEventHandler;
feedback: string;
feedbackType: FeedbackType;
setError: (error: string | ClerkAPIError | undefined) => void;
@@ -57,6 +59,7 @@ type FieldStateProps = {
setHasPassedComplexity: (b: boolean) => void;
clearFeedback: () => void;
hasPassedComplexity: boolean;
+ isFocused: boolean;
} & Omit;
export type FormControlState = FieldStateProps & {
@@ -87,6 +90,7 @@ export const useFormControl = (
const { translateError, t } = useLocalizations();
const [value, setValueInternal] = useState(initialState);
+ const [isFocused, setFocused] = useState(false);
const [checked, setCheckedInternal] = useState(opts?.defaultChecked || false);
const [hasPassedComplexity, setHasPassedComplexity] = useState(false);
const [feedback, setFeedback] = useState<{ message: string; type: FeedbackType }>({
@@ -130,6 +134,14 @@ export const useFormControl = (
setFeedback({ message: '', type: 'info' });
};
+ const onFocus: FormControlState['onFocus'] = () => {
+ setFocused(true);
+ };
+
+ const onBlur: FormControlState['onBlur'] = () => {
+ setFocused(false);
+ };
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { defaultChecked, validatePassword: validatePasswordProp, buildErrorMessage, ...restOpts } = opts;
@@ -141,6 +153,8 @@ export const useFormControl = (
setSuccess,
setError,
onChange,
+ onBlur,
+ onFocus,
setWarning,
feedback: feedback.message || t(opts.infoText),
feedbackType: feedback.type,
@@ -149,6 +163,7 @@ export const useFormControl = (
hasPassedComplexity,
setHasPassedComplexity,
validatePassword: opts.type === 'password' ? opts.validatePassword : undefined,
+ isFocused,
...restOpts,
};
@@ -178,10 +193,8 @@ type DebouncingOption = {
isFocused?: boolean;
delayInMs?: number;
};
-
export const useFormControlFeedback = (opts?: DebouncingOption): DebouncedFeedback => {
const { feedback = '', delayInMs = 100, feedbackType = 'info', isFocused = false } = opts || {};
-
const shouldHide = isFocused ? false : ['info', 'warning'].includes(feedbackType);
const debouncedState = useDebounce(