From 2d087a3fb69b071d3cd86c7fd5f6ce636067e78a Mon Sep 17 00:00:00 2001 From: rohitr-raz Date: Fri, 3 Jul 2026 11:02:26 +0530 Subject: [PATCH 01/11] feat(rn): add React Native support for Drawer Implements .native.tsx files for the Drawer component, replacing the stub implementations with real native rendering using styled-components/native and react-native-reanimated. - Add AnimatedDrawerContainer.native.tsx (reanimated slide-in/overlay) - Implement Drawer.native.tsx via conditional @gorhom/portal mount (mirrors the working Popover/Tooltip native pattern) - Implement DrawerSubcomponents.native.tsx (header/body) - Register a BladeBottomSheetPortal PortalHost in the RN Storybook preview so portal-based components render in Storybook, and add flex:1 sizing - Fix invalid bare string children in Drawer.stories.tsx (native crash) - Add native tests + snapshot Co-Authored-By: Claude Co-authored-by: Cursor --- .changeset/drawer-react-native-support.md | 5 + .../blade/.storybook/react-native/preview.tsx | 17 +- .../BladeProvider/BladeProvider.native.tsx | 5 +- .../Drawer/AnimatedDrawerContainer.native.tsx | 173 ++ .../src/components/Drawer/Drawer.native.tsx | 221 +- .../Drawer/DrawerSubcomponents.native.tsx | 138 +- .../src/components/Drawer/StackProvider.tsx | 2 +- .../Drawer/__tests__/Drawer.native.test.tsx | 181 ++ .../__snapshots__/Drawer.native.test.tsx.snap | 2626 +++++++++++++++++ .../components/Drawer/docs/Drawer.stories.tsx | 4 +- 10 files changed, 3338 insertions(+), 34 deletions(-) create mode 100644 .changeset/drawer-react-native-support.md create mode 100644 packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx create mode 100644 packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx create mode 100644 packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap diff --git a/.changeset/drawer-react-native-support.md b/.changeset/drawer-react-native-support.md new file mode 100644 index 0000000000..d913ffa594 --- /dev/null +++ b/.changeset/drawer-react-native-support.md @@ -0,0 +1,5 @@ +--- +"@razorpay/blade": minor +--- + +feat(rn): add React Native support for Drawer component diff --git a/packages/blade/.storybook/react-native/preview.tsx b/packages/blade/.storybook/react-native/preview.tsx index 228c5460d5..e54f939c5c 100644 --- a/packages/blade/.storybook/react-native/preview.tsx +++ b/packages/blade/.storybook/react-native/preview.tsx @@ -2,11 +2,23 @@ import 'react-native-gesture-handler'; import { withBackgrounds } from '@storybook/addon-ondevice-backgrounds'; import { StyleSheet } from 'react-native'; import { View } from 'react-native'; +import { PortalHost } from '@gorhom/portal'; +// `@storybook/react-native-ui` wraps every story in `@gorhom/bottom-sheet`'s +// `BottomSheetModalProvider`, which nests its OWN `PortalProvider` between the story +// and the app-level `BladeBottomSheetPortal` host declared in `BladeProvider.native`. +// `usePortal()` resolves to that nearest (Storybook) provider, so portal components +// (Drawer, BottomSheet, Popover, Tooltip, Modal, …) target a provider that has no +// `BladeBottomSheetPortal` host and their teleported content is silently dropped — +// making them impossible to verify on-device. Registering the same-named host inside +// the story decorator makes it resolvable on the nearest provider so portal content +// renders within the story canvas. Production is unaffected (a real app has only the +// single BladeProvider host). export const decorators = [ (StoryFn) => ( + ), withBackgrounds, @@ -21,5 +33,8 @@ export const parameters = { }; const styles = StyleSheet.create({ - container: { padding: 16 }, + // `flex: 1` lets the container fill the story canvas so absolutely-positioned + // portal content (e.g. the full-height Drawer) can size against a real viewport + // instead of collapsing to the intrinsic height of the story's inline content. + container: { flex: 1, padding: 16 }, }); diff --git a/packages/blade/src/components/BladeProvider/BladeProvider.native.tsx b/packages/blade/src/components/BladeProvider/BladeProvider.native.tsx index e814347716..28eadb2e8d 100644 --- a/packages/blade/src/components/BladeProvider/BladeProvider.native.tsx +++ b/packages/blade/src/components/BladeProvider/BladeProvider.native.tsx @@ -6,6 +6,7 @@ import { ThemeContext } from './useTheme'; import { useBladeProvider } from './useBladeProvider'; import type { BladeProviderProps } from './types'; import { BottomSheetStackProvider } from '~components/BottomSheet/BottomSheetStack'; +import { DrawerStackProvider } from '~components/Drawer/StackProvider'; const gestureHandlerStyle = { flex: 1, @@ -23,7 +24,9 @@ const BladeProvider = ({ - {children} + + {children} + diff --git a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx new file mode 100644 index 0000000000..445a67c7dd --- /dev/null +++ b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import styled from 'styled-components/native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, + runOnJS, +} from 'react-native-reanimated'; +import { Dimensions, Pressable } from 'react-native'; +import type { ElevationStyles } from '~tokens/global/elevation'; +import BaseBox from '~components/Box/BaseBox'; +import { getElevationValue } from '~components/Box/BaseBox/baseBoxStyles'; +import { useTheme } from '~components/BladeProvider'; +import { makeAccessible } from '~utils/makeAccessible'; + +const noop = (): void => {}; + +const fillStyle = { + position: 'absolute' as const, + top: 0, + left: 0, + right: 0, + bottom: 0, +}; + +// Outer animated wrapper carries the slide/opacity animation + elevation shadow. +// Shadow lives here (NOT on the inner surface) because the inner surface uses +// `overflow: 'hidden'` for the Android border-radius clip fix, which would clip the shadow. +const StyledDrawerWrapper = styled(Animated.View)(() => { + return { + position: 'absolute' as const, + top: 0, + bottom: 0, + right: 0, + width: '90%', + flexDirection: 'column' as const, + }; +}); + +const StyledDrawerSurface = styled(BaseBox)(({ theme }) => { + return { + flex: 1, + backgroundColor: theme.colors.popup.background.gray.subtle, + // base breakpoint renders the drawer edge-to-edge with no radius + borderRadius: 0, + overflow: 'hidden' as const, + flexDirection: 'column' as const, + }; +}); + +const StyledOverlay = styled(Animated.View)(({ theme }) => { + return { + ...fillStyle, + backgroundColor: theme.colors.overlay.background.subtle, + }; +}); + +type AnimatedDrawerContainerProps = { + /** + * Drives the enter/exit animation. When `true` the drawer slides in and the + * overlay fades in; when `false` it slides out and fades away. + */ + isVisible: boolean; + /** + * Whether to render the dismissible overlay behind the drawer surface. + */ + showOverlay: boolean; + /** + * Called when the user presses the overlay to dismiss the drawer. + */ + onOverlayPress: () => void; + /** + * Called after the exit animation completes (parity with web `onUnmount`). + */ + onExitComplete?: () => void; + /** + * Accessibility label announced for the drawer dialog. + */ + accessibilityLabel?: string; + /** + * Drawer content (DrawerHeader / DrawerBody / DrawerFooter). + */ + children: React.ReactNode; +}; + +/** + * Encapsulates the reanimated slide-in surface + fading overlay for the native + * Drawer. Mirrors the web `AnimatedDrawerContainer` styled component but drives + * `translateX`/`opacity` via reanimated shared values instead of CSS transitions. + */ +const AnimatedDrawerContainer = ({ + isVisible, + showOverlay, + onOverlayPress, + onExitComplete, + accessibilityLabel, + children, +}: AnimatedDrawerContainerProps): React.ReactElement => { + const { theme } = useTheme(); + const screenWidth = Dimensions.get('window').width; + // Initialize the shared values from the CURRENT visibility so an open drawer is + // already at its resting OPEN state (translateX = 0, opacity = 1) on the very first + // committed frame — even before/without the `withTiming` callback firing. Starting + // them unconditionally at the closed values meant that if the enter animation did not + // run (e.g. it was scheduled after the commit, or the shared values were reset), the + // surface stayed off-screen-right at opacity 0 and never became visible. + const translateX = useSharedValue(isVisible ? 0 : screenWidth); + const surfaceOpacity = useSharedValue(isVisible ? 1 : 0); + const overlayOpacity = useSharedValue(isVisible ? 1 : 0); + + const shadow = (getElevationValue('highRaised', theme) as unknown) as ElevationStyles; + + React.useEffect(() => { + const enterConfig = { + duration: theme.motion.duration.gentle, + easing: theme.motion.easing.entrance, + }; + const exitConfig = { + duration: theme.motion.duration.xmoderate, + easing: theme.motion.easing.exit, + }; + const config = isVisible ? enterConfig : exitConfig; + + translateX.value = withTiming(isVisible ? 0 : screenWidth, config); + surfaceOpacity.value = withTiming(isVisible ? 1 : 0, config); + overlayOpacity.value = withTiming(isVisible ? 1 : 0, config, (finished) => { + if (finished && !isVisible) { + runOnJS(onExitComplete ?? noop)(); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisible, screenWidth]); + + const surfaceAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: surfaceOpacity.value, + transform: [{ translateX: translateX.value }], + }; + }); + + const overlayAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: overlayOpacity.value, + }; + }); + + return ( + <> + {showOverlay ? ( + + + + ) : null} + + + {children} + + + + ); +}; + +export { AnimatedDrawerContainer }; diff --git a/packages/blade/src/components/Drawer/Drawer.native.tsx b/packages/blade/src/components/Drawer/Drawer.native.tsx index c7b128bd74..a7143d9989 100644 --- a/packages/blade/src/components/Drawer/Drawer.native.tsx +++ b/packages/blade/src/components/Drawer/Drawer.native.tsx @@ -1,14 +1,221 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { Portal } from '@gorhom/portal'; +import { AccessibilityInfo, findNodeHandle } from 'react-native'; +import { AnimatedDrawerContainer } from './AnimatedDrawerContainer.native'; +import { drawerComponentIds } from './drawerComponentIds'; +import { DrawerContext } from './DrawerContext'; import type { DrawerProps } from './types'; -import { Text } from '~components/Typography'; -import { throwBladeError } from '~utils/logger'; +import BaseBox from '~components/Box/BaseBox'; +import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; +import { componentZIndices } from '~utils/componentZIndices'; +import { useDrawerStack, StackingContext } from '~components/Drawer/StackProvider'; +import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; +import { useId } from '~utils/useId'; +import { useVerifyAllowedChildren } from '~utils/useVerifyAllowedChildren'; +import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute'; -const Drawer = (_props: DrawerProps): React.ReactElement => { - throwBladeError({ - message: 'Drawer is not yet implemented for native', - moduleName: 'Drawer', +const focusOnElement = (element: React.Component | null): void => { + if (!element) return; + const reactTag = findNodeHandle(element); + if (reactTag) { + AccessibilityInfo.setAccessibilityFocus(reactTag); + } +}; + +const _Drawer = ({ + isOpen, + onDismiss, + onUnmount, + zIndex = componentZIndices.drawer, + children, + accessibilityLabel, + showOverlay = true, + initialFocusRef, + isLazy = true, + testID, + ...rest +}: DrawerProps): React.ReactElement | null => { + const [zIndexState, setZIndexState] = React.useState(zIndex); + + useVerifyAllowedChildren({ + children, + componentName: 'Drawer', + allowedComponents: [ + drawerComponentIds.DrawerHeader, + drawerComponentIds.DrawerBody, + drawerComponentIds.DrawerFooter, + ], }); - return Drawer Component is not available for Native mobile apps.; + const drawerId = useId('drawer'); + const { drawerStack, addToDrawerStack, removeFromDrawerStack } = useDrawerStack(); + + // Native presence handling: `use-presence` (web) relies on `document`, so we + // gate mount/unmount locally instead. + // - isMounted stays true through the exit animation, then flips false via onExitComplete. + // - isVisible mirrors isOpen and drives the reanimated slide/opacity. + // - isExiting is true while the drawer is animating out (mounted but not open). + const [isMounted, setIsMounted] = React.useState(isOpen); + const isVisible = isOpen; + const isExiting = isMounted && !isOpen; + + React.useEffect(() => { + if (isOpen) { + setIsMounted(true); + } + }, [isOpen]); + + const handleExitComplete = React.useCallback(() => { + // Flip `isMounted` false so the Portal unmounts after the exit animation. Guard + // the `onUnmount` side-effect behind the previous-mounted flag so it only fires + // when the drawer had actually been opened and is now finishing its exit — never + // on the initial closed render. + setIsMounted((prevIsMounted) => { + if (prevIsMounted) { + onUnmount?.(); + } + return false; + }); + }, [onUnmount]); + + const { stackingLevel } = React.useMemo(() => { + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + const level = Object.keys(drawerStack).indexOf(drawerId) + 1; + return { + stackingLevel: level, + }; + }, [drawerId, drawerStack]); + + React.useEffect(() => { + if (isOpen) { + addToDrawerStack({ elementId: drawerId, onDismiss }); + // Move accessibility focus to the requested element (parity with web initialFocus) + focusOnElement(initialFocusRef?.current ?? null); + } else { + removeFromDrawerStack({ elementId: drawerId }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + // When z-index is not defined by user, we use default drawer z index and add stackingLevel to ensure + // new drawer that opens, always opens on top of previous one. + React.useEffect(() => { + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + setZIndexState(zIndex + stackingLevel); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMounted]); + + const contextValue = React.useMemo( + () => ({ + close: onDismiss, + stackingLevel, + isExiting, + }), + [isExiting, onDismiss, stackingLevel], + ); + + // `@gorhom/portal` teleports children into `PortalHost`, which lives OUTSIDE this + // component's React subtree. Teleported children therefore lose access to the + // contexts provided here. We re-provide both the DrawerStack context and the + // Drawer context INSIDE the Portal so DrawerHeader (rendered via the portal) can + // read the up-to-date `drawerStack`, `close`, and `stackingLevel`. + // (BottomSheet.native uses the same re-provide-inside-Portal workaround.) + const stackContextValue = React.useMemo( + () => ({ + drawerStack, + addToDrawerStack, + removeFromDrawerStack, + }), + [drawerStack, addToDrawerStack, removeFromDrawerStack], + ); + + // `@gorhom/portal` teleports children into the host via an effect that runs when the + // `Portal` element itself mounts. The reliable, proven pattern (used by the working + // `Popover.native` / `Tooltip.native` against this same `BladeBottomSheetPortal` host) + // is to conditionally MOUNT the `Portal` while the drawer is present rather than keep + // it always mounted and flip a visibility prop. Mounting fresh on open guarantees: + // - the add-portal effect fires so the subtree actually teleports and renders, and + // - `AnimatedDrawerContainer` re-mounts with `isVisible = true`, so its shared values + // initialize at the OPEN resting state (translateX 0, opacity 1) and it is visible + // on the very first committed frame. + // Presence is driven by `isMounted`: set true on open, flipped false by + // `handleExitComplete` after the exit animation so the slide-out still plays before + // the Portal unmounts. `children` render whenever the drawer is mounted. + return isMounted ? ( + + + + + + {children} + + + + + + ) : null; }; +/** + * ### Drawer Component + * + * A drawer is a panel that slides in mostly from right side of the screen over the existing content in the viewport. + * It helps in providing additional details or context and can also be used to promote product features or new products. + * + * --- + * + * #### Usage + * + * ```jsx + const MyDrawer = () => { + const [showDrawer, setShowDrawer] = React.useState(false); + return ( + + + setShowDrawer(false)} + > + + + + + + + + ) + } + * ``` + * + * --- + * + * Checkout {@link https://blade.razorpay.com/?path=/docs/components-drawer Drawer Documentation} + * + * + */ +const Drawer = assignWithoutSideEffects(_Drawer, { + displayName: 'Drawer', + componentId: drawerComponentIds.Drawer, +}); + export { Drawer }; diff --git a/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx b/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx index e57ee02e9f..5bd5131dbc 100644 --- a/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx +++ b/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx @@ -1,33 +1,127 @@ import React from 'react'; +import { ScrollView } from 'react-native'; +import { drawerComponentIds } from './drawerComponentIds'; +import { DrawerContext } from './DrawerContext'; import type { DrawerHeaderProps, DrawerFooterProps } from './types'; -import { Text } from '~components/Typography'; -import { throwBladeError } from '~utils/logger'; +import { useDrawerStack } from './StackProvider'; +import { BaseHeader } from '~components/BaseHeaderFooter/BaseHeader'; +import { BaseFooter } from '~components/BaseHeaderFooter/BaseFooter'; +import { Box } from '~components/Box'; +import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; +import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute'; -const DrawerHeader = (_props: DrawerHeaderProps): React.ReactElement => { - throwBladeError({ - message: 'DrawerHeader is not yet implemented for native', - moduleName: 'DrawerHeader', - }); +const _DrawerHeader = ({ + title, + subtitle, + leading, + trailing, + titleSuffix, + children, + // `color` drives the radial-gradient backgroundImage on web. Native has no CSS + // radial-gradient, so we intentionally omit the gradient and keep the surface solid. + color: _color = 'information', + showDivider = true, + ...rest +}: DrawerHeaderProps): React.ReactElement => { + const { close, closeButtonRef, stackingLevel, isExiting } = React.useContext(DrawerContext); + const { drawerStack } = useDrawerStack(); - return Drawer Component is not available for Native mobile apps.; -}; + const closeAllDrawers = (): void => { + for (const onDismiss of Object.values(drawerStack)) { + onDismiss(); + } + }; + + const isStackedDrawer = stackingLevel && stackingLevel > 1; -const DrawerBody = (_props: { children: React.ReactNode }): React.ReactElement => { - throwBladeError({ - message: 'DrawerBody is not yet implemented for native', - moduleName: 'DrawerBody', - }); + const isAtleastOneDrawerOpen = Object.keys(drawerStack).length > 0; - return Drawer Component is not available for Native mobile apps.; + // This condition is to avoid back button disappear while stacked drawer is in the exiting transition + const isDrawerExiting = isAtleastOneDrawerOpen && isExiting && stackingLevel !== 1; + + return ( + closeAllDrawers()} + onBackButtonClick={() => close()} + title={title} + size="xlarge" + titleSuffix={titleSuffix} + subtitle={subtitle} + leading={leading} + trailing={trailing} + showDivider={showDivider} + {...makeAnalyticsAttribute(rest)} + > + {children} + + ); }; -const DrawerFooter = (_props: DrawerFooterProps): React.ReactElement => { - throwBladeError({ - message: 'DrawerFooter is not yet implemented for native', - moduleName: 'DrawerFooter', - }); +/** + * #### Usage + * + * ```jsx + * New} + * leading={} + * trailing={ + * + * ``` + * + */ +const DrawerFooter = assignWithoutSideEffects(_DrawerFooter, { + componentId: drawerComponentIds.DrawerFooter, +}); -export { DrawerHeader, DrawerBody, DrawerFooter }; +export { DrawerHeader, DrawerBody, DrawerFooter, drawerPadding }; diff --git a/packages/blade/src/components/Drawer/StackProvider.tsx b/packages/blade/src/components/Drawer/StackProvider.tsx index 0270dccdb9..5a75284a8e 100644 --- a/packages/blade/src/components/Drawer/StackProvider.tsx +++ b/packages/blade/src/components/Drawer/StackProvider.tsx @@ -70,4 +70,4 @@ const useDrawerStack = (): GlobalStackStateType => { return React.useContext(StackingContext); }; -export { DrawerStackProvider, useDrawerStack }; +export { DrawerStackProvider, useDrawerStack, StackingContext }; diff --git a/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx b/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx new file mode 100644 index 0000000000..beb38ba2a9 --- /dev/null +++ b/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx @@ -0,0 +1,181 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import type { DrawerProps } from '../'; +import { Drawer, DrawerBody, DrawerHeader, DrawerFooter } from '../'; +import renderWithTheme from '~utils/testing/renderWithTheme.native'; +import { Badge } from '~components/Badge'; +import { Button } from '~components/Button'; +import { Text } from '~components/Typography'; +import { AnnouncementIcon, DownloadIcon } from '~components/Icons'; + +jest.useFakeTimers(); + +beforeAll(() => jest.spyOn(console, 'error').mockImplementation()); +afterAll(() => jest.restoreAllMocks()); + +const BasicDrawer = (props: Partial): React.ReactElement => { + const [isOpen, setIsOpen] = React.useState(false); + return ( + <> + { + setIsOpen(false); + props.onDismiss?.(); + }} + accessibilityLabel="Test Drawer" + > + + + Test Content + + + + + ); +}; + +describe(' (native)', () => { + it('renders a Drawer', () => { + const { toJSON } = renderWithTheme( + {}} accessibilityLabel="Test Drawer"> + } + title="Address Details" + subtitle="Saving addresses will improve your checkout experience" + trailing={ + + , + ); + expect(getByText('Footer Button')).toBeTruthy(); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap b/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap new file mode 100644 index 0000000000..259dacf8da --- /dev/null +++ b/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap @@ -0,0 +1,2626 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` (native) renders a Drawer 1`] = ` + + + + + + + + + + + + + + + + + + + + + + Address Details + + + + + + + + NEW + + + + + + + + + Saving addresses will improve your checkout experience + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Test Content + + + + + + + + + +`; + +exports[` (native) renders a Drawer with footer 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + Custom Header + + + + + + + + + + + + Custom Content + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Footer Button + + + + + + + + + + +`; + +exports[` (native) should render a Drawer with a custom header 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + Custom Header + + + + + + + + + + + + Custom Content + + + + + + + + + +`; diff --git a/packages/blade/src/components/Drawer/docs/Drawer.stories.tsx b/packages/blade/src/components/Drawer/docs/Drawer.stories.tsx index b3695ca30b..aaddcb359d 100644 --- a/packages/blade/src/components/Drawer/docs/Drawer.stories.tsx +++ b/packages/blade/src/components/Drawer/docs/Drawer.stories.tsx @@ -87,7 +87,7 @@ const DrawerTemplate: StoryFn = (args) => { /> - {' '} + @@ -216,7 +216,7 @@ export const InitialFocus = (args: DrawerProps): React.ReactElement => { /> - {' '} + From d8b4901424b438e8eddcbda6b221aee87fd8ec16 Mon Sep 17 00:00:00 2001 From: "rzp-slash[bot]" Date: Fri, 3 Jul 2026 11:20:43 +0530 Subject: [PATCH 02/11] fix: resolve lint errors in native Drawer components - Remove empty noop arrow function, guard onExitComplete call directly - Rename unused isLazy to ignoredIsLazy to satisfy no-unused-vars rule Co-authored-by: admin --- .../components/Drawer/AnimatedDrawerContainer.native.tsx | 6 ++---- packages/blade/src/components/Drawer/Drawer.native.tsx | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx index 445a67c7dd..6182183958 100644 --- a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx +++ b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx @@ -13,8 +13,6 @@ import { getElevationValue } from '~components/Box/BaseBox/baseBoxStyles'; import { useTheme } from '~components/BladeProvider'; import { makeAccessible } from '~utils/makeAccessible'; -const noop = (): void => {}; - const fillStyle = { position: 'absolute' as const, top: 0, @@ -124,8 +122,8 @@ const AnimatedDrawerContainer = ({ translateX.value = withTiming(isVisible ? 0 : screenWidth, config); surfaceOpacity.value = withTiming(isVisible ? 1 : 0, config); overlayOpacity.value = withTiming(isVisible ? 1 : 0, config, (finished) => { - if (finished && !isVisible) { - runOnJS(onExitComplete ?? noop)(); + if (finished && !isVisible && onExitComplete) { + runOnJS(onExitComplete)(); } }); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/blade/src/components/Drawer/Drawer.native.tsx b/packages/blade/src/components/Drawer/Drawer.native.tsx index a7143d9989..1c5028cfff 100644 --- a/packages/blade/src/components/Drawer/Drawer.native.tsx +++ b/packages/blade/src/components/Drawer/Drawer.native.tsx @@ -32,7 +32,7 @@ const _Drawer = ({ accessibilityLabel, showOverlay = true, initialFocusRef, - isLazy = true, + isLazy: ignoredIsLazy = true, testID, ...rest }: DrawerProps): React.ReactElement | null => { From fa1e8e22616dee2ae90b0ace82b4e300b3f67a44 Mon Sep 17 00:00:00 2001 From: "rzp-slash[bot]" Date: Fri, 3 Jul 2026 11:54:46 +0530 Subject: [PATCH 03/11] fix: resolve lint errors in native Drawer components Co-authored-by: admin --- packages/blade/src/components/Drawer/Drawer.native.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blade/src/components/Drawer/Drawer.native.tsx b/packages/blade/src/components/Drawer/Drawer.native.tsx index 1c5028cfff..00aa0c16d4 100644 --- a/packages/blade/src/components/Drawer/Drawer.native.tsx +++ b/packages/blade/src/components/Drawer/Drawer.native.tsx @@ -32,7 +32,7 @@ const _Drawer = ({ accessibilityLabel, showOverlay = true, initialFocusRef, - isLazy: ignoredIsLazy = true, + isLazy: _isLazy = true, testID, ...rest }: DrawerProps): React.ReactElement | null => { From a67abf9d54d4897fb15ed172688691ea886eabdb Mon Sep 17 00:00:00 2001 From: "rzp-slash[bot]" Date: Fri, 3 Jul 2026 12:18:04 +0530 Subject: [PATCH 04/11] fix: replace Dimensions.get with useWindowDimensions for reactive screen width [resolved by agent] Co-authored-by: admin --- .../src/components/Drawer/AnimatedDrawerContainer.native.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx index 6182183958..354d7f499f 100644 --- a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx +++ b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx @@ -6,7 +6,7 @@ import Animated, { withTiming, runOnJS, } from 'react-native-reanimated'; -import { Dimensions, Pressable } from 'react-native'; +import { Pressable, useWindowDimensions } from 'react-native'; import type { ElevationStyles } from '~tokens/global/elevation'; import BaseBox from '~components/Box/BaseBox'; import { getElevationValue } from '~components/Box/BaseBox/baseBoxStyles'; @@ -95,7 +95,7 @@ const AnimatedDrawerContainer = ({ children, }: AnimatedDrawerContainerProps): React.ReactElement => { const { theme } = useTheme(); - const screenWidth = Dimensions.get('window').width; + const { width: screenWidth } = useWindowDimensions(); // Initialize the shared values from the CURRENT visibility so an open drawer is // already at its resting OPEN state (translateX = 0, opacity = 1) on the very first // committed frame — even before/without the `withTiming` callback firing. Starting From 2b72c0921d8b91b2634ab897e5c374ed61fe2b09 Mon Sep 17 00:00:00 2001 From: rohitr-raz Date: Wed, 8 Jul 2026 20:15:55 +0530 Subject: [PATCH 05/11] fix(rn): fix Drawer enter animation and align motion with web - Initialize the surface shared values at the closed/off-screen state so the slide-in has a frame to animate from (enter was snapping into place) - Mirror web's Drawer transition timings (xmoderate/entrance, moderate/exit) - Use Dimensions.get for screen width and a noop exit callback fallback - Refine DrawerSubcomponents native layout and update the Drawer stories - Regenerate Drawer native snapshots Co-authored-by: Cursor --- .../Drawer/AnimatedDrawerContainer.native.tsx | 42 +- .../src/components/Drawer/Drawer.native.tsx | 4 +- .../Drawer/DrawerSubcomponents.native.tsx | 89 +- .../__snapshots__/Drawer.native.test.tsx.snap | 2449 +++++++++-------- .../components/Drawer/docs/Drawer.stories.tsx | 94 +- 5 files changed, 1521 insertions(+), 1157 deletions(-) diff --git a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx index 354d7f499f..09c1607565 100644 --- a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx +++ b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx @@ -6,13 +6,15 @@ import Animated, { withTiming, runOnJS, } from 'react-native-reanimated'; -import { Pressable, useWindowDimensions } from 'react-native'; +import { Dimensions, Pressable } from 'react-native'; import type { ElevationStyles } from '~tokens/global/elevation'; import BaseBox from '~components/Box/BaseBox'; import { getElevationValue } from '~components/Box/BaseBox/baseBoxStyles'; import { useTheme } from '~components/BladeProvider'; import { makeAccessible } from '~utils/makeAccessible'; +const noop = (): void => {}; + const fillStyle = { position: 'absolute' as const, top: 0, @@ -39,7 +41,7 @@ const StyledDrawerSurface = styled(BaseBox)(({ theme }) => { return { flex: 1, backgroundColor: theme.colors.popup.background.gray.subtle, - // base breakpoint renders the drawer edge-to-edge with no radius + // base breakpoint renders the drawer edge-to-edge with no radius (matches web's phone view) borderRadius: 0, overflow: 'hidden' as const, flexDirection: 'column' as const, @@ -95,26 +97,34 @@ const AnimatedDrawerContainer = ({ children, }: AnimatedDrawerContainerProps): React.ReactElement => { const { theme } = useTheme(); - const { width: screenWidth } = useWindowDimensions(); - // Initialize the shared values from the CURRENT visibility so an open drawer is - // already at its resting OPEN state (translateX = 0, opacity = 1) on the very first - // committed frame — even before/without the `withTiming` callback firing. Starting - // them unconditionally at the closed values meant that if the enter animation did not - // run (e.g. it was scheduled after the commit, or the shared values were reset), the - // surface stayed off-screen-right at opacity 0 and never became visible. - const translateX = useSharedValue(isVisible ? 0 : screenWidth); - const surfaceOpacity = useSharedValue(isVisible ? 1 : 0); - const overlayOpacity = useSharedValue(isVisible ? 1 : 0); + const screenWidth = Dimensions.get('window').width; + // Always initialize the shared values at the CLOSED / off-screen state + // (translateX = screenWidth, opacity = 0) so there is a "from" frame to animate FROM + // when the drawer mounts open. The Portal mounts fresh on open, so this container + // mounts with `isVisible = true`; the mount effect below then drives `withTiming` to + // the open state, producing the slide-in. + // + // Initializing from the current visibility instead (open → translateX 0) meant that on + // open the surface was already at its resting position, so the enter `withTiming(0)` + // had nothing to animate and the panel snapped into place instantly. The exit still + // animated (0 → screenWidth) which is why only the enter transition looked broken. + const translateX = useSharedValue(screenWidth); + const surfaceOpacity = useSharedValue(0); + const overlayOpacity = useSharedValue(0); const shadow = (getElevationValue('highRaised', theme) as unknown) as ElevationStyles; React.useEffect(() => { + // Mirror web's Drawer surface transition (Drawer.web.tsx): enter uses + // `duration.xmoderate` + `easing.entrance`, exit uses `duration.moderate` + + // `easing.exit`. Previously enter used the slower `gentle` (480ms), which made the + // native open feel sluggish compared to web's 360ms slide-in. const enterConfig = { - duration: theme.motion.duration.gentle, + duration: theme.motion.duration.xmoderate, easing: theme.motion.easing.entrance, }; const exitConfig = { - duration: theme.motion.duration.xmoderate, + duration: theme.motion.duration.moderate, easing: theme.motion.easing.exit, }; const config = isVisible ? enterConfig : exitConfig; @@ -122,8 +132,8 @@ const AnimatedDrawerContainer = ({ translateX.value = withTiming(isVisible ? 0 : screenWidth, config); surfaceOpacity.value = withTiming(isVisible ? 1 : 0, config); overlayOpacity.value = withTiming(isVisible ? 1 : 0, config, (finished) => { - if (finished && !isVisible && onExitComplete) { - runOnJS(onExitComplete)(); + if (finished && !isVisible) { + runOnJS(onExitComplete ?? noop)(); } }); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/blade/src/components/Drawer/Drawer.native.tsx b/packages/blade/src/components/Drawer/Drawer.native.tsx index 00aa0c16d4..6f0edfb26c 100644 --- a/packages/blade/src/components/Drawer/Drawer.native.tsx +++ b/packages/blade/src/components/Drawer/Drawer.native.tsx @@ -137,8 +137,8 @@ const _Drawer = ({ // it always mounted and flip a visibility prop. Mounting fresh on open guarantees: // - the add-portal effect fires so the subtree actually teleports and renders, and // - `AnimatedDrawerContainer` re-mounts with `isVisible = true`, so its shared values - // initialize at the OPEN resting state (translateX 0, opacity 1) and it is visible - // on the very first committed frame. + // initialize at the CLOSED / off-screen state (translateX = screenWidth, opacity 0) + // and its mount effect animates them to the open state — producing the slide-in. // Presence is driven by `isMounted`: set true on open, flipped false by // `handleExitComplete` after the exit animation so the slide-out still plays before // the Portal unmounts. `children` render whenever the drawer is mounted. diff --git a/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx b/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx index 5bd5131dbc..8eb79c9743 100644 --- a/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx +++ b/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { ScrollView } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import Svg, { Defs, RadialGradient, Stop, Rect } from 'react-native-svg'; import { drawerComponentIds } from './drawerComponentIds'; import { DrawerContext } from './DrawerContext'; import type { DrawerHeaderProps, DrawerFooterProps } from './types'; @@ -9,6 +10,46 @@ import { BaseFooter } from '~components/BaseHeaderFooter/BaseFooter'; import { Box } from '~components/Box'; import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute'; +import { useTheme } from '~utils'; + +/** + * Replicates the web DrawerHeader's radial-gradient background on native. + * + * Web uses a CSS `radial-gradient(150% 100% at 50% 100%, transparent 0%, subtle 100%)` + * driven by the `color` prop. React Native has no CSS radial-gradient, so we draw an + * equivalent gradient with react-native-svg using the same status-driven feedback token. + */ +const DrawerHeaderGradient = ({ + color, +}: { + color: NonNullable; +}): React.ReactElement => { + const { theme } = useTheme(); + // Web's `feedback.background[color].subtle` token is a low-opacity hsla() (e.g. 0.18 alpha). + // Web applies it as the far stop of a radial-gradient that fades from `transparent`, so the + // visible tint never exceeds that alpha (hence it looks very light). react-native-svg's + // does not honor the alpha channel embedded in a color string, so we split the token into its + // opaque hue + explicit `stopOpacity` to reproduce web's light tint exactly (no magic values). + const subtleColor = theme.colors.feedback.background[color].subtle; + const alphaMatch = subtleColor.match(/hsla?\([^)]*,\s*([\d.]+)\s*\)$/); + const subtleAlpha = alphaMatch ? Number(alphaMatch[1]) : 1; + const opaqueColor = subtleColor.replace(/^hsla/, 'hsl').replace(/,\s*[\d.]+\s*\)$/, ')'); + const gradientId = `drawer-header-gradient-${color}`; + + return ( + + + + + + + + + + + + ); +}; const _DrawerHeader = ({ title, @@ -17,9 +58,10 @@ const _DrawerHeader = ({ trailing, titleSuffix, children, - // `color` drives the radial-gradient backgroundImage on web. Native has no CSS - // radial-gradient, so we intentionally omit the gradient and keep the surface solid. - color: _color = 'information', + // `color` drives the radial-gradient background on web. Native has no CSS + // radial-gradient, so we replicate it with an equivalent react-native-svg gradient + // (see DrawerHeaderGradient) using the same status-driven feedback token. + color = 'information', showDivider = true, ...rest }: DrawerHeaderProps): React.ReactElement => { @@ -40,24 +82,27 @@ const _DrawerHeader = ({ const isDrawerExiting = isAtleastOneDrawerOpen && isExiting && stackingLevel !== 1; return ( - closeAllDrawers()} - onBackButtonClick={() => close()} - title={title} - size="xlarge" - titleSuffix={titleSuffix} - subtitle={subtitle} - leading={leading} - trailing={trailing} - showDivider={showDivider} - {...makeAnalyticsAttribute(rest)} - > - {children} - + + + closeAllDrawers()} + onBackButtonClick={() => close()} + title={title} + size="xlarge" + titleSuffix={titleSuffix} + subtitle={subtitle} + leading={leading} + trailing={trailing} + showDivider={showDivider} + {...makeAnalyticsAttribute(rest)} + > + {children} + + ); }; diff --git a/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap b/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap index 259dacf8da..08e0f6bc9c 100644 --- a/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap +++ b/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap @@ -85,7 +85,7 @@ exports[` (native) renders a Drawer 1`] = ` "top": 0, }, { - "opacity": 1, + "opacity": 0, }, ] } @@ -113,10 +113,10 @@ exports[` (native) renders a Drawer 1`] = ` "shadowRadius": 12, }, { - "opacity": 1, + "opacity": 0, "transform": [ { - "translateX": 0, + "translateX": 750, }, ], }, @@ -144,992 +144,1085 @@ exports[` (native) renders a Drawer 1`] = ` } > + + + + + + + + + + - - - - - - - + > + + + + - - Address Details - + + Address Details + - - NEW - + + NEW + + + + Saving addresses will improve your checkout experience + - - Saving addresses will improve your checkout experience - - - - + - - - - - + + - + + + + + - - + + + + - - - - + - - - - - + strokeWidth={4} + width="100%" + x="0" + y="0" + /> + + + - - - - + - - + propList={ + [ + "fill", + ] + } + /> + + + + - - - - - - - + > + + + + - - + > + + + - (native) renders a Drawer with footer 1`] = ` "top": 0, }, { - "opacity": 1, + "opacity": 0, }, ] } @@ -1316,10 +1409,10 @@ exports[` (native) renders a Drawer with footer 1`] = ` "shadowRadius": 12, }, { - "opacity": 1, + "opacity": 0, "transform": [ { - "translateX": 0, + "translateX": 750, }, ], }, @@ -1347,251 +1440,344 @@ exports[` (native) renders a Drawer with footer 1`] = ` } > + + + + + + + + + + - - - - - + > + + + + - - - - Custom Header - - - - - + + Custom Header + + + + + + (native) should render a Drawer with a custom header 1`] = ` "top": 0, }, { - "opacity": 1, + "opacity": 0, }, ] } @@ -2276,10 +2462,10 @@ exports[` (native) should render a Drawer with a custom header 1`] = ` "shadowRadius": 12, }, { - "opacity": 1, + "opacity": 0, "transform": [ { - "translateX": 0, + "translateX": 750, }, ], }, @@ -2307,251 +2493,344 @@ exports[` (native) should render a Drawer with a custom header 1`] = ` } > + + + + + + + + + + - - - - - + > + + + + - - - - Custom Header - + + Custom Header + + + - = (args) => { trailing={ - + + + + + + + @@ -119,7 +123,7 @@ export const DrawerStacking = (args: DrawerProps): React.ReactElement => { trailing={ - + + + + + + + @@ -260,7 +270,13 @@ export const WithCustomHeader = (args: DrawerProps): React.ReactElement => { suffix="decimals" /> - + Captured @@ -280,14 +296,20 @@ export const WithCustomHeader = (args: DrawerProps): React.ReactElement => { - - + + Created on Jan 11, 2025 - + Starters{"'"} CFP Private Limited Vendor @@ -305,13 +327,17 @@ export const WithCustomHeader = (args: DrawerProps): React.ReactElement => { - - - + + + + + + + @@ -373,7 +399,7 @@ export const WithFooter = (args: DrawerProps): React.ReactElement => { Personal Information - + John Doe Individual @@ -566,13 +592,17 @@ export const WithFooter = (args: DrawerProps): React.ReactElement => { {showFooter && ( - - - + + + + + + + )} From 28254fd225ffd1b8a425f36541c545d82d82f583 Mon Sep 17 00:00:00 2001 From: "rzp-slash[bot]" Date: Wed, 8 Jul 2026 20:32:44 +0530 Subject: [PATCH 06/11] fix: auto-heal PR via Slash PR Healer Co-authored-by: admin --- .../components/Drawer/AnimatedDrawerContainer.native.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx index 09c1607565..c331a32189 100644 --- a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx +++ b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx @@ -13,8 +13,6 @@ import { getElevationValue } from '~components/Box/BaseBox/baseBoxStyles'; import { useTheme } from '~components/BladeProvider'; import { makeAccessible } from '~utils/makeAccessible'; -const noop = (): void => {}; - const fillStyle = { position: 'absolute' as const, top: 0, @@ -132,8 +130,8 @@ const AnimatedDrawerContainer = ({ translateX.value = withTiming(isVisible ? 0 : screenWidth, config); surfaceOpacity.value = withTiming(isVisible ? 1 : 0, config); overlayOpacity.value = withTiming(isVisible ? 1 : 0, config, (finished) => { - if (finished && !isVisible) { - runOnJS(onExitComplete ?? noop)(); + if (finished && !isVisible && onExitComplete) { + runOnJS(onExitComplete)(); } }); // eslint-disable-next-line react-hooks/exhaustive-deps From b597d7aeea08ee56f2e619b1710a14c04e9dfd4e Mon Sep 17 00:00:00 2001 From: rohitr-raz Date: Thu, 9 Jul 2026 14:48:52 +0530 Subject: [PATCH 07/11] fix(drawer-native): add closeButtonRef to context and honor isLazy - Add closeButtonRef useRef and include it in DrawerContext - Fall back to closeButtonRef when focusing on open - Honor isLazy: initialize isMounted with isOpen || !isLazy - Guard onUnmount behind hasEverOpenedRef - Update isLazy test for web parity --- .../blade/src/components/Drawer/Drawer.native.tsx | 14 +++++++++----- .../Drawer/__tests__/Drawer.native.test.tsx | 8 +++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/blade/src/components/Drawer/Drawer.native.tsx b/packages/blade/src/components/Drawer/Drawer.native.tsx index 6f0edfb26c..9705d746f1 100644 --- a/packages/blade/src/components/Drawer/Drawer.native.tsx +++ b/packages/blade/src/components/Drawer/Drawer.native.tsx @@ -32,11 +32,12 @@ const _Drawer = ({ accessibilityLabel, showOverlay = true, initialFocusRef, - isLazy: _isLazy = true, + isLazy = true, testID, ...rest }: DrawerProps): React.ReactElement | null => { const [zIndexState, setZIndexState] = React.useState(zIndex); + const closeButtonRef = React.useRef(null); useVerifyAllowedChildren({ children, @@ -56,12 +57,14 @@ const _Drawer = ({ // - isMounted stays true through the exit animation, then flips false via onExitComplete. // - isVisible mirrors isOpen and drives the reanimated slide/opacity. // - isExiting is true while the drawer is animating out (mounted but not open). - const [isMounted, setIsMounted] = React.useState(isOpen); + const [isMounted, setIsMounted] = React.useState(isOpen || !isLazy); const isVisible = isOpen; const isExiting = isMounted && !isOpen; + const hasEverOpenedRef = React.useRef(isOpen); React.useEffect(() => { if (isOpen) { + hasEverOpenedRef.current = true; setIsMounted(true); } }, [isOpen]); @@ -72,7 +75,7 @@ const _Drawer = ({ // when the drawer had actually been opened and is now finishing its exit — never // on the initial closed render. setIsMounted((prevIsMounted) => { - if (prevIsMounted) { + if (prevIsMounted && hasEverOpenedRef.current) { onUnmount?.(); } return false; @@ -91,7 +94,7 @@ const _Drawer = ({ if (isOpen) { addToDrawerStack({ elementId: drawerId, onDismiss }); // Move accessibility focus to the requested element (parity with web initialFocus) - focusOnElement(initialFocusRef?.current ?? null); + focusOnElement(initialFocusRef?.current ?? closeButtonRef.current ?? null); } else { removeFromDrawerStack({ elementId: drawerId }); } @@ -109,6 +112,7 @@ const _Drawer = ({ const contextValue = React.useMemo( () => ({ close: onDismiss, + closeButtonRef, stackingLevel, isExiting, }), @@ -142,7 +146,7 @@ const _Drawer = ({ // Presence is driven by `isMounted`: set true on open, flipped false by // `handleExitComplete` after the exit animation so the slide-out still plays before // the Portal unmounts. `children` render whenever the drawer is mounted. - return isMounted ? ( + return isMounted || !isLazy ? ( diff --git a/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx b/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx index beb38ba2a9..e2a16bd396 100644 --- a/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx +++ b/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx @@ -88,12 +88,10 @@ describe(' (native)', () => { expect(getByText('Test Content')).toBeTruthy(); }); - it('should not mount content while closed even when isLazy is false', () => { - // With the conditional-Portal-mount pattern the drawer subtree only mounts while - // open, so `isLazy` no longer eagerly mounts content behind a closed drawer. - const { queryByText } = renderWithTheme(); + it('should mount content while closed when isLazy is false', () => { + const { getByText } = renderWithTheme(); - expect(queryByText('Test Content')).toBeNull(); + expect(getByText('Test Content')).toBeTruthy(); }); it('should close drawer via the header close button', () => { From e2c0c27d06740a595c6a6167ded835a6722919b1 Mon Sep 17 00:00:00 2001 From: "rzp-slash[bot]" Date: Fri, 10 Jul 2026 10:40:54 +0530 Subject: [PATCH 08/11] fix: make SVG gradient ID unique with useId and fix JSDoc closing tag typo [resolved by agent] Co-authored-by: admin --- packages/blade/src/components/Drawer/Drawer.native.tsx | 2 +- packages/blade/src/components/Drawer/Drawer.web.tsx | 2 +- .../src/components/Drawer/DrawerSubcomponents.native.tsx | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/blade/src/components/Drawer/Drawer.native.tsx b/packages/blade/src/components/Drawer/Drawer.native.tsx index 9705d746f1..000faa390a 100644 --- a/packages/blade/src/components/Drawer/Drawer.native.tsx +++ b/packages/blade/src/components/Drawer/Drawer.native.tsx @@ -205,7 +205,7 @@ const _Drawer = ({ - + ) } diff --git a/packages/blade/src/components/Drawer/Drawer.web.tsx b/packages/blade/src/components/Drawer/Drawer.web.tsx index 3fb2399a78..711a6238f2 100644 --- a/packages/blade/src/components/Drawer/Drawer.web.tsx +++ b/packages/blade/src/components/Drawer/Drawer.web.tsx @@ -254,7 +254,7 @@ const _Drawer: React.ForwardRefRenderFunction = ( - + ) } diff --git a/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx b/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx index 8eb79c9743..b11c90c33f 100644 --- a/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx +++ b/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx @@ -10,6 +10,7 @@ import { BaseFooter } from '~components/BaseHeaderFooter/BaseFooter'; import { Box } from '~components/Box'; import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute'; +import { useId } from '~utils/useId'; import { useTheme } from '~utils'; /** @@ -25,6 +26,7 @@ const DrawerHeaderGradient = ({ color: NonNullable; }): React.ReactElement => { const { theme } = useTheme(); + const uniqueId = useId('drawer-header-gradient'); // Web's `feedback.background[color].subtle` token is a low-opacity hsla() (e.g. 0.18 alpha). // Web applies it as the far stop of a radial-gradient that fades from `transparent`, so the // visible tint never exceeds that alpha (hence it looks very light). react-native-svg's @@ -34,7 +36,7 @@ const DrawerHeaderGradient = ({ const alphaMatch = subtleColor.match(/hsla?\([^)]*,\s*([\d.]+)\s*\)$/); const subtleAlpha = alphaMatch ? Number(alphaMatch[1]) : 1; const opaqueColor = subtleColor.replace(/^hsla/, 'hsl').replace(/,\s*[\d.]+\s*\)$/, ')'); - const gradientId = `drawer-header-gradient-${color}`; + const gradientId = `${uniqueId}-${color}`; return ( From c24756a3c37652e9bc4aad33f926eb4a41187ac0 Mon Sep 17 00:00:00 2001 From: "rzp-slash[bot]" Date: Fri, 10 Jul 2026 11:26:20 +0530 Subject: [PATCH 09/11] fix: mock useId in Drawer native tests for deterministic snapshot IDs Co-authored-by: admin --- .../Drawer/__tests__/Drawer.native.test.tsx | 4 ++++ .../__snapshots__/Drawer.native.test.tsx.snap | 12 ++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx b/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx index e2a16bd396..42c1dffa29 100644 --- a/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx +++ b/packages/blade/src/components/Drawer/__tests__/Drawer.native.test.tsx @@ -9,6 +9,10 @@ import { Button } from '~components/Button'; import { Text } from '~components/Typography'; import { AnnouncementIcon, DownloadIcon } from '~components/Icons'; +jest.mock('~utils/useId', () => ({ + useId: (prefix?: string) => (prefix ? `${prefix}-0` : '0'), +})); + jest.useFakeTimers(); beforeAll(() => jest.spyOn(console, 'error').mockImplementation()); diff --git a/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap b/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap index 08e0f6bc9c..584304a38f 100644 --- a/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap +++ b/packages/blade/src/components/Drawer/__tests__/__snapshots__/Drawer.native.test.tsx.snap @@ -210,7 +210,7 @@ exports[` (native) renders a Drawer 1`] = ` } gradientTransform={null} gradientUnits={0} - name="drawer-header-gradient-information" + name="drawer-header-gradient-0-information" rx="150%" ry="100%" /> @@ -218,7 +218,7 @@ exports[` (native) renders a Drawer 1`] = ` (native) renders a Drawer with footer 1`] = ` } gradientTransform={null} gradientUnits={0} - name="drawer-header-gradient-information" + name="drawer-header-gradient-0-information" rx="150%" ry="100%" /> @@ -1514,7 +1514,7 @@ exports[` (native) renders a Drawer with footer 1`] = ` (native) should render a Drawer with a custom header 1`] = ` } gradientTransform={null} gradientUnits={0} - name="drawer-header-gradient-information" + name="drawer-header-gradient-0-information" rx="150%" ry="100%" /> @@ -2567,7 +2567,7 @@ exports[` (native) should render a Drawer with a custom header 1`] = ` Date: Fri, 10 Jul 2026 12:34:48 +0530 Subject: [PATCH 10/11] fix: resolve PR review comments for Drawer native implementation Co-authored-by: admin --- .../Drawer/AnimatedDrawerContainer.native.tsx | 24 ++++++++++++++++++- .../src/components/Drawer/Drawer.native.tsx | 23 ++++++++++++------ .../Drawer/DrawerSubcomponents.native.tsx | 4 ++-- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx index c331a32189..ece96c56fc 100644 --- a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx +++ b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx @@ -5,6 +5,7 @@ import Animated, { useSharedValue, withTiming, runOnJS, + cancelAnimation, } from 'react-native-reanimated'; import { Dimensions, Pressable } from 'react-native'; import type { ElevationStyles } from '~tokens/global/elevation'; @@ -75,6 +76,12 @@ type AnimatedDrawerContainerProps = { * Accessibility label announced for the drawer dialog. */ accessibilityLabel?: string; + /** + * Whether this drawer is the first (bottom-most) in a stack of 2+ open drawers. + * When true, the resting translateX is offset slightly so the first drawer peeks + * out behind the stacked drawer — matching web's stacked-drawer positioning. + */ + isFirstDrawerInStack?: boolean; /** * Drawer content (DrawerHeader / DrawerBody / DrawerFooter). */ @@ -92,6 +99,7 @@ const AnimatedDrawerContainer = ({ onOverlayPress, onExitComplete, accessibilityLabel, + isFirstDrawerInStack = false, children, }: AnimatedDrawerContainerProps): React.ReactElement => { const { theme } = useTheme(); @@ -127,7 +135,11 @@ const AnimatedDrawerContainer = ({ }; const config = isVisible ? enterConfig : exitConfig; - translateX.value = withTiming(isVisible ? 0 : screenWidth, config); + // When this drawer is the first in a stack (another drawer is open on top), + // offset the resting position slightly to the left so the first drawer peeks + // out behind the stacked drawer — matching web's stacked-drawer positioning. + const stackOffset = isFirstDrawerInStack ? -theme.spacing[5] : 0; + translateX.value = withTiming(isVisible ? stackOffset : screenWidth, config); surfaceOpacity.value = withTiming(isVisible ? 1 : 0, config); overlayOpacity.value = withTiming(isVisible ? 1 : 0, config, (finished) => { if (finished && !isVisible && onExitComplete) { @@ -137,6 +149,16 @@ const AnimatedDrawerContainer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible, screenWidth]); + // Cancel all in-flight animations on unmount to prevent UI-thread worklets + // from continuing to animate shared values after the component is gone. + React.useEffect(() => { + return () => { + cancelAnimation(translateX); + cancelAnimation(surfaceOpacity); + cancelAnimation(overlayOpacity); + }; + }, [translateX, surfaceOpacity, overlayOpacity]); + const surfaceAnimatedStyle = useAnimatedStyle(() => { return { opacity: surfaceOpacity.value, diff --git a/packages/blade/src/components/Drawer/Drawer.native.tsx b/packages/blade/src/components/Drawer/Drawer.native.tsx index 000faa390a..4b0ba43aaf 100644 --- a/packages/blade/src/components/Drawer/Drawer.native.tsx +++ b/packages/blade/src/components/Drawer/Drawer.native.tsx @@ -69,24 +69,32 @@ const _Drawer = ({ } }, [isOpen]); + const wasMountedRef = React.useRef(false); + + React.useEffect(() => { + wasMountedRef.current = isMounted; + }, [isMounted]); + const handleExitComplete = React.useCallback(() => { // Flip `isMounted` false so the Portal unmounts after the exit animation. Guard // the `onUnmount` side-effect behind the previous-mounted flag so it only fires // when the drawer had actually been opened and is now finishing its exit — never // on the initial closed render. - setIsMounted((prevIsMounted) => { - if (prevIsMounted && hasEverOpenedRef.current) { - onUnmount?.(); - } - return false; - }); + // + // The onUnmount call is intentionally OUTSIDE the state updater to keep the + // updater pure (React StrictMode double-invokes updaters in development). + if (wasMountedRef.current && hasEverOpenedRef.current) { + onUnmount?.(); + } + setIsMounted(false); }, [onUnmount]); - const { stackingLevel } = React.useMemo(() => { + const { stackingLevel, isFirstDrawerInStack } = React.useMemo(() => { // eslint-disable-next-line @typescript-eslint/restrict-plus-operands const level = Object.keys(drawerStack).indexOf(drawerId) + 1; return { stackingLevel: level, + isFirstDrawerInStack: level === 1 && Object.keys(drawerStack).length > 1, }; }, [drawerId, drawerStack]); @@ -170,6 +178,7 @@ const _Drawer = ({ onOverlayPress={onDismiss} onExitComplete={handleExitComplete} accessibilityLabel={accessibilityLabel} + isFirstDrawerInStack={isFirstDrawerInStack} > {children} diff --git a/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx b/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx index b11c90c33f..7276596c8b 100644 --- a/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx +++ b/packages/blade/src/components/Drawer/DrawerSubcomponents.native.tsx @@ -78,10 +78,10 @@ const _DrawerHeader = ({ const isStackedDrawer = stackingLevel && stackingLevel > 1; - const isAtleastOneDrawerOpen = Object.keys(drawerStack).length > 0; + const isAtLeastOneDrawerOpen = Object.keys(drawerStack).length > 0; // This condition is to avoid back button disappear while stacked drawer is in the exiting transition - const isDrawerExiting = isAtleastOneDrawerOpen && isExiting && stackingLevel !== 1; + const isDrawerExiting = isAtLeastOneDrawerOpen && isExiting && stackingLevel !== 1; return ( From 5b39742d54ad1b75a1bde5d4a377cc370756335d Mon Sep 17 00:00:00 2001 From: "rzp-slash[bot]" Date: Wed, 15 Jul 2026 20:57:36 +0530 Subject: [PATCH 11/11] fix: resolve review comments on Drawer native implementation [resolved by agent] Co-authored-by: admin --- .../Drawer/AnimatedDrawerContainer.native.tsx | 43 ++++++++++++------- .../src/components/Drawer/Drawer.native.tsx | 35 ++++++++------- .../Drawer/DrawerSubcomponents.native.tsx | 4 +- 3 files changed, 51 insertions(+), 31 deletions(-) diff --git a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx index ece96c56fc..d2c0825382 100644 --- a/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx +++ b/packages/blade/src/components/Drawer/AnimatedDrawerContainer.native.tsx @@ -7,7 +7,7 @@ import Animated, { runOnJS, cancelAnimation, } from 'react-native-reanimated'; -import { Dimensions, Pressable } from 'react-native'; +import { Pressable, useWindowDimensions } from 'react-native'; import type { ElevationStyles } from '~tokens/global/elevation'; import BaseBox from '~components/Box/BaseBox'; import { getElevationValue } from '~components/Box/BaseBox/baseBoxStyles'; @@ -93,17 +93,24 @@ type AnimatedDrawerContainerProps = { * Drawer. Mirrors the web `AnimatedDrawerContainer` styled component but drives * `translateX`/`opacity` via reanimated shared values instead of CSS transitions. */ -const AnimatedDrawerContainer = ({ - isVisible, - showOverlay, - onOverlayPress, - onExitComplete, - accessibilityLabel, - isFirstDrawerInStack = false, - children, -}: AnimatedDrawerContainerProps): React.ReactElement => { +const AnimatedDrawerContainer = React.forwardRef< + React.ComponentRef, + AnimatedDrawerContainerProps +>( + ( + { + isVisible, + showOverlay, + onOverlayPress, + onExitComplete, + accessibilityLabel, + isFirstDrawerInStack = false, + children, + }, + ref, + ): React.ReactElement => { const { theme } = useTheme(); - const screenWidth = Dimensions.get('window').width; + const { width: screenWidth } = useWindowDimensions(); // Always initialize the shared values at the CLOSED / off-screen state // (translateX = screenWidth, opacity = 0) so there is a "from" frame to animate FROM // when the drawer mounts open. The Portal mounts fresh on open, so this container @@ -120,6 +127,11 @@ const AnimatedDrawerContainer = ({ const shadow = (getElevationValue('highRaised', theme) as unknown) as ElevationStyles; + const onExitCompleteRef = React.useRef(onExitComplete); + React.useEffect(() => { + onExitCompleteRef.current = onExitComplete; + }, [onExitComplete]); + React.useEffect(() => { // Mirror web's Drawer surface transition (Drawer.web.tsx): enter uses // `duration.xmoderate` + `easing.entrance`, exit uses `duration.moderate` + @@ -142,8 +154,8 @@ const AnimatedDrawerContainer = ({ translateX.value = withTiming(isVisible ? stackOffset : screenWidth, config); surfaceOpacity.value = withTiming(isVisible ? 1 : 0, config); overlayOpacity.value = withTiming(isVisible ? 1 : 0, config, (finished) => { - if (finished && !isVisible && onExitComplete) { - runOnJS(onExitComplete)(); + if (finished && !isVisible && onExitCompleteRef.current) { + runOnJS(onExitCompleteRef.current)(); } }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -183,7 +195,7 @@ const AnimatedDrawerContainer = ({ ) : null} - + ); -}; + }, +); export { AnimatedDrawerContainer }; diff --git a/packages/blade/src/components/Drawer/Drawer.native.tsx b/packages/blade/src/components/Drawer/Drawer.native.tsx index 4b0ba43aaf..5e4ec0a05b 100644 --- a/packages/blade/src/components/Drawer/Drawer.native.tsx +++ b/packages/blade/src/components/Drawer/Drawer.native.tsx @@ -14,6 +14,7 @@ import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; import { useId } from '~utils/useId'; import { useVerifyAllowedChildren } from '~utils/useVerifyAllowedChildren'; import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute'; +import type { BladeElementRef } from '~utils/types'; const focusOnElement = (element: React.Component | null): void => { if (!element) return; @@ -23,19 +24,22 @@ const focusOnElement = (element: React.Component | null): void => { } }; -const _Drawer = ({ - isOpen, - onDismiss, - onUnmount, - zIndex = componentZIndices.drawer, - children, - accessibilityLabel, - showOverlay = true, - initialFocusRef, - isLazy = true, - testID, - ...rest -}: DrawerProps): React.ReactElement | null => { +const _Drawer: React.ForwardRefRenderFunction = ( + { + isOpen, + onDismiss, + onUnmount, + zIndex = componentZIndices.drawer, + children, + accessibilityLabel, + showOverlay = true, + initialFocusRef, + isLazy = true, + testID, + ...rest + }, + ref, +): React.ReactElement | null => { const [zIndexState, setZIndexState] = React.useState(zIndex); const closeButtonRef = React.useRef(null); @@ -115,7 +119,7 @@ const _Drawer = ({ // eslint-disable-next-line @typescript-eslint/restrict-plus-operands setZIndexState(zIndex + stackingLevel); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isMounted]); + }, [isMounted, stackingLevel]); const contextValue = React.useMemo( () => ({ @@ -173,6 +177,7 @@ const _Drawer = ({ {...makeAnalyticsAttribute(rest)} >