diff --git a/packages/clerk-js/src/ui/elements/FormControl.tsx b/packages/clerk-js/src/ui/elements/FormControl.tsx index b9de767e6c5..24efddb346d 100644 --- a/packages/clerk-js/src/ui/elements/FormControl.tsx +++ b/packages/clerk-js/src/ui/elements/FormControl.tsx @@ -1,6 +1,7 @@ import type { FieldId } from '@clerk/types'; import type { ClerkAPIError } from '@clerk/types'; -import type { ComponentType, PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; +import { useEffect } from 'react'; import React, { forwardRef, useCallback, useMemo, useState } from 'react'; import type { LocalizationKey } from '../customizables'; @@ -22,7 +23,7 @@ import { useLocalizations, } from '../customizables'; import type { ElementDescriptor } from '../customizables/elementDescriptors'; -import { useFieldMessageVisibility, usePrefersReducedMotion } from '../hooks'; +import { usePrefersReducedMotion } from '../hooks'; import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; import { animations } from '../styledSystem'; import type { FeedbackType } from '../utils'; @@ -52,7 +53,7 @@ type FormControlProps = Omit, 'label' | 'placehol feedbackType: FeedbackType; setHasPassedComplexity: (b: boolean) => void; hasPassedComplexity: boolean; - informationText?: string | LocalizationKey; + infoText?: string | LocalizationKey; radioOptions?: { value: string; label: string | LocalizationKey; @@ -103,7 +104,7 @@ function useFormTextAnimation() { animation: `${enterAnimation ? animations.inAnimation : animations.outAnimation} ${ t.transitionDuration.$textField } ${t.transitionTiming.$common}`, - transition: `height ${t.transitionDuration.$slow} ${t.transitionTiming.$common}`, // This is expensive but required for a smooth layout shift + transition: `height ${t.transitionDuration.$slow} ${t.transitionTiming.$common}`, // This is expensive but required for a smooth layout shift }); }, [prefersReducedMotion], @@ -114,25 +115,18 @@ function useFormTextAnimation() { }; } -type CalculateConfigProps = { - recalculate?: LocalizationKey | string | undefined; -}; -type Px = number; -const useCalculateErrorTextHeight = (config: CalculateConfigProps = {}) => { - const [height, setHeight] = useState(24); +const useCalculateErrorTextHeight = ({ feedback }: { feedback: string }) => { + const [height, setHeight] = useState(0); const calculateHeight = useCallback( (element: HTMLElement | null) => { if (element) { - const computedStyles = getComputedStyle(element); - const marginTop = parseInt(computedStyles.marginTop.replace('px', '')); - - const newHeight = 1.1 * marginTop + element.scrollHeight; - setHeight(newHeight); + setHeight(element.scrollHeight); } }, - [config?.recalculate], + [feedback], ); + return { height, calculateHeight, @@ -141,19 +135,26 @@ const useCalculateErrorTextHeight = (config: CalculateConfigProps = {}) => { type FormFeedbackDescriptorsKeys = 'error' | 'warning' | 'info' | 'success'; +type Feedback = { feedback?: string; feedbackType?: FeedbackType; shouldEnter: boolean }; + type FormFeedbackProps = Partial['debounced'] & { id: FieldId }> & { elementDescriptors?: Partial>; }; -const delay = 350; - export const FormFeedback = (props: FormFeedbackProps) => { - const { id, elementDescriptors, feedback: _feedback, feedbackType: _feedbackType = 'info' } = props; - const feedback = useFieldMessageVisibility(_feedback, delay); - const feedbackType = useFieldMessageVisibility(_feedbackType, delay); - const { calculateHeight, height } = useCalculateErrorTextHeight({ - recalculate: feedback, + const { id, elementDescriptors, feedback, feedbackType = 'info' } = props; + const [feedbacks, setFeedbacks] = useState<{ + a?: Feedback; + b?: Feedback; + }>({ a: { feedback, feedbackType, shouldEnter: true }, b: undefined }); + const { calculateHeight: calculateHeightA, height: heightA } = useCalculateErrorTextHeight({ + feedback: feedbacks.a?.feedback || '', }); + const { calculateHeight: calculateHeightB, height: heightB } = useCalculateErrorTextHeight({ + feedback: feedbacks.b?.feedback || '', + }); + const [heightMax, setHeightMax] = useState(Math.max(heightA, heightB)); + const { getFormTextAnimation } = useFormTextAnimation(); const defaultElementDescriptors = { error: descriptors.formFieldErrorText, @@ -162,6 +163,34 @@ export const FormFeedback = (props: FormFeedbackProps) => { success: descriptors.formFieldSuccessText, }; + useEffect(() => { + setFeedbacks(oldFeedbacks => { + if (oldFeedbacks.a?.shouldEnter) { + return { + a: { ...oldFeedbacks.a, shouldEnter: false }, + b: { + feedback, + feedbackType, + shouldEnter: true, + }, + }; + } else { + return { + a: { + feedback, + feedbackType, + shouldEnter: true, + }, + b: { ...oldFeedbacks.b, shouldEnter: false }, + }; + } + }); + }, [feedback, feedbackType]); + + useEffect(() => { + setHeightMax(h => Math.max(heightA, heightB, h)); + }, [heightA, heightB]); + const getElementProps = (type: FormFeedbackDescriptorsKeys) => { const descriptor = (elementDescriptors?.[type] || defaultElementDescriptors[type]) as ElementDescriptor | undefined; return { @@ -170,34 +199,51 @@ export const FormFeedback = (props: FormFeedbackProps) => { }; }; - if (!feedback) { - return null; - } - - const FormInfoComponent: Record = { + const FormInfoComponent: Record< + FeedbackType, + typeof FormErrorText | typeof FormInfoText | typeof FormSuccessText | typeof FormWarningText + > = { error: FormErrorText, info: FormInfoText, success: FormSuccessText, warning: FormWarningText, }; - const InfoComponent = FormInfoComponent[feedbackType || 'info']; + const InfoComponentA = FormInfoComponent[feedbacks.a?.feedbackType || 'info']; + const InfoComponentB = FormInfoComponent[feedbacks.b?.feedbackType || 'info']; return ( - + + ({ + visibility: feedbacks.a?.shouldEnter ? 'visible' : 'hidden', + }), + getFormTextAnimation(!!feedbacks.a?.shouldEnter), + ]} + localizationKey={feedbacks.a?.feedback} + /> + ({ + visibility: feedbacks.b?.shouldEnter ? 'visible' : 'hidden', + }), + getFormTextAnimation(!!feedbacks.b?.shouldEnter), + ]} + localizationKey={feedbacks.b?.feedback} + /> + ); }; @@ -205,7 +251,11 @@ export const FormFeedback = (props: FormFeedbackProps) => { export const FormControl = forwardRef>((props, ref) => { const { t } = useLocalizations(); const card = useCardState(); + const [isFocused, setIsFocused] = useState(false); const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + hasPassedComplexity, + infoText, id, isRequired, isOptional, @@ -265,7 +315,7 @@ export const FormControl = forwardRef { + inputElementProps.onFocus?.(e); + setIsFocused(true); + }} + onBlur={e => { + inputElementProps.onBlur?.(e); + // set a timeout because new errors might appear + // and we don't want to spam layout shifts + setTimeout(() => { + setIsFocused(false); + }, 500); + }} ref={ref} placeholder={t(placeholder)} /> diff --git a/packages/clerk-js/src/ui/styledSystem/animations.ts b/packages/clerk-js/src/ui/styledSystem/animations.ts index 4dcf904cda5..e2c643bbcc9 100644 --- a/packages/clerk-js/src/ui/styledSystem/animations.ts +++ b/packages/clerk-js/src/ui/styledSystem/animations.ts @@ -65,20 +65,17 @@ const notificationAnimation = keyframes` `; const outAnimation = keyframes` - 20% { - opacity: 1; - transform: translateY(0px); + 0% { + opacity:1; + translateY(0px); max-height: 6rem; - } - 80% { - opacity: 0; - transform: translateY(5px); - max-height: 0; - } + visibility: visible; + } 100% { opacity: 0; transform: translateY(5px); max-height: 0; + visibility: visible; } `; diff --git a/packages/clerk-js/src/ui/utils/useFormControl.ts b/packages/clerk-js/src/ui/utils/useFormControl.ts index 60a3dc9290a..3a9c061b581 100644 --- a/packages/clerk-js/src/ui/utils/useFormControl.ts +++ b/packages/clerk-js/src/ui/utils/useFormControl.ts @@ -168,12 +168,18 @@ type DebouncedFeedback = { type DebouncingOption = { feedback?: string; feedbackType?: FeedbackType; + isFocused?: boolean; delayInMs?: number; }; export const useFormControlFeedback = (opts?: DebouncingOption): DebouncedFeedback => { - const { feedback = '', delayInMs = 100, feedbackType = 'info' } = opts || {}; + const { feedback = '', delayInMs = 100, feedbackType = 'info', isFocused = false } = opts || {}; - const debouncedState = useSetTimeout({ feedback, feedbackType }, delayInMs); + const shouldHide = isFocused ? false : ['info', 'warning'].includes(feedbackType); + + const debouncedState = useSetTimeout( + { feedback: shouldHide ? '' : feedback, feedbackType: shouldHide ? 'info' : feedbackType }, + delayInMs, + ); return { debounced: debouncedState,