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(