diff --git a/apps/docs/docs/components/overlay/Tray/_mobileExamples.mdx b/apps/docs/docs/components/overlay/Tray/_mobileExamples.mdx index fedf31c23..305625313 100644 --- a/apps/docs/docs/components/overlay/Tray/_mobileExamples.mdx +++ b/apps/docs/docs/components/overlay/Tray/_mobileExamples.mdx @@ -370,8 +370,33 @@ function PreventDismissTray() { ## Accessibility +### Accessibility labels + Trays require an accessibility label. If you pass in a ReactNode to `title`, make sure to set `accessibilityLabel`. +### Reduce Motion + +Use the `reduceMotion` prop to accommodate users with reduced motion settings. + +```jsx +function ReducedMotionTray() { + const [visible, setVisible] = useState(false); + const handleOpen = () => setVisible(true); + const handleClose = () => setVisible(false); + + return ( + + + {visible && ( + + This tray fades in and out using opacity. + + )} + + ); +} +``` + ## Styling ### Handlebar diff --git a/apps/docs/docs/components/overlay/Tray/_webExamples.mdx b/apps/docs/docs/components/overlay/Tray/_webExamples.mdx index 4e5298560..ac31fa252 100644 --- a/apps/docs/docs/components/overlay/Tray/_webExamples.mdx +++ b/apps/docs/docs/components/overlay/Tray/_webExamples.mdx @@ -441,8 +441,51 @@ function PreventDismissTray() { ## Accessibility +### Accessibility labels + Trays require an accessibility label. If you pass in a ReactNode to `title`, make sure to set `accessibilityLabel` or `accessibilityLabelledBy`. +### Reduce Motion + +Use the `reduceMotion` prop to accommodate users with reduced motion settings. + +```jsx live +function ReducedMotionTray() { + const [visible, setVisible] = useState(false); + const { isPhone } = useBreakpoints(); + const handleOpen = () => setVisible(true); + const handleClose = () => setVisible(false); + + return ( + + + {visible && ( + ( + + Close + + } + /> + )} + > + This tray fades in and out using opacity. + + )} + + ); +} +``` + ## Styling The Tray component exposes `styles` and `classNames` props for customizing various parts of the component. Available keys include: `root`, `overlay`, `container`, `header`, `title`, `content`, `footer`, `handleBar`, `handleBarHandle`, and `closeButton`. diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index 20e857e39..45dc15491 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -246,6 +246,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/DrawerMisc.stories').default, }, + { + key: 'DrawerReduceMotion', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/DrawerReduceMotion.stories').default, + }, { key: 'DrawerRight', getComponent: () => @@ -840,6 +845,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/TrayRedesign.stories').default, }, + { + key: 'TrayReduceMotion', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayReduceMotion.stories').default, + }, { key: 'TrayScrollable', getComponent: () => diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 54367973c..714e7b004 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.45.0 ((2/12/2026, 07:33 AM PST)) + +This is an artificial version bump with no new change. + ## 8.44.2 ((2/10/2026, 08:38 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index 0763016ca..7a5977803 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.44.2", + "version": "8.45.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 07d278cbe..e85c04073 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.45.0 ((2/12/2026, 07:33 AM PST)) + +This is an artificial version bump with no new change. + ## 8.44.2 ((2/10/2026, 08:38 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 19878e938..184c80328 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.44.2", + "version": "8.45.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 8ea3b2404..e0caef6c7 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 8.45.0 (2/12/2026 PST) + +#### 🚀 Updates + +- Add reduce motion support for Tray. [[#386](https://github.com/coinbase/cds/pull/386)] + ## 8.44.2 (2/10/2026 PST) #### 🐞 Fixes diff --git a/packages/mobile/package.json b/packages/mobile/package.json index e782e11fc..4e13eb603 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.44.2", + "version": "8.45.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/overlays/__stories__/DrawerReduceMotion.stories.tsx b/packages/mobile/src/overlays/__stories__/DrawerReduceMotion.stories.tsx new file mode 100644 index 000000000..28d4aa8a9 --- /dev/null +++ b/packages/mobile/src/overlays/__stories__/DrawerReduceMotion.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; + +import { DefaultDrawer } from './Drawers'; + +const DrawerReduceMotionScreen = () => { + return ( + + + + + + ); +}; + +export default DrawerReduceMotionScreen; diff --git a/packages/mobile/src/overlays/__stories__/Drawers.tsx b/packages/mobile/src/overlays/__stories__/Drawers.tsx index 21bcbf8c8..8bbf0840b 100644 --- a/packages/mobile/src/overlays/__stories__/Drawers.tsx +++ b/packages/mobile/src/overlays/__stories__/Drawers.tsx @@ -11,7 +11,10 @@ import { Text } from '../../typography/Text'; import type { DrawerBaseProps } from '../drawer/Drawer'; import { Drawer } from '../drawer/Drawer'; -export const DefaultDrawer = ({ pin = 'left' }: Pick) => { +export const DefaultDrawer = ({ + pin = 'left', + reduceMotion, +}: Pick) => { const [isVisible, setIsVisible] = useState(true); const setIsVisibleOff = useCallback(() => setIsVisible(false), [setIsVisible]); const setIsVisibleOn = useCallback(() => setIsVisible(true), [setIsVisible]); @@ -19,7 +22,7 @@ export const DefaultDrawer = ({ pin = 'left' }: Pick) => <> {isVisible && ( - + {({ handleClose }) => ( diff --git a/packages/mobile/src/overlays/__stories__/TrayReduceMotion.stories.tsx b/packages/mobile/src/overlays/__stories__/TrayReduceMotion.stories.tsx new file mode 100644 index 000000000..2bdfbe40c --- /dev/null +++ b/packages/mobile/src/overlays/__stories__/TrayReduceMotion.stories.tsx @@ -0,0 +1,54 @@ +import React, { useCallback, useRef, useState } from 'react'; + +import { Button } from '../../buttons/Button'; +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { VStack } from '../../layout'; +import { Text } from '../../typography/Text'; +import type { DrawerRefBaseProps } from '../drawer/Drawer'; +import { Tray } from '../tray/Tray'; + +export const TrayReduceMotionScreen = () => { + return ( + + + + + + ); +}; + +const TrayWithReduceMotion = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + return ( + <> + + {isTrayVisible && ( + + + + Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, + interdum lorem id, viverra. + + + + )} + + ); +}; + +export default TrayReduceMotionScreen; diff --git a/packages/mobile/src/overlays/drawer/Drawer.tsx b/packages/mobile/src/overlays/drawer/Drawer.tsx index 1bb5ba6f6..6dbfba09d 100644 --- a/packages/mobile/src/overlays/drawer/Drawer.tsx +++ b/packages/mobile/src/overlays/drawer/Drawer.tsx @@ -99,6 +99,12 @@ export type DrawerBaseProps = SharedProps & * @deprecated Use TrayStickyFooter as a Tray child instead. */ stickyFooter?: DrawerRenderChildren | React.ReactNode; + /** + * When true, the drawer opens and closes with an opacity fade instead of + * a slide animation. Swipe-to-dismiss gestures remain enabled and use + * the slide transform so the drawer follows the user's finger naturally. + */ + reduceMotion?: boolean; }; export type DrawerProps = DrawerBaseProps & { @@ -140,6 +146,7 @@ export const Drawer = memo( handleBarAccessibilityLabel = 'Dismiss', accessibilityLabel, accessibilityLabelledBy, + reduceMotion, style, styles, accessibilityRole = 'alert', @@ -156,9 +163,10 @@ export const Drawer = memo( drawerAnimation, animateDrawerOut, animateDrawerIn, + animateSnapBack, drawerAnimationStyles, animateSwipeToClose, - } = useDrawerAnimation(pin, verticalDrawerPercentageOfView); + } = useDrawerAnimation(pin, verticalDrawerPercentageOfView, reduceMotion); const [opacityAnimation, animateOverlayIn, animateOverlayOut] = useOverlayAnimation( drawerAnimationDefaultDuration, ); @@ -203,7 +211,7 @@ export const Drawer = memo( const panGestureHandlers = useDrawerPanResponder({ pin, drawerAnimation, - animateDrawerIn, + animateSnapBack, disableCapturePanGestureToDismiss, onBlur, handleSwipeToClose, diff --git a/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx b/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx index 349879927..5fded9f3b 100644 --- a/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx +++ b/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx @@ -42,6 +42,7 @@ const MockDrawer = ({ onCloseComplete, pin = 'bottom', preventDismissGestures, + reduceMotion, }: Partial) => { const [isVisible, setIsVisible] = useState(false); const setIsVisibleOn = useCallback(() => setIsVisible(true), [setIsVisible]); @@ -61,6 +62,7 @@ const MockDrawer = ({ onCloseComplete={handleRequestClose} pin={pin} preventDismissGestures={preventDismissGestures} + reduceMotion={reduceMotion} visible={isVisible} > {({ handleClose }) => ( @@ -151,4 +153,28 @@ describe('Drawer', () => { await delay(DURATION); expect(onCloseComplete).not.toHaveBeenCalled(); }); + + describe('reduceMotion', () => { + it('closes the Drawer when the close button is pressed with reduceMotion enabled', async () => { + const onCloseComplete = jest.fn(); + render(); + + fireEvent.press(screen.getByText('Open Drawer')); + expect(screen.getByText(loremIpsum)).toBeTruthy(); + + fireEvent.press(screen.getByText('Close Drawer')); + await waitFor(() => expect(onCloseComplete).toHaveBeenCalledTimes(1)); + }); + + it('still closes the Drawer via overlay press with reduceMotion enabled', async () => { + const onCloseComplete = jest.fn(); + render(); + + fireEvent.press(screen.getByText('Open Drawer')); + expect(screen.getByText(loremIpsum)).toBeTruthy(); + + fireEvent(screen.getByTestId('drawer-overlay'), 'onTouchStart'); + await waitFor(() => expect(onCloseComplete).toHaveBeenCalledTimes(1)); + }); + }); }); diff --git a/packages/mobile/src/overlays/drawer/useDrawerAnimation.ts b/packages/mobile/src/overlays/drawer/useDrawerAnimation.ts index 240e52820..f1756fe9b 100644 --- a/packages/mobile/src/overlays/drawer/useDrawerAnimation.ts +++ b/packages/mobile/src/overlays/drawer/useDrawerAnimation.ts @@ -24,6 +24,7 @@ const animateDrawer = { export const useDrawerAnimation = ( pin: PinningDirection | undefined = 'bottom', verticalDrawerPercentageOfView: number | undefined = defaultVerticalDrawerPercentageOfView, + reduceMotion?: boolean, ) => { const windowDimensions = useWindowDimensions(); @@ -33,15 +34,40 @@ export const useDrawerAnimation = ( : windowDimensions.width * horizontalDrawerPercentageOfView; const drawerAnimation = useRef(new Animated.Value(0)); + // Separate opacity value used when reduceMotion is true so that + // open/close-button fades are independent of the transform that + // the pan-responder drives during swipe gestures. + const contentOpacity = useRef(new Animated.Value(reduceMotion ? 0 : 1)); - const animateDrawerIn = useMemo( - () => Animated.timing(drawerAnimation.current, animateDrawer.animateIn), - [], - ); - const animateDrawerOut = useMemo( - () => Animated.timing(drawerAnimation.current, animateDrawer.animateOut), - [], - ); + const animateDrawerIn = useMemo(() => { + if (reduceMotion) { + return Animated.parallel([ + Animated.timing(drawerAnimation.current, { + ...animateDrawer.animateIn, + duration: 0, + }), + Animated.timing(contentOpacity.current, animateDrawer.animateIn), + ]); + } + return Animated.timing(drawerAnimation.current, animateDrawer.animateIn); + }, [reduceMotion]); + + const animateDrawerOut = useMemo(() => { + if (reduceMotion) { + return Animated.timing(contentOpacity.current, animateDrawer.animateOut); + } + return Animated.timing(drawerAnimation.current, animateDrawer.animateOut); + }, [reduceMotion]); + + const animateSnapBack = useMemo(() => { + if (reduceMotion) { + return Animated.parallel([ + Animated.timing(drawerAnimation.current, animateDrawer.animateIn), + Animated.timing(contentOpacity.current, animateDrawer.animateIn), + ]); + } + return Animated.timing(drawerAnimation.current, animateDrawer.animateIn); + }, [reduceMotion]); /** custom animation config for swipe and fling to close that has no friction and is faster */ const animateSwipeToClose = useMemo( @@ -89,13 +115,30 @@ export const useDrawerAnimation = ( } }, [pin, drawerDimension]); + const drawerAnimationStyles = useMemo(() => { + if (reduceMotion) { + return { + opacity: contentOpacity.current, + transform: [translation], + }; + } + return { transform: [translation] }; + }, [reduceMotion, translation]); + return useMemo(() => { return { drawerAnimation: drawerAnimation.current, animateDrawerOut, animateDrawerIn, - drawerAnimationStyles: { transform: [translation] }, + animateSnapBack, + drawerAnimationStyles, animateSwipeToClose, }; - }, [animateDrawerOut, animateDrawerIn, translation, animateSwipeToClose]); + }, [ + animateDrawerOut, + animateDrawerIn, + animateSnapBack, + drawerAnimationStyles, + animateSwipeToClose, + ]); }; diff --git a/packages/mobile/src/overlays/drawer/useDrawerPanResponder.ts b/packages/mobile/src/overlays/drawer/useDrawerPanResponder.ts index ea2c65be9..5323da494 100644 --- a/packages/mobile/src/overlays/drawer/useDrawerPanResponder.ts +++ b/packages/mobile/src/overlays/drawer/useDrawerPanResponder.ts @@ -19,7 +19,7 @@ import { modulate } from '@coinbase/cds-common/utils/modulate'; type UseDrawerPanResponderParams = { drawerAnimation: Animated.Value; - animateDrawerIn: Animated.CompositeAnimation; + animateSnapBack: Animated.CompositeAnimation; pin: PinningDirection; disableCapturePanGestureToDismiss: boolean; onBlur?: () => void; @@ -38,7 +38,7 @@ const calculateDragOffset = (x: number) => { export const useDrawerPanResponder = ({ pin, drawerAnimation, - animateDrawerIn, + animateSnapBack, disableCapturePanGestureToDismiss, onBlur, handleSwipeToClose, @@ -233,13 +233,13 @@ export const useDrawerPanResponder = ({ onBlur?.(); handleSwipeToClose(); } else { - animateDrawerIn.start(); + animateSnapBack.start(); } }, }); }, [ drawerAnimation, - animateDrawerIn, + animateSnapBack, parseGestureState, shouldCaptureGestures, shouldDismiss, diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 169a44b40..980fc6b98 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 8.45.0 (2/12/2026 PST) + +#### 🚀 Updates + +- Add reduce motion support for Tray. [[#386](https://github.com/coinbase/cds/pull/386)] + ## 8.44.2 (2/10/2026 PST) #### 🐞 Fixes diff --git a/packages/web/package.json b/packages/web/package.json index 8455410ca..e9d6c629d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.44.2", + "version": "8.45.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/overlays/tray/Tray.tsx b/packages/web/src/overlays/tray/Tray.tsx index 5a087631b..c6941a463 100644 --- a/packages/web/src/overlays/tray/Tray.tsx +++ b/packages/web/src/overlays/tray/Tray.tsx @@ -18,7 +18,14 @@ import { type OverlayContentContextValue, } from '@coinbase/cds-common/overlays/OverlayContentContext'; import { css } from '@linaria/core'; -import { domMax, LazyMotion, m as motion, useAnimate, useDragControls } from 'framer-motion'; +import { + domMax, + LazyMotion, + m as motion, + MotionConfig, + useAnimate, + useDragControls, +} from 'framer-motion'; import { IconButton } from '../../buttons'; import { cx } from '../../cx'; @@ -186,6 +193,11 @@ export type TrayBaseProps = { * @default true */ restoreFocusOnUnmount?: boolean; + /** + * When true, the tray will use opacity animation instead of transform animation. + * This is useful for supporting reduced motion for accessibility. + */ + reduceMotion?: boolean; /** * Sets an accessible label for the close button. * On web, maps to `aria-label` and defines a string value that labels an interactive element. @@ -254,6 +266,7 @@ export const Tray = memo( accessibilityLabelledBy, focusTabIndexElements, restoreFocusOnUnmount = true, + reduceMotion, closeAccessibilityLabel = 'Close', closeAccessibilityHint, styles, @@ -294,18 +307,21 @@ export const Tray = memo( const handleClose = useCallback(() => { if (!scope.current) return; - animate( - scope.current, - isSideTray + + let finalAnimationValue; + if (reduceMotion) { + finalAnimationValue = { opacity: 0 }; + } else { + finalAnimationValue = isSideTray ? { x: pin === 'right' ? '100%' : '-100%' } - : { y: pin === 'bottom' ? '100%' : '-100%' }, - animationConfig.slideOut.transition, - ).then(() => { + : { y: pin === 'bottom' ? '100%' : '-100%' }; + } + animate(scope.current, finalAnimationValue, animationConfig.slideOut.transition).then(() => { setIsOpen(false); onClose?.(); onCloseComplete?.(); }); - }, [animate, scope, isSideTray, pin, onClose, onCloseComplete]); + }, [animate, scope, isSideTray, pin, onClose, onCloseComplete, reduceMotion]); const handleSwipeClose = useCallback(() => { if (!scope.current) return; @@ -349,15 +365,21 @@ export const Tray = memo( [trayHeight, handleSwipeClose, animate, scope], ); - const initialAnimationValue = useMemo( - () => - isSideTray - ? { x: pin === 'right' ? '100%' : '-100%' } - : { y: pin === 'bottom' ? '100%' : '-100%' }, - [isSideTray, pin], - ); - - const animateValue = useMemo(() => (isSideTray ? { x: 0 } : { y: 0 }), [isSideTray]); + const initialAnimationValue = useMemo(() => { + if (reduceMotion) { + return { opacity: 0 }; + } + return isSideTray + ? { x: pin === 'right' ? '100%' : '-100%' } + : { y: pin === 'bottom' ? '100%' : '-100%' }; + }, [isSideTray, pin, reduceMotion]); + + const animateValue = useMemo(() => { + if (reduceMotion) { + return { opacity: 1 }; + } + return isSideTray ? { x: 0 } : { y: 0 }; + }, [isSideTray, reduceMotion]); // Handle bar only shows for bottom-pinned trays (matching mobile behavior) const shouldShowHandleBar = showHandleBar && pin === 'bottom'; @@ -427,160 +449,165 @@ export const Tray = memo( style={styles?.overlay} testID="tray-overlay" /> - - - ) => e.stopPropagation()} - onDragEnd={!preventDismiss ? handleDragEnd : undefined} - pin={pin} - role={role} - style={{ - maxHeight: isSideTray ? undefined : verticalDrawerPercentageOfView, - touchAction: !preventDismiss && pin === 'bottom' ? 'none' : undefined, - ...styles?.container, - }} - tabIndex={0} - transition={animationConfig.slideIn.transition} - width={isSideTray ? 'min(400px, 100vw)' : undefined} + + + - - {(shouldShowTitle || headerContent || shouldShowHandleBar) && ( - - {shouldShowHandleBar && - (preventDismiss ? ( - - ) : ( - ) => { - dragControls.start(e); - }} - styles={{ - root: styles?.handleBar, - handle: { ...styles?.handleBarHandle, touchAction: 'none' }, - }} - /> - ))} - {shouldShowTitle && ( - - {title && - (typeof title === 'string' ? ( - - {title} - - ) : ( - title - ))} - {shouldShowCloseButton && ( - - )} - - )} - {headerContent} - + ) => e.stopPropagation()} + onDragEnd={!preventDismiss ? handleDragEnd : undefined} + pin={pin} + role={role} + style={{ + maxHeight: isSideTray ? undefined : verticalDrawerPercentageOfView, + touchAction: !preventDismiss && pin === 'bottom' ? 'none' : undefined, + ...styles?.container, + }} + tabIndex={0} + transition={animationConfig.slideIn.transition} + width={isSideTray ? 'min(400px, 100vw)' : undefined} + > - {content} + {(shouldShowTitle || headerContent || shouldShowHandleBar) && ( + + {shouldShowHandleBar && + (preventDismiss ? ( + + ) : ( + ) => { + dragControls.start(e); + }} + styles={{ + root: styles?.handleBar, + handle: { ...styles?.handleBarHandle, touchAction: 'none' }, + }} + /> + ))} + {shouldShowTitle && ( + + {title && + (typeof title === 'string' ? ( + + {title} + + ) : ( + title + ))} + {shouldShowCloseButton && ( + + )} + + )} + {headerContent} + + )} + + {content} + + {footerContent} - {footerContent} - - - - + + + + diff --git a/packages/web/src/overlays/tray/__stories__/Tray.stories.tsx b/packages/web/src/overlays/tray/__stories__/Tray.stories.tsx index 5d106cf67..980b4d70f 100644 --- a/packages/web/src/overlays/tray/__stories__/Tray.stories.tsx +++ b/packages/web/src/overlays/tray/__stories__/Tray.stories.tsx @@ -581,3 +581,32 @@ export const ResponsiveFullBleedImageListCells = () => { ); }; + +export const ReduceMotion = () => { + const [showBasicTray, setShowBasicTray] = useState(false); + + return ( + + Basic Tray with String Title + + {showBasicTray && ( + setShowBasicTray(false)} + title="Basic Tray Example" + > + + + This is a basic tray with a simple string title. Clicking outside or pressing ESC will + close it. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod, nisl eget + aliquam ultricies, nunc nisl aliquet nunc, quis aliquam nunc nisl eu nunc. + + + + )} + + ); +}; diff --git a/packages/web/src/overlays/tray/__tests__/Tray.test.tsx b/packages/web/src/overlays/tray/__tests__/Tray.test.tsx index 1b9dc3c27..b41c6f75f 100644 --- a/packages/web/src/overlays/tray/__tests__/Tray.test.tsx +++ b/packages/web/src/overlays/tray/__tests__/Tray.test.tsx @@ -402,6 +402,23 @@ describe('Tray', () => { expect(tray).toHaveAttribute('aria-labelledby', LABELLED_BY); expect(tray).toHaveAttribute('aria-label', LABEL); }); + + it('uses opacity animation when reduceMotion is true', async () => { + const onCloseCompleteSpy = jest.fn(); + render( + + + {loremIpsum} + + , + ); + + // Test "closed" opacity style by asserting the opacity style asynchronously before the animation completes + expect(screen.getByTestId('tray')).toHaveStyle({ opacity: 0 }); + await waitFor(() => { + expect(screen.getByTestId('tray')).toHaveStyle({ opacity: 1 }); + }); + }); }); describe('static classNames', () => {