From 12e1c2fb9e1939bfc0ef87ba3ad722ee810100b7 Mon Sep 17 00:00:00 2001 From: Saurabh Daware Date: Mon, 9 Dec 2024 15:43:27 +0530 Subject: [PATCH] feat(motion): add initial presets code (#2360) * feat: init motion presets rfc * docs: add poc video * docs: add comparison table and pocs * feat: add gsap poc * add layout animations poc with blade components * feat: add basic API decision * feat: add api decisions and memes * feat: add api decisions and memes * feat: remove unrelated changes * docs: add morph note * feat: add video example * docs: add note for previews * docs: add more videos * fix: images * fix: code alignments * docs: fix widths of cols * feat: add chat interface demo * typo * fix: width of previews * feat: update all token values * feat: motion, migrate internal motion tokens * fix: ts check * fix: ts * fix: switch delay * feat: add base entry exit presets * fix: example card alignment * feat: add stagger component * feat: add animateInteractions * refactor: use common BaseMotionBox * refactor: move stagger and animateinteraction check * fix: durations map * feat: add morph and scale preset * feat: add Slide * refactor: remove unused code add todo * docs: update animationInteractions docs * feat: add css bezier function * feat: add view transitions API note * feat: add view transitions API note * feat: add controled scale, enhancer animateinteraction * feat: replace framer-motion imports with motion/react * feat: rename framer motion to motion/react * feat: add framer motion name change note in library table * docs: add new open questions and conclusions * fix: change misleading scale heading * feat: add fade token values * fix(AnimateInteractions): a11y focus issues * feat: add token valyes for move * feat: add slide tokens * fix: stories * fix: typecheck * feat: add refs to components till checkbox * feat: add refs till radio * feat: migration to ref till typography * feat: add withRef story * fix: scale box * feat: add shouldUnmountWhenHidden * feat: handle no unmount transitions in stagger * feat: add slideFromOffset prop * refactor: simplify basemotion * feat: add memo for variants object * fix: focus on animate interactions * fix: resolve anurag's comments * feat: add comments for getOuterMotionRef * feat: add delay prop * fix: stagger type * feat: support framer-motion v4 * feat: add borderRadius and backgroundColor morph support * feat: remove reduced motion handling --- packages/blade/.storybook/react/preview.tsx | 22 +-- packages/blade/package.json | 2 + .../src/components/Accordion/Accordion.tsx | 32 +++-- packages/blade/src/components/Alert/Alert.tsx | 36 ++--- .../blade/src/components/Amount/Amount.tsx | 36 ++--- .../AnimateInteractions.native.tsx | 14 ++ .../AnimateInteractions.stories.tsx | 98 ++++++++++++++ .../AnimateInteractions.web.tsx | 31 +++++ .../AnimateInteractionsProvider.tsx | 12 ++ .../components/AnimateInteractions/index.ts | 2 + .../components/AnimateInteractions/types.ts | 6 + .../AnimateInteractions/useFocusWithin.ts | 35 +++++ packages/blade/src/components/Badge/Badge.tsx | 27 ++-- .../src/components/BaseMotion/BaseMotion.tsx | 125 ++++++++++++++++++ .../components/BaseMotion/baseMotionUtils.ts | 83 ++++++++++++ .../blade/src/components/BaseMotion/index.ts | 2 + .../blade/src/components/BaseMotion/types.ts | 89 +++++++++++++ .../components/BottomNav/BottomNav.web.tsx | 16 ++- .../BottomSheet/BottomSheet.web.tsx | 2 +- .../components/Breadcrumb/Breadcrumb.web.tsx | 23 ++-- .../ButtonGroup/ButtonGroup.web.tsx | 25 ++-- .../src/components/Card/Card.stories.tsx | 49 +++++++ packages/blade/src/components/Card/Card.tsx | 51 +++---- .../src/components/Card/CardRoot.native.tsx | 18 ++- .../src/components/Card/CardRoot.web.tsx | 14 +- .../components/Carousel/Carousel.native.tsx | 26 ++-- .../src/components/Carousel/Carousel.web.tsx | 45 ++++--- .../src/components/Checkbox/Checkbox.tsx | 9 +- packages/blade/src/components/Chip/Chip.tsx | 6 +- .../blade/src/components/Chip/ChipGroup.tsx | 50 ++++--- packages/blade/src/components/Chip/types.ts | 4 +- .../components/Collapsible/Collapsible.tsx | 32 +++-- .../blade/src/components/Counter/Counter.tsx | 27 ++-- .../blade/src/components/Divider/Divider.tsx | 28 ++-- .../src/components/Dropdown/Dropdown.tsx | 28 ++-- .../blade/src/components/Fade/Fade.native.tsx | 15 +++ .../src/components/Fade/Fade.stories.tsx | 92 +++++++++++++ .../blade/src/components/Fade/Fade.web.tsx | 56 ++++++++ packages/blade/src/components/Fade/index.ts | 2 + .../components/FileUpload/FileUpload.web.tsx | 5 +- .../blade/src/components/FileUpload/types.ts | 5 +- .../src/components/Indicator/Indicator.tsx | 29 ++-- .../components/Input/BaseInput/BaseInput.tsx | 19 ++- packages/blade/src/components/List/List.tsx | 19 +-- .../src/components/Morph/Morph.native.tsx | 14 ++ .../src/components/Morph/Morph.stories.tsx | 71 ++++++++++ .../blade/src/components/Morph/Morph.web.tsx | 25 ++++ packages/blade/src/components/Morph/index.ts | 2 + packages/blade/src/components/Morph/types.ts | 6 + .../blade/src/components/Move/Move.native.tsx | 15 +++ .../src/components/Move/Move.stories.tsx | 69 ++++++++++ .../blade/src/components/Move/Move.web.tsx | 61 +++++++++ packages/blade/src/components/Move/index.ts | 2 + .../components/ProgressBar/ProgressBar.tsx | 41 +++--- packages/blade/src/components/Radio/Radio.tsx | 11 +- .../components/Radio/RadioIcon/Fade.web.tsx | 2 +- .../src/components/Scale/Scale.native.tsx | 14 ++ .../src/components/Scale/Scale.stories.tsx | 64 +++++++++ .../blade/src/components/Scale/Scale.web.tsx | 45 +++++++ packages/blade/src/components/Scale/index.ts | 2 + packages/blade/src/components/Scale/types.ts | 11 ++ .../src/components/SideNav/SideNav.web.tsx | 16 +-- .../Skeleton/PulseAnimation.native.tsx | 10 +- .../src/components/Skeleton/Skeleton.tsx | 58 ++++---- .../src/components/Slide/Slide.native.tsx | 14 ++ .../src/components/Slide/Slide.stories.tsx | 57 ++++++++ .../blade/src/components/Slide/Slide.web.tsx | 120 +++++++++++++++++ packages/blade/src/components/Slide/index.ts | 2 + packages/blade/src/components/Slide/types.ts | 14 ++ .../Spinner/BaseSpinner/BaseSpinner.tsx | 26 ++-- .../components/Spinner/Spinner/Spinner.tsx | 26 ++-- .../src/components/Stagger/Stagger.native.tsx | 14 ++ .../components/Stagger/Stagger.stories.tsx | 88 ++++++++++++ .../src/components/Stagger/Stagger.web.tsx | 60 +++++++++ .../components/Stagger/StaggerProvider.tsx | 19 +++ .../blade/src/components/Stagger/index.ts | 2 + .../blade/src/components/Stagger/types.ts | 7 + .../components/StepGroup/StepGroup.web.tsx | 29 ++-- .../blade/src/components/Switch/Switch.tsx | 5 +- packages/blade/src/components/Switch/types.ts | 4 +- .../blade/src/components/Table/Table.web.tsx | 2 + .../blade/src/components/Tabs/Tabs.native.tsx | 4 +- .../blade/src/components/Tabs/Tabs.web.tsx | 29 ++-- packages/blade/src/components/Tag/Tag.tsx | 83 ++++++------ .../src/components/TopNav/TopNav.web.tsx | 9 +- .../src/components/Typography/Code/Code.tsx | 79 ++++++----- .../components/Typography/Display/Display.tsx | 32 +++-- .../components/Typography/Heading/Heading.tsx | 33 +++-- .../src/components/Typography/Text/Text.tsx | 45 +++---- .../Text/__tests__/Text.native.test.tsx | 3 +- .../Text/__tests__/Text.web.test.tsx | 3 +- packages/blade/src/tokens/global/motion.ts | 2 +- packages/blade/src/utils/cssBezierToArray.ts | 24 ++++ packages/blade/src/utils/getMotionRefs.ts | 87 ++++++++++++ packages/blade/src/utils/msToSeconds.ts | 5 + yarn.lock | 15 +++ 96 files changed, 2338 insertions(+), 490 deletions(-) create mode 100644 packages/blade/src/components/AnimateInteractions/AnimateInteractions.native.tsx create mode 100644 packages/blade/src/components/AnimateInteractions/AnimateInteractions.stories.tsx create mode 100644 packages/blade/src/components/AnimateInteractions/AnimateInteractions.web.tsx create mode 100644 packages/blade/src/components/AnimateInteractions/AnimateInteractionsProvider.tsx create mode 100644 packages/blade/src/components/AnimateInteractions/index.ts create mode 100644 packages/blade/src/components/AnimateInteractions/types.ts create mode 100644 packages/blade/src/components/AnimateInteractions/useFocusWithin.ts create mode 100644 packages/blade/src/components/BaseMotion/BaseMotion.tsx create mode 100644 packages/blade/src/components/BaseMotion/baseMotionUtils.ts create mode 100644 packages/blade/src/components/BaseMotion/index.ts create mode 100644 packages/blade/src/components/BaseMotion/types.ts create mode 100644 packages/blade/src/components/Fade/Fade.native.tsx create mode 100644 packages/blade/src/components/Fade/Fade.stories.tsx create mode 100644 packages/blade/src/components/Fade/Fade.web.tsx create mode 100644 packages/blade/src/components/Fade/index.ts create mode 100644 packages/blade/src/components/Morph/Morph.native.tsx create mode 100644 packages/blade/src/components/Morph/Morph.stories.tsx create mode 100644 packages/blade/src/components/Morph/Morph.web.tsx create mode 100644 packages/blade/src/components/Morph/index.ts create mode 100644 packages/blade/src/components/Morph/types.ts create mode 100644 packages/blade/src/components/Move/Move.native.tsx create mode 100644 packages/blade/src/components/Move/Move.stories.tsx create mode 100644 packages/blade/src/components/Move/Move.web.tsx create mode 100644 packages/blade/src/components/Move/index.ts create mode 100644 packages/blade/src/components/Scale/Scale.native.tsx create mode 100644 packages/blade/src/components/Scale/Scale.stories.tsx create mode 100644 packages/blade/src/components/Scale/Scale.web.tsx create mode 100644 packages/blade/src/components/Scale/index.ts create mode 100644 packages/blade/src/components/Scale/types.ts create mode 100644 packages/blade/src/components/Slide/Slide.native.tsx create mode 100644 packages/blade/src/components/Slide/Slide.stories.tsx create mode 100644 packages/blade/src/components/Slide/Slide.web.tsx create mode 100644 packages/blade/src/components/Slide/index.ts create mode 100644 packages/blade/src/components/Slide/types.ts create mode 100644 packages/blade/src/components/Stagger/Stagger.native.tsx create mode 100644 packages/blade/src/components/Stagger/Stagger.stories.tsx create mode 100644 packages/blade/src/components/Stagger/Stagger.web.tsx create mode 100644 packages/blade/src/components/Stagger/StaggerProvider.tsx create mode 100644 packages/blade/src/components/Stagger/index.ts create mode 100644 packages/blade/src/components/Stagger/types.ts create mode 100644 packages/blade/src/utils/cssBezierToArray.ts create mode 100644 packages/blade/src/utils/getMotionRefs.ts create mode 100644 packages/blade/src/utils/msToSeconds.ts diff --git a/packages/blade/.storybook/react/preview.tsx b/packages/blade/.storybook/react/preview.tsx index a0336b6921e..1b0f6836183 100644 --- a/packages/blade/.storybook/react/preview.tsx +++ b/packages/blade/.storybook/react/preview.tsx @@ -11,6 +11,7 @@ import { DocsContainer } from '@storybook/addon-docs'; import React from 'react'; import { MINIMAL_VIEWPORTS } from '@storybook/addon-viewport'; import './global.css'; +import { domMax, LazyMotion } from 'framer-motion'; export const parameters = { // disable snapshot by default and then enable it only for kitchen sink @@ -180,15 +181,18 @@ export const decorators = [ return ( - - - - - + {/* strict in LazyMotion will make sure we don't use excessive `motion` component in blade components and instead use light weight `m` */} + + + + + + + ); }, diff --git a/packages/blade/package.json b/packages/blade/package.json index 7042dd38589..c02a7feebdf 100644 --- a/packages/blade/package.json +++ b/packages/blade/package.json @@ -239,6 +239,7 @@ "fastify": "4.28.1", "figures": "3.2.0", "flat": "5.0.2", + "motion": "11.12.0", "globby": "14.0.1", "ismobilejs": "1.1.1", "jest": "29.6.1", @@ -292,6 +293,7 @@ "react": ">=18", "react-dom": ">=18", "styled-components": "^5", + "framer-motion": ">=4", "react-native": "^0.72", "@floating-ui/react-native": "^0.10.0", "react-native-reanimated": "^3.4.1", diff --git a/packages/blade/src/components/Accordion/Accordion.tsx b/packages/blade/src/components/Accordion/Accordion.tsx index 9c6cfcd065a..16f48851856 100644 --- a/packages/blade/src/components/Accordion/Accordion.tsx +++ b/packages/blade/src/components/Accordion/Accordion.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import type { ReactElement } from 'react'; import { useCallback, useMemo, useState, cloneElement, Children } from 'react'; import type { AccordionContextState } from './AccordionContext'; @@ -10,6 +11,7 @@ import type { BoxProps } from '~components/Box'; import { size as sizeTokens } from '~tokens/global'; import { makeSize } from '~utils'; import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; +import type { BladeElementRef } from '~utils/types'; const MIN_WIDTH: BoxProps['minWidth'] = { s: makeSize(sizeTokens[200]), @@ -61,18 +63,21 @@ const getVariantStyles = (variant: AccordionProps['variant']): BoxProps => { * Checkout https://blade.razorpay.com/?path=/docs/components-accordion--docs * */ -const Accordion = ({ - defaultExpandedIndex, - expandedIndex, - onExpandChange, - showNumberPrefix = false, - children, - variant = 'transparent', - size = 'large', - maxWidth, - testID, - ...styledProps -}: AccordionProps): ReactElement => { +const _Accordion = ( + { + defaultExpandedIndex, + expandedIndex, + onExpandChange, + showNumberPrefix = false, + children, + variant = 'transparent', + size = 'large', + maxWidth, + testID, + ...styledProps + }: AccordionProps, + ref: React.Ref, +): ReactElement => { const [expandedAccordionItemIndex, setExpandedAccordionItemIndex] = useState( defaultExpandedIndex, ); @@ -118,6 +123,7 @@ const Accordion = ({ return ( @@ -136,4 +142,6 @@ const Accordion = ({ ); }; +const Accordion = React.forwardRef(_Accordion); + export { Accordion }; diff --git a/packages/blade/src/components/Alert/Alert.tsx b/packages/blade/src/components/Alert/Alert.tsx index 1eb0d4ea530..388ae15e4ed 100644 --- a/packages/blade/src/components/Alert/Alert.tsx +++ b/packages/blade/src/components/Alert/Alert.tsx @@ -1,5 +1,5 @@ import type { ReactChild, ReactElement } from 'react'; -import { Fragment, useState } from 'react'; +import React, { Fragment, useState, forwardRef } from 'react'; import { StyledAlert } from './StyledAlert'; import type { IconComponent } from '~components/Icons'; @@ -21,7 +21,7 @@ import BaseButton from '~components/Button/BaseButton'; import { BaseLink } from '~components/Link/BaseLink'; import type { FeedbackColors, SubtleOrIntense } from '~tokens/theme/theme'; import { useTheme } from '~components/BladeProvider'; -import type { DotNotationSpacingStringToken, TestID } from '~utils/types'; +import type { BladeElementRef, DotNotationSpacingStringToken, TestID } from '~utils/types'; import { makeAccessible } from '~utils/makeAccessible'; type PrimaryAction = { @@ -124,19 +124,22 @@ const intentIconMap = { notice: AlertTriangleIcon, }; -const Alert = ({ - description, - title, - isDismissible = true, - onDismiss, - emphasis = 'subtle', - isFullWidth = false, - color = 'neutral', - actions, - testID, - icon, - ...styledProps -}: AlertProps): ReactElement | null => { +const _Alert = ( + { + description, + title, + isDismissible = true, + onDismiss, + emphasis = 'subtle', + isFullWidth = false, + color = 'neutral', + actions, + testID, + icon, + ...styledProps + }: AlertProps, + ref: React.Ref, +): ReactElement | null => { const { theme } = useTheme(); const { matchedDeviceType } = useBreakpoint({ breakpoints: theme.breakpoints }); const [isVisible, setIsVisible] = useState(true); @@ -298,6 +301,7 @@ const Alert = ({ return ( { +const _Amount = ( + { + value, + suffix = 'decimals', + type = 'body', + size = 'medium', + weight = 'regular', + isAffixSubtle = true, + isStrikethrough = false, + color, + currencyIndicator = 'currency-symbol', + currency = 'INR', + testID, + ...styledProps + }: AmountProps, + ref: React.Ref, +): ReactElement => { if (__DEV__) { if (typeof value !== 'number') { throwBladeError({ @@ -326,6 +329,7 @@ const _Amount = ({ return ( { + throwBladeError({ + message: 'AnimateInteractions is not yet implemented for native', + moduleName: 'AnimateInteractions', + }); + + return AnimateInteractions Component is not available for Native mobile apps.; +}; + +export { AnimateInteractions }; diff --git a/packages/blade/src/components/AnimateInteractions/AnimateInteractions.stories.tsx b/packages/blade/src/components/AnimateInteractions/AnimateInteractions.stories.tsx new file mode 100644 index 00000000000..e5cbcb4d771 --- /dev/null +++ b/packages/blade/src/components/AnimateInteractions/AnimateInteractions.stories.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; +import { Title } from '@storybook/addon-docs'; +import { AnimateInteractions } from './'; +import type { AnimateInteractionsProps } from './'; +import { Sandbox } from '~utils/storybook/Sandbox'; +import StoryPageWrapper from '~utils/storybook/StoryPageWrapper'; +import { Button } from '~components/Button'; +import { Box } from '~components/Box'; +import { Fade } from '~components/Fade'; +import { Move } from '~components/Move'; +import { Text } from '~components/Typography'; +import { Card, CardBody } from '~components/Card'; + +const Page = (): React.ReactElement => { + return ( + + Usage + + {` + const todo = 'todo'; + `} + + + ); +}; + +export default { + title: 'Motion/AnimateInteractions', + component: AnimateInteractions, + tags: ['autodocs'], + parameters: { + docs: { + page: Page, + }, + }, +} as Meta; + +const AnimateInteractionsTemplate: StoryFn = (args) => { + return ( + + + {args.children} + + + ); +}; + +export const Default = AnimateInteractionsTemplate.bind({}); +Default.args = { + children: ( + + Hover me for magic + + I appear depending on interaction on parent container + + + ), +}; + +export const ScaleChildOnCardHover = AnimateInteractionsTemplate.bind({}); +ScaleChildOnCardHover.args = { + children: ( + + + + + + Hi I am text inside card. Hover over this card to see magic + + + + + + + + + + + + + + ), +}; diff --git a/packages/blade/src/components/AnimateInteractions/AnimateInteractions.web.tsx b/packages/blade/src/components/AnimateInteractions/AnimateInteractions.web.tsx new file mode 100644 index 00000000000..87ef5d5a201 --- /dev/null +++ b/packages/blade/src/components/AnimateInteractions/AnimateInteractions.web.tsx @@ -0,0 +1,31 @@ +import { BaseMotionEnhancerBox } from '~components/BaseMotion'; +import { AnimateInteractionsContext } from './AnimateInteractionsProvider'; +import { useFocusWithin } from './useFocusWithin'; +import React from 'react'; +import { useAnimation } from 'framer-motion'; +import type { AnimateInteractionsProps } from './types'; + +export const AnimateInteractions = ({ + children, + motionTriggers = ['hover'], +}: AnimateInteractionsProps) => { + const baseMotionRef = React.useRef(null); + const controls = useAnimation(); + + useFocusWithin(baseMotionRef, { + onFocusWithin: () => { + controls.start('animate'); + }, + onBlurWithin: () => { + controls.start('exit'); + }, + }); + + return ( + + + {children} + + + ); +}; diff --git a/packages/blade/src/components/AnimateInteractions/AnimateInteractionsProvider.tsx b/packages/blade/src/components/AnimateInteractions/AnimateInteractionsProvider.tsx new file mode 100644 index 00000000000..0dc6d26f9d8 --- /dev/null +++ b/packages/blade/src/components/AnimateInteractions/AnimateInteractionsProvider.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +const AnimateInteractionsContext = React.createContext({ + isInsideAnimateInteractionsContainer: false, +}); + +const useAnimateInteractions = () => { + const animateInteractionsContextValue = React.useContext(AnimateInteractionsContext); + return animateInteractionsContextValue; +}; + +export { useAnimateInteractions, AnimateInteractionsContext }; diff --git a/packages/blade/src/components/AnimateInteractions/index.ts b/packages/blade/src/components/AnimateInteractions/index.ts new file mode 100644 index 00000000000..378a7a93219 --- /dev/null +++ b/packages/blade/src/components/AnimateInteractions/index.ts @@ -0,0 +1,2 @@ +export { AnimateInteractions } from './AnimateInteractions'; +export type { AnimateInteractionsProps } from './types'; diff --git a/packages/blade/src/components/AnimateInteractions/types.ts b/packages/blade/src/components/AnimateInteractions/types.ts new file mode 100644 index 00000000000..2af742e13ea --- /dev/null +++ b/packages/blade/src/components/AnimateInteractions/types.ts @@ -0,0 +1,6 @@ +import type { BaseMotionBoxProps } from '~components/BaseMotion'; + +export type AnimateInteractionsProps = { + children: React.ReactElement; + motionTriggers?: BaseMotionBoxProps['motionTriggers']; +}; diff --git a/packages/blade/src/components/AnimateInteractions/useFocusWithin.ts b/packages/blade/src/components/AnimateInteractions/useFocusWithin.ts new file mode 100644 index 00000000000..02cf5e2c5b5 --- /dev/null +++ b/packages/blade/src/components/AnimateInteractions/useFocusWithin.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; + +type FocusWithinHandlers = { + onFocusWithin?: () => void; + onBlurWithin?: () => void; +}; + +export function useFocusWithin( + ref: React.RefObject, + { onFocusWithin, onBlurWithin }: FocusWithinHandlers, +) { + useEffect(() => { + const element = ref.current; + if (!element) return; + + const handleFocusIn = () => { + onFocusWithin?.(); + }; + + const handleFocusOut = (event: FocusEvent) => { + // Ensure that focus is not still within the container + if (element && !element.contains(event.relatedTarget as Node)) { + onBlurWithin?.(); + } + }; + + element.addEventListener('focusin', handleFocusIn); + element.addEventListener('focusout', handleFocusOut); + + return () => { + element.removeEventListener('focusin', handleFocusIn); + element.removeEventListener('focusout', handleFocusOut); + }; + }, [ref, onFocusWithin, onBlurWithin]); +} diff --git a/packages/blade/src/components/Badge/Badge.tsx b/packages/blade/src/components/Badge/Badge.tsx index baa2763afa6..046bf90cc7f 100644 --- a/packages/blade/src/components/Badge/Badge.tsx +++ b/packages/blade/src/components/Badge/Badge.tsx @@ -10,11 +10,12 @@ import { Text } from '~components/Typography'; import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; import { getStyledProps } from '~components/Box/styledProps'; import type { StyledPropsBlade } from '~components/Box/styledProps'; -import type { StringChildrenType, TestID } from '~utils/types'; +import type { BladeElementRef, StringChildrenType, TestID } from '~utils/types'; import { getStringFromReactText } from '~src/utils/getStringChildren'; import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; import { isReactNative, makeSize } from '~utils'; import { throwBladeError } from '~utils/logger'; +import React from 'react'; type BadgeProps = { /** @@ -87,15 +88,18 @@ const getColorProps = ({ return props; }; -const _Badge = ({ - children, - emphasis = 'subtle', - icon: Icon, - size = 'medium', - color = 'neutral', - testID, - ...styledProps -}: BadgeProps): ReactElement => { +const _Badge = ( + { + children, + emphasis = 'subtle', + icon: Icon, + size = 'medium', + color = 'neutral', + testID, + ...styledProps + }: BadgeProps, + ref: React.Ref, +): ReactElement => { const childrenString = getStringFromReactText(children); if (__DEV__) { if (!childrenString?.trim()) { @@ -128,6 +132,7 @@ const _Badge = ({ return ( , +) => { + const { isInsideAnimateInteractionsContainer } = useAnimateInteractions(); + const { isInsideStaggerContainer, staggerType } = useStagger(); + const shouldSkipAnimationVariables = + (isInsideAnimateInteractionsContainer && motionTriggers.includes('on-animate-interactions')) || + isInsideStaggerContainer; + + const animationVariables = shouldSkipAnimationVariables + ? {} + : makeAnimationVariables(motionTriggers, { + animateVisibility, + }); + + const motionVariants = useMotionVariants( + userMotionVariants, + isInsideStaggerContainer ? staggerType : type, + ); + + return ( + + {children} + + ); +}; + +/** + * Base motion component that handles animation variables, reduced motion, type and motionTriggers prop, etc + */ +const BaseMotionBox = React.forwardRef(_BaseMotionBox); + +const _BaseMotionEnhancerBox: React.ForwardRefRenderFunction = ( + { children, ...motionBoxArgs }, + ref, +) => { + return ( + + ); +}; + +/** + * Used in AnimateInteraction, Scale, etc + * + * Enhances the child to add motion support + */ +const BaseMotionEnhancerBox = React.forwardRef(_BaseMotionEnhancerBox); + +/** + * Base component for entry / exit animations + * + * Handles states, entry exit controls, animation variables, mount / unmount, etc + */ +const BaseMotionEntryExit = ({ + children, + motionVariants, + isVisible = true, + type = 'inout', + motionTriggers = ['mount'], + shouldUnmountWhenHidden = false, +}: BaseMotionEntryExitProps) => { + // Only need AnimatePresence when we have to unmount the component + const AnimateWrapper = shouldUnmountWhenHidden ? AnimatePresence : React.Fragment; + // keep it always mounted when shouldUnmountWhenHidden is false + const isMounted = shouldUnmountWhenHidden ? isVisible : true; + + const motionMeta: MotionMeta = React.useMemo(() => { + return { + isEnhanced: true, + // @ts-expect-error: ref does exist on children prop + innerRef: children.ref, + }; + // @ts-expect-error: ref does exist on children prop + }, [children.ref]); + + return ( + + {isMounted ? ( + + ) : null} + + ); +}; + +export { MotionDiv, BaseMotionBox, BaseMotionEnhancerBox, BaseMotionEntryExit }; diff --git a/packages/blade/src/components/BaseMotion/baseMotionUtils.ts b/packages/blade/src/components/BaseMotion/baseMotionUtils.ts new file mode 100644 index 00000000000..5f3c4f7b890 --- /dev/null +++ b/packages/blade/src/components/BaseMotion/baseMotionUtils.ts @@ -0,0 +1,83 @@ +import type { BaseMotionBoxProps, MotionTriggersType, MotionVariantsType } from './types'; + +// This type is exported in new framer-motion versions but does not exist in earlier versions so adding it manually here +type AnimationType = 'animate' | 'whileHover' | 'whileInView' | 'whileTap' | 'whileFocus'; + +const motionTriggersArrayToGesturePropsMap: Record< + Exclude, + AnimationType +> = { + mount: 'animate', + hover: 'whileHover', + 'in-view': 'whileInView', + tap: 'whileTap', + focus: 'whileFocus', +}; + +type AnimationVariablesType = Partial< + Record, keyof MotionVariantsType> +> & { + animate?: BaseMotionBoxProps['animateVisibility'] | BaseMotionBoxProps['animate']; +}; + +const makeAnimationVariables = ( + motionTriggers: MotionTriggersType[], + { animateVisibility }: { animateVisibility: BaseMotionBoxProps['animateVisibility'] }, +) => { + const interactionVariables = motionTriggers.reduce( + (prevProps, currentTrigger) => { + if (currentTrigger === 'on-animate-interactions') { + return prevProps; + } + + // Sometimes animations are conditional. In those cases we use those conditional values in animate + if (currentTrigger === 'mount' && animateVisibility) { + prevProps.animate = animateVisibility; + return prevProps; + } + + prevProps[motionTriggersArrayToGesturePropsMap[currentTrigger]] = 'animate'; + return prevProps; + }, + {}, + ); + + return { initial: 'initial', exit: 'exit', ...interactionVariables }; +}; + +const useMotionVariants = ( + motionVariants: BaseMotionBoxProps['motionVariants'], + type: BaseMotionBoxProps['type'], +): BaseMotionBoxProps['motionVariants'] => { + if (!motionVariants) { + return undefined; + } + + const shouldSkipEntryAnimation = type === 'out'; + const shouldSkipExitAnimation = type === 'in'; + + // We override durations to stop animations but still continue with the expected position changes + const newMotionVariants: BaseMotionBoxProps['motionVariants'] = { + initial: { + ...motionVariants.initial, + }, + animate: { + ...motionVariants.animate, + transition: { + ...motionVariants.animate?.transition, + duration: shouldSkipEntryAnimation ? 0.0001 : motionVariants.animate?.transition?.duration, + }, + }, + exit: { + ...motionVariants.exit, + transition: { + ...motionVariants.exit.transition, + duration: shouldSkipExitAnimation ? 0.0001 : motionVariants.exit.transition?.duration, + }, + }, + }; + + return newMotionVariants; +}; + +export { makeAnimationVariables, useMotionVariants }; diff --git a/packages/blade/src/components/BaseMotion/index.ts b/packages/blade/src/components/BaseMotion/index.ts new file mode 100644 index 00000000000..a3bfa761445 --- /dev/null +++ b/packages/blade/src/components/BaseMotion/index.ts @@ -0,0 +1,2 @@ +export * from './BaseMotion'; +export * from './types'; diff --git a/packages/blade/src/components/BaseMotion/types.ts b/packages/blade/src/components/BaseMotion/types.ts new file mode 100644 index 00000000000..1aa3ca228db --- /dev/null +++ b/packages/blade/src/components/BaseMotion/types.ts @@ -0,0 +1,89 @@ +import type { AnimationControls, TargetAndTransition, Tween } from 'framer-motion'; +import type React from 'react'; +import type { Delay } from '~tokens/global/motion'; + +type MotionTriggerEntryExitType = 'mount' | 'in-view' | 'focus' | 'on-animate-interactions'; +type MotionTriggersType = MotionTriggerEntryExitType | 'hover' | 'tap'; + +type MotionVariantsType = { + initial: TargetAndTransition & { + transition?: Tween; + }; + animate: TargetAndTransition & { + transition?: Tween; + }; + exit: TargetAndTransition & { + transition?: Tween; + }; +}; + +type MotionDelay = keyof Delay | { enter: keyof Delay; exit: keyof Delay }; + +type BaseMotionBoxProps = { + as?: React.ReactElement; + children: React.ReactElement; + type?: 'in' | 'out' | 'inout'; + /** + * @default ['mount'] + */ + motionTriggers?: MotionTriggersType[]; + motionVariants?: MotionVariantsType; + /** + * Option to override the animate + * + * Useful when you want to control animation custom + * + * E.g. + * ```js + * const controls = useAnimationControls(); + * + * animate={controls} + * ``` + */ + animate?: AnimationControls; + + /** + * This is for scenarios where you want to conditionally animate a component instead of it having static defined animation. + * + * E.g. In scenarios where your motion component is always mounted, you can use this to switch visibility + * + * ```js + * animateVisibility={isVisible ? 'animate' : 'exit'} + * ``` + */ + animateVisibility?: keyof MotionVariantsType; +}; + +type BaseMotionEntryExitProps = Pick & { + isVisible?: boolean; + motionTriggers?: MotionTriggerEntryExitType[]; + shouldUnmountWhenHidden?: boolean; + delay?: MotionDelay; +}; + +type MotionMeta = { + innerRef: React.Ref; + isEnhanced: boolean; +}; + +type MotionMetaProp = { + /** + * @private + * + * This prop is internally injected when Motion Preset enhances some component. + * + * You only need to add this prop to component that requires you to pass ref to some internal component. E.g. in checkbox, we need ref on internal input component but we also need one ref on outer component. + * Use this in combination with `getOuterMotionRef` and `getInnerMotionRef` utilities + */ + _motionMeta?: MotionMeta; +}; + +export type { + BaseMotionEntryExitProps, + MotionVariantsType, + MotionTriggersType, + BaseMotionBoxProps, + MotionMeta, + MotionMetaProp, + MotionDelay, +}; diff --git a/packages/blade/src/components/BottomNav/BottomNav.web.tsx b/packages/blade/src/components/BottomNav/BottomNav.web.tsx index ae24e4d620b..42b05cbaf9f 100644 --- a/packages/blade/src/components/BottomNav/BottomNav.web.tsx +++ b/packages/blade/src/components/BottomNav/BottomNav.web.tsx @@ -10,6 +10,7 @@ import { getFocusRingStyles } from '~utils/getFocusRingStyles'; import { throwBladeError } from '~utils/logger'; import { makeAccessible } from '~utils/makeAccessible'; import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; +import type { BladeElementRef } from '~utils/types'; /** * ### BottomNav component @@ -51,12 +52,10 @@ import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; * Checkout {@link https://blade.razorpay.com/??path=/docs/components-bottomnav--doc BottomNav Documentation} */ -const BottomNav = ({ - children, - zIndex = componentZIndices.bottomNav, - testID, - ...styledProps -}: BottomNavProps): React.ReactElement => { +const _BottomNav = ( + { children, zIndex = componentZIndices.bottomNav, testID, ...styledProps }: BottomNavProps, + ref: React.Ref, +): React.ReactElement => { if (__DEV__) { const childrenCount = React.Children.count(children); if (childrenCount > 5 && childrenCount < 2) { @@ -69,6 +68,7 @@ const BottomNav = ({ return ( ((props) => { paddingLeft: makeSpace(props.theme.spacing[0]), paddingRight: makeSpace(props.theme.spacing[0]), transition: `color ${makeMotionTime(props.theme.motion.duration['2xquick'])} ${ - props.theme.motion.easing.standard.effective + props.theme.motion.easing.standard }`, '&[aria-current="page"]': { color: props.theme.colors.interactive.text.primary.subtle, @@ -161,4 +161,6 @@ const BottomNavItem = ({ ); }; +const BottomNav = React.forwardRef(_BottomNav); + export { BottomNav, BottomNavItem }; diff --git a/packages/blade/src/components/BottomSheet/BottomSheet.web.tsx b/packages/blade/src/components/BottomSheet/BottomSheet.web.tsx index 3404e4a8fc1..aa4e79807b7 100644 --- a/packages/blade/src/components/BottomSheet/BottomSheet.web.tsx +++ b/packages/blade/src/components/BottomSheet/BottomSheet.web.tsx @@ -378,7 +378,7 @@ const _BottomSheet = ({ }, [isReady]); // usePresence hook waits for the animation to finish before unmounting the component - // It's similar to framer-motions usePresence hook + // It's similar to motion/react's usePresence hook // https://www.framer.com/docs/animate-presence/#usepresence const { isMounted, isVisible } = usePresence(Boolean(_isOpen), { transitionDuration: theme.motion.duration.moderate, diff --git a/packages/blade/src/components/Breadcrumb/Breadcrumb.web.tsx b/packages/blade/src/components/Breadcrumb/Breadcrumb.web.tsx index 4e3fd89c65f..6551a8bc404 100644 --- a/packages/blade/src/components/Breadcrumb/Breadcrumb.web.tsx +++ b/packages/blade/src/components/Breadcrumb/Breadcrumb.web.tsx @@ -7,6 +7,7 @@ import { Text } from '~components/Typography'; import { makeAccessible } from '~utils/makeAccessible'; import { getStyledProps } from '~components/Box/styledProps'; import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; +import type { BladeElementRef } from '~utils/types'; const Separator = ({ size, @@ -26,18 +27,22 @@ const Separator = ({ const listStyleNone = { listStyle: 'none' }; -const Breadcrumb = ({ - size = 'medium', - color = 'primary', - showLastSeparator = false, - accessibilityLabel = 'Breadcrumb', - children, - ...styledProps -}: BreadcrumbProps): React.ReactElement => { +const _Breadcrumb = ( + { + size = 'medium', + color = 'primary', + showLastSeparator = false, + accessibilityLabel = 'Breadcrumb', + children, + ...styledProps + }: BreadcrumbProps, + ref: React.Ref, +): React.ReactElement => { const contextValue = React.useMemo(() => ({ size, color }), [size, color]); return ( { +const _ButtonGroup = ( + { + children, + isDisabled = false, + size = 'medium', + color = 'primary', + variant = 'primary', + isFullWidth = false, + testID, + ...styledProps + }: ButtonGroupProps, + ref: React.Ref, +): React.ReactElement => { const contextValue = { isDisabled, size, @@ -72,6 +76,7 @@ const _ButtonGroup = ({ return (