Skip to content

Commit

Permalink
fix(clerk-js): Smooth error animation and min height
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg committed Sep 27, 2023
1 parent 889e87b commit 1fd9157
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 49 deletions.
138 changes: 100 additions & 38 deletions packages/clerk-js/src/ui/elements/FormControl.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -52,7 +53,7 @@ type FormControlProps = Omit<PropsOfComponent<typeof Input>, 'label' | 'placehol
feedbackType: FeedbackType;
setHasPassedComplexity: (b: boolean) => void;
hasPassedComplexity: boolean;
informationText?: string | LocalizationKey;
infoText?: string | LocalizationKey;
radioOptions?: {
value: string;
label: string | LocalizationKey;
Expand Down Expand Up @@ -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],
Expand All @@ -114,25 +115,18 @@ function useFormTextAnimation() {
};
}

type CalculateConfigProps = {
recalculate?: LocalizationKey | string | undefined;
};
type Px = number;
const useCalculateErrorTextHeight = (config: CalculateConfigProps = {}) => {
const [height, setHeight] = useState<Px>(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,
Expand All @@ -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<ReturnType<typeof useFormControlFeedback>['debounced'] & { id: FieldId }> & {
elementDescriptors?: Partial<Record<FormFeedbackDescriptorsKeys, ElementDescriptor>>;
};

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,
Expand All @@ -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 {
Expand All @@ -170,42 +199,63 @@ export const FormFeedback = (props: FormFeedbackProps) => {
};
};

if (!feedback) {
return null;
}

const FormInfoComponent: Record<FeedbackType, ComponentType> = {
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 (
<Box
style={{
height, // dynamic height
height: heightMax, // dynamic height
position: 'relative',
}}
sx={[getFormTextAnimation(!!feedback)]}
>
<InfoComponent
{...getElementProps(feedbackType || 'info')}
//@ts-expect-error
ref={calculateHeight}
sx={getFormTextAnimation(!!feedback)}
localizationKey={feedback}
/>
<Box>
<InfoComponentA
{...(feedbacks.a?.feedbackType ? getElementProps(feedbacks.a.feedbackType) : {})}
ref={calculateHeightA}
sx={[
() => ({
visibility: feedbacks.a?.shouldEnter ? 'visible' : 'hidden',
}),
getFormTextAnimation(!!feedbacks.a?.shouldEnter),
]}
localizationKey={feedbacks.a?.feedback}
/>
<InfoComponentB
{...(feedbacks.b?.feedbackType ? getElementProps(feedbacks.b.feedbackType) : {})}
ref={calculateHeightB}
sx={[
() => ({
visibility: feedbacks.b?.shouldEnter ? 'visible' : 'hidden',
}),
getFormTextAnimation(!!feedbacks.b?.shouldEnter),
]}
localizationKey={feedbacks.b?.feedback}
/>
</Box>
</Box>
);
};

export const FormControl = forwardRef<HTMLInputElement, PropsWithChildren<FormControlProps>>((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,
Expand Down Expand Up @@ -265,7 +315,7 @@ export const FormControl = forwardRef<HTMLInputElement, PropsWithChildren<FormCo

const isCheckbox = props.type === 'checkbox';

const { debounced: debouncedState } = useFormControlFeedback({ feedback, feedbackType });
const { debounced: debouncedState } = useFormControlFeedback({ feedback, feedbackType, isFocused });

const ActionLabel = actionLabel ? (
<Link
Expand Down Expand Up @@ -339,6 +389,18 @@ export const FormControl = forwardRef<HTMLInputElement, PropsWithChildren<FormCo
isDisabled={isDisabled}
isRequired={isRequired}
{...inputElementProps}
onFocus={e => {
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)}
/>
Expand Down
15 changes: 6 additions & 9 deletions packages/clerk-js/src/ui/styledSystem/animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
`;

Expand Down
10 changes: 8 additions & 2 deletions packages/clerk-js/src/ui/utils/useFormControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 1fd9157

Please sign in to comment.