feat(rn): add React Native support for Drawer#3645
Conversation
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 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: 0bd408e The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
🤖 Slash AI Review has been triggered. View execution logs |
🛡️ Coverage ReportSummaryFull Coverage Details |
|
(Review Cancelled - Superseded by a new run) |
- Remove empty noop arrow function, guard onExitComplete call directly - Rename unused isLazy to ignoredIsLazy to satisfy no-unused-vars rule Co-authored-by: admin <admin>
|
🤖 Slash AI Review has been triggered. View execution logs |
|
(Review Cancelled - Superseded by a new run) |
There was a problem hiding this comment.
✨ Agentic PR Review ✨
UI Review
❌ 2 failed
| Check | Problem |
|---|---|
| iOS Drawer visual verification | No screenshots or videos are embedded in the PR body to visually verify the iOS Drawer implementation. Only a textual description of manual testing is provided, which is insufficient for ui-critique verification of React Native components. |
| Android Drawer visual verification | Android visual verification was not performed. No screenshots or videos demonstrating Drawer behavior on Android are provided. |
Usage
import { Drawer, DrawerHeader, DrawerBody, DrawerFooter } from '@razorpay/blade/components';
const MyDrawer = () => {
const [showDrawer, setShowDrawer] = React.useState(false);
return (
<Box>
<Button onClick={() => setShowDrawer(true)}>Open Drawer</Button>
<Drawer
isOpen={showDrawer}
onDismiss={() => setShowDrawer(false)}
>
<DrawerHeader title="Announcements" />
<DrawerBody>
<Text>Content</Text>
</DrawerBody>
</Drawer>
</Box>
);
};Co-authored-by: admin <admin>
|
(Review Cancelled - Superseded by a new run) |
There was a problem hiding this comment.
✨ Agentic PR Review ✨
UI Review
❌ 1 failed
| Check | Problem |
|---|---|
| React Native Drawer - visual verification | No screenshots / videos found in PR_BODY (required for react native components) |
Usage
import { Drawer, DrawerHeader, DrawerBody, DrawerFooter } from '@razorpay/blade/components';
const MyDrawer = () => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<Box>
<Button onClick={() => setIsOpen(true)}>Open Drawer</Button>
<Drawer
isOpen={isOpen}
onDismiss={() => setIsOpen(false)}
>
<DrawerHeader title="Announcements" />
<DrawerBody>
<Text>Content</Text>
</DrawerBody>
</Drawer>
</Box>
);
};| accessibilityLabel, | ||
| showOverlay = true, | ||
| initialFocusRef, | ||
| isLazy: _isLazy = true, |
There was a problem hiding this comment.
🟠 [MAJOR] · code-quality-critique · confidence: 7/10
Problem: isLazy prop is silently ignored on native
Suggestion: The prop is destructured as _isLazy and never used, so isLazy={false} (keep drawer mounted when closed, a documented API contract) has no effect on native. Either implement the behaviour — keep isMounted true when isLazy=false even after close — or throw/warn so callers are not silently misled.
|
|
||
| React.useEffect(() => { | ||
| if (isOpen) { | ||
| addToDrawerStack({ elementId: drawerId, onDismiss }); |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 6/10
Problem: closeButtonRef not provided in native DrawerContext value
Suggestion: DrawerSubcomponents.native.tsx reads closeButtonRef from DrawerContext and forwards it to BaseHeader, but Drawer.native.tsx never populates it. Create a local const closeButtonRef = React.useRef(null) in _Drawer and include it in contextValue, mirroring the web implementation, so BaseHeader can attach the ref to its close button.
…een width [resolved by agent] Co-authored-by: admin <admin>
|
(Review Cancelled - Superseded by a new run) |
There was a problem hiding this comment.
✨ Agentic PR Review ✨
UI Review
✅ 1 passed · ❌ 1 failed
| Check | Problem |
|---|---|
| React Native Drawer - Visual Verification | No screenshots / videos found in PR_BODY (required for react native components) |
Passing checks (1)
| Check |
|---|
| ✅ Web Storybook - Drawer Story Fix |
Usage
import { Drawer, DrawerHeader, DrawerBody, DrawerFooter } from '@razorpay/blade/components';
const MyDrawer = () => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>Open Drawer</Button>
<Drawer
isOpen={isOpen}
onDismiss={() => setIsOpen(false)}
showOverlay={true}
accessibilityLabel="My Drawer"
isLazy={true}
testID="my-drawer"
>
<DrawerHeader
title="Announcements"
subtitle="What's new"
leading={<AnnouncementIcon size="large" />}
trailing={<Button icon={DownloadIcon} />}
titleSuffix={<Badge color="positive">NEW</Badge>}
showDivider={true}
/>
<DrawerBody>
<Text>Drawer content here</Text>
</DrawerBody>
<DrawerFooter showDivider={true}>
<Button variant="primary" isFullWidth>Continue</Button>
</DrawerFooter>
</Drawer>
</>
);
};- 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 <cursoragent@cursor.com>
|
🤖 Slash AI Review has been triggered. View execution logs |
|
(Review Cancelled - Superseded by a new run) |
Co-authored-by: admin <admin>
|
(Review Cancelled - Superseded by a new run) |
There was a problem hiding this comment.
✨ Agentic PR Review ✨
UI Review
❌ 1 failed
| Check | Problem |
|---|---|
| Visual Verification | No screenshots / videos found in PR_BODY (required for react native components) |
Usage
import { Drawer, DrawerHeader, DrawerBody, DrawerFooter } from '@razorpay/blade/components';
const MyDrawer = () => {
const [showDrawer, setShowDrawer] = React.useState(false);
return (
<Box>
<Button onPress={() => setShowDrawer(true)}>Open Drawer</Button>
<Drawer
isOpen={showDrawer}
onDismiss={() => setShowDrawer(false)}
accessibilityLabel="Announcements drawer"
showOverlay={true}
isLazy={true}
testID="my-drawer"
>
<DrawerHeader
title="Announcements"
subtitle="What's new"
leading={<DrawerHeaderIcon icon={AnnouncementIcon} />}
trailing={<Button icon={DownloadIcon} />}
titleSuffix={<DrawerHeaderBadge>New</DrawerHeaderBadge>}
color="information"
showDivider={true}
/>
<DrawerBody>
<Text>Content here</Text>
</DrawerBody>
<DrawerFooter showDivider={true}>
<Button isFullWidth>Continue</Button>
</DrawerFooter>
</Drawer>
</Box>
);
};There was a problem hiding this comment.
✨ Agentic PR Review ✨
UI Review
✅ 2 passed · ❌ 1 failed
| Check | Problem | Screenshot |
|---|---|---|
| React Native Drawer - Visual Verification |
Passing checks (2)
| Check | Screenshot |
|---|---|
| ✅ Web Drawer Stories - Simple Drawer (flexDirection fix) | ![]() |
| ✅ Web Drawer Stories - Drawer Stacking (flexDirection fix) | ![]() |
Usage
import { Drawer, DrawerHeader, DrawerBody, DrawerFooter } from '@razorpay/blade/components';
const MyDrawer = () => {
const [showDrawer, setShowDrawer] = React.useState(false);
return (
<Box>
<Button onClick={() => setShowDrawer(true)}>Open Drawer</Button>
<Drawer
isOpen={showDrawer}
onDismiss={() => setShowDrawer(false)}
>
<DrawerHeader title="Announcements" />
<DrawerBody>
<Text>Content</Text>
</DrawerBody>
<DrawerFooter>
<Button isFullWidth>Continue</Button>
</DrawerFooter>
</Drawer>
</Box>
);
}- 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
|
(Review Cancelled - Superseded by a new run) |
|
(Review Cancelled - Superseded by a new run) |
There was a problem hiding this comment.
✨ Agentic PR Review ✨
UI Review
🔗 Storybook: Preview
✅ 6 passed
Passing checks (6)
| Check | Screenshot |
|---|---|
| ✅ Drawer SimpleDrawer story | ![]() |
| ✅ Drawer NoOverlay story | ![]() |
| ✅ Drawer DrawerStacking story | ![]() |
| ✅ Drawer InitialFocus story | ![]() |
| ✅ Drawer WithCustomHeader story | ![]() |
| ✅ Drawer WithFooter story | ![]() |
CI / Sanity
✅ 17 passed · ⏭️ 2 skipped
Passing checks (17)
Usage
import { Drawer, DrawerHeader, DrawerBody, DrawerFooter } from '@razorpay/blade/components';
<Drawer isOpen={showDrawer} onDismiss={() => setShowDrawer(false)} accessibilityLabel="My Drawer">
<DrawerHeader title="Title" subtitle="Subtitle" />
<DrawerBody>Content</DrawerBody>
<DrawerFooter>
<Button variant="primary" isFullWidth>Continue</Button>
</DrawerFooter>
</Drawer>| }); | ||
| }, [onUnmount]); | ||
|
|
||
| const { stackingLevel } = React.useMemo(() => { |
There was a problem hiding this comment.
🙏🏻 [NEEDS CLARIFICATION] · api-decision-critique · confidence: 7/10
The native Drawer omits the isFirstDrawerInStack concept that exists on web (Drawer.web.tsx:123-130). On web, when multiple drawers are stacked, the first drawer shifts an extra 16px/24px left (translateX(calc(-100% - 16px))) to reveal the drawer behind it, and gets top/bottom margin. On native, all drawers slide to the same position (translateX: 0), so stacked drawers fully overlap with no peek-through. The DrawerStacking story exists and would render differently on native. Is this an intentional mobile UX decision, or should the stacking offset be replicated? If intentional, consider adding a code comment documenting the divergence.
| children, | ||
| }: AnimatedDrawerContainerProps): React.ReactElement => { | ||
| const { theme } = useTheme(); | ||
| const screenWidth = Dimensions.get('window').width; |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 7/10
Problem: Dimensions.get('window').width is captured at render time and is not reactive to dimension changes (device rotation, window resize). While the effect depends on screenWidth, the component may not re-render on rotation unless something else triggers it. useWindowDimensions() is the recommended React Native hook that subscribes to dimension changes and guarantees re-renders. Note: BottomSheet.native.tsx uses the same pattern, so this is consistent with the existing codebase but suboptimal.
Suggestion: Replace const screenWidth = Dimensions.get('window').width; with const { width: screenWidth } = useWindowDimensions(); (import from 'react-native').
| // 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*\)$/); |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 7/10
Problem: The regex-based color parsing at lines 34-36 assumes the theme token is always in hsla() format. If the token format ever changes (e.g., to rgba(), #rrggbb, or a named color), the regex won't match: subtleAlpha silently defaults to 1 (fully opaque) and opaqueColor remains unchanged — producing a fully opaque gradient instead of the intended light tint, with no error thrown.
Suggestion: Add a defensive check: if alphaMatch is null, log a warning and fall back to a safe default (e.g., use the color as-is with a reasonable default alpha like 0.1). Alternatively, parse the color with a more robust approach that handles multiple formats.
| 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}`; |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 8/10
Problem: The SVG gradient ID drawer-header-gradient-${color} is not unique across drawer instances. When two stacked drawers have the same color prop (e.g., both information), both <RadialGradient> elements get the same id. Duplicate SVG IDs are invalid and can cause rendering issues — some SVG renderers may fail to render the gradient for the second instance, resulting in a missing background on the stacked drawer's header. Drawer stacking is a supported feature (see DrawerStacking story).
Suggestion: Make the gradient ID unique by incorporating a unique identifier, e.g., using useId(): const gradientId = useId('drawer-header-gradient'); or append a unique suffix.
There was a problem hiding this comment.
✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)
There was a problem hiding this comment.
Made the gradient ID unique by using useId() to generate a per-instance prefix: const gradientId = ${uniqueId}-${color};. This ensures stacked drawers with the same color prop no longer collide on the SVG gradient ID. [resolved by agent]
| }; | ||
| }, [drawerId, drawerStack]); | ||
|
|
||
| React.useEffect(() => { |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 6/10
Problem: The effect that manages addToDrawerStack/removeFromDrawerStack has no cleanup function. If the component unmounts while isOpen is true (e.g., parent conditionally removes the <Drawer> element without first setting isOpen to false), the drawer's entry remains stale in drawerStack. Subsequently, closeAllDrawers() in DrawerHeader would call the stale onDismiss callback, potentially causing errors.
Suggestion: Add a cleanup function to the effect: return () => { removeFromDrawerStack({ elementId: drawerId }); }; to ensure the stack entry is always removed when the component unmounts.
|
|
||
| React.useEffect(() => { | ||
| if (isOpen) { | ||
| addToDrawerStack({ elementId: drawerId, onDismiss }); |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 5/10
Problem: The effect depends only on [isOpen] but captures onDismiss at the time it runs. If the parent re-renders with a new onDismiss function reference (common with inline arrow functions), the effect won't re-run and the stack retains the old callback. When closeAllDrawers() calls Object.values(drawerStack), it invokes the stale onDismiss which may reference outdated closure values.
Suggestion: Add onDismiss to the effect's dependency array, or store onDismiss in a ref to avoid re-triggering the add/remove cycle while keeping the callback current.
| } | ||
| }, [isOpen]); | ||
|
|
||
| const handleExitComplete = React.useCallback(() => { |
There was a problem hiding this comment.
🔵 [MINOR] · api-decision-critique · confidence: 7/10
Problem: onUnmount timing differs between web and native. On web, onUnmount fires after usePresence's exitTransitionDuration which is theme.motion.duration.xmoderate, while the visible CSS transition uses theme.motion.duration.moderate. On native, onUnmount fires immediately after the reanimated exit animation completes, which uses duration.moderate. This means native fires onUnmount sooner than web relative to the dismiss action.
Suggestion: Consider aligning the web exitTransitionDuration to theme.motion.duration.moderate to match the visible animation, which would also align with native. Alternatively, add a note in the types docs that onUnmount timing is best-effort and may vary by platform.
| * | ||
| * | ||
| */ | ||
| const Drawer = assignWithoutSideEffects(_Drawer, { |
There was a problem hiding this comment.
🙏🏻 [NEEDS CLARIFICATION] · api-decision-critique · confidence: 6/10
The web Drawer wraps _Drawer with React.forwardRef(), allowing consumers to pass a ref to access the drawer DOM element. The native Drawer does not use forwardRef, so passing ref on native silently does nothing. This is consistent with the existing BottomSheet.native pattern (which also omits forwardRef), but is a cross-platform API parity gap. Is the omission intentional for native, or should forwardRef be added for consistency?
… typo [resolved by agent] Co-authored-by: admin <admin>
|
🤖 Slash AI Review has been triggered. View execution logs |
|
(Review Cancelled - Superseded by a new run) |
Co-authored-by: admin <admin>
|
(Review Cancelled - Superseded by a new run) |
|
✨ Agentic PR Healer ✨ Fixed CI failure: 3 failing snapshot tests in Root cause: The Fix:
This ensures snapshot tests are stable and don't depend on the module-level |
There was a problem hiding this comment.
✨ Agentic PR Review ✨
UI Review
🔗 Storybook: Preview
✅ 7 passed · ❌ 5 failed · ⏭️ 1 skipped
| Check | Problem |
|---|---|
| Stacking Offset Visual Parity | Missing isFirstDrawerInStack translateX offset in native AnimatedDrawerContainer — stacked drawers will look visually different from web Suggestion: Pass stackingLevel/isFirstDrawerInStack from Drawer.native.tsx into AnimatedDrawerContainer and apply a translateX offset when a drawer is the first in a stack of 2+. |
| Overlay Animation Timing Parity | Overlay animation durations differ between web and native Suggestion: Use separate timing configs for overlay vs surface to match web's DrawerOverlay styled component. |
| DrawerHeaderGradient hsla Color Parsing | Fragile regex-based hsla color parsing in DrawerHeaderGradient — silently degrades to full opacity if token format changes Suggestion: Add a DEV warning when the regex doesn't match, or use a structured color parser. |
| Screen Dimension Change Handling | No Dimensions change listener — screenWidth is captured once at mount and never updated for rotation/resize Suggestion: Use useWindowDimensions() hook from 'react-native' which re-renders on dimension changes. |
| Scroll Lock Parity | No scroll lock on native — background content can scroll while drawer is open Suggestion: Consider intercepting touch events on the overlay or using a modal-level interaction lock. |
Passing checks (7)
| Check |
|---|
| ✅ Storybook Publish |
| ✅ Chromatic Deployment |
| ✅ DrawerFooter Sticky Position Parity |
| ✅ Surface Animation Duration Parity |
| ✅ Shadow/Elevation Handling |
| ✅ Portal and Context Re-provision |
| ✅ Accessibility Parity |
CI / Sanity
✅ 12 passed
Passing checks (12)
Usage
import { Drawer, DrawerHeader, DrawerBody, DrawerFooter } from '@razorpay/blade';
const MyDrawer = () => {
const [showDrawer, setShowDrawer] = React.useState(false);
return (
<>
<Button onClick={() => setShowDrawer(true)}>Open Drawer</Button>
<Drawer
isOpen={showDrawer}
onDismiss={() => setShowDrawer(false)}
onUnmount={() => console.log('Drawer closed and unmounted')}
showOverlay={true}
accessibilityLabel="Announcements drawer"
>
<DrawerHeader title="Announcements" subtitle="What's new" />
<DrawerBody>
<Text>Drawer content goes here</Text>
</DrawerBody>
<DrawerFooter>
<Button variant="primary" isFullWidth>Continue</Button>
</DrawerFooter>
</Drawer>
</>
);
};|
|
||
| const shadow = (getElevationValue('highRaised', theme) as unknown) as ElevationStyles; | ||
|
|
||
| React.useEffect(() => { |
There was a problem hiding this comment.
🟠 [MAJOR] · code-quality-critique · confidence: 9/10
Problem: The useEffect that drives withTiming animations has no cleanup function. Every other reanimated component in the codebase (Spinner, ProgressBar, Skeleton) calls cancelAnimation on each shared value in the useEffect cleanup to prevent the UI-thread worklet from continuing to animate after unmount. If AnimatedDrawerContainer unmounts mid-animation (e.g. rapid open/close toggle), the withTiming worklets on translateX, surfaceOpacity, and overlayOpacity continue running on the UI thread with no component to update, risking memory leaks or native crashes.
Suggestion: Add a cleanup return to the useEffect that cancels all three shared values: cancelAnimation(translateX); cancelAnimation(surfaceOpacity); cancelAnimation(overlayOpacity); Also add cancelAnimation to the import from 'react-native-reanimated'.
There was a problem hiding this comment.
✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)
There was a problem hiding this comment.
Fixed: Added cancelAnimation to the reanimated import and a cleanup useEffect that cancels all three shared values (translateX, surfaceOpacity, overlayOpacity) on unmount, matching the pattern used by other reanimated components (Spinner, ProgressBar, Skeleton).
React.useEffect(() => {
return () => {
cancelAnimation(translateX);
cancelAnimation(surfaceOpacity);
cancelAnimation(overlayOpacity);
};
}, [translateX, surfaceOpacity, overlayOpacity]);[resolved by agent]
| // 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) => { |
There was a problem hiding this comment.
🟠 [MAJOR] · code-quality-critique · confidence: 8/10
Problem: handleExitComplete calls onUnmount?.() inside the setIsMounted state updater function. React state updater functions should be pure — in StrictMode (development), React intentionally double-invokes updaters to detect side effects, which would cause onUnmount to fire twice.
Suggestion: Move the onUnmount call outside the updater. Use a ref to track the previous mounted state, then call onUnmount and setIsMounted separately.
There was a problem hiding this comment.
✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)
There was a problem hiding this comment.
Fixed: Moved the onUnmount?.() call outside the setIsMounted state updater. Now uses a wasMountedRef (synced via a useEffect) to track the previous mounted state, and calls onUnmount and setIsMounted(false) as separate statements. The state updater is now pure, avoiding the StrictMode double-invocation issue.
const wasMountedRef = React.useRef(false);
React.useEffect(() => {
wasMountedRef.current = isMounted;
}, [isMounted]);
const handleExitComplete = React.useCallback(() => {
if (wasMountedRef.current && hasEverOpenedRef.current) {
onUnmount?.();
}
setIsMounted(false);
}, [onUnmount]);[resolved by agent]
| }; | ||
| }, [drawerId, drawerStack]); | ||
|
|
||
| React.useEffect(() => { |
There was a problem hiding this comment.
🟠 [MAJOR] · code-quality-critique · confidence: 7/10
Problem: There is no hardware back button (Android BackHandler) handling. The web version handles Escape key to dismiss the drawer. On Android, users expect the hardware back button to close an open drawer. Without a BackHandler subscription, pressing back while a drawer is open will navigate away from the screen instead of closing the drawer.
Suggestion: Add a BackHandler subscription inside the isOpen effect: const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { onDismiss(); return true; }); return () => backHandler.remove();
| }); | ||
| }, [onUnmount]); | ||
|
|
||
| const { stackingLevel } = React.useMemo(() => { |
There was a problem hiding this comment.
🔵 [MINOR] · api-decision-critique · confidence: 8/10
Problem: Missing isFirstDrawerInStack computation. Web computes this and passes it to AnimatedDrawerContainer to apply different translateX offsets for stacked drawers. Native omits it entirely, so stacked drawers will have identical positioning to single drawers — losing the visual offset that distinguishes stacked drawers on web.
Suggestion: Compute isFirstDrawerInStack the same way as web and pass it to AnimatedDrawerContainer to adjust translateX target for stacked drawer positioning parity.
There was a problem hiding this comment.
✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)
There was a problem hiding this comment.
Fixed: Added isFirstDrawerInStack computation in Drawer.native.tsx (matching web's logic exactly: level === 1 && Object.keys(drawerStack).length > 1) and passed it as a new prop to AnimatedDrawerContainer. In the animated container, when isFirstDrawerInStack is true and the drawer is visible, the resting translateX target is offset by -theme.spacing[5] (16px, matching web's base breakpoint offset) so the first drawer peeks out behind the stacked drawer.
// Drawer.native.tsx
const { stackingLevel, isFirstDrawerInStack } = React.useMemo(() => {
const level = Object.keys(drawerStack).indexOf(drawerId) + 1;
return {
stackingLevel: level,
isFirstDrawerInStack: level === 1 && Object.keys(drawerStack).length > 1,
};
}, [drawerId, drawerStack]);
// AnimatedDrawerContainer.native.tsx
const stackOffset = isFirstDrawerInStack ? -theme.spacing[5] : 0;
translateX.value = withTiming(isVisible ? stackOffset : screenWidth, config);[resolved by agent]
| bottom: 0, | ||
| right: 0, | ||
| width: '90%', | ||
| flexDirection: 'column' as const, |
There was a problem hiding this comment.
🔵 [MINOR] · api-decision-critique · confidence: 7/10
Problem: Drawer width is hardcoded to '90%' with no responsive breakpoints. Web uses width={{ base: '90%', s: '375px', m: '420px' }} to cap the drawer width on larger screens. On RN tablet or wide-window contexts, the native drawer will be significantly wider than its web counterpart.
Suggestion: Use the theme's breakpoint system or Dimensions-based logic to apply responsive width matching web's responsive width tokens.
| children, | ||
| }: AnimatedDrawerContainerProps): React.ReactElement => { | ||
| const { theme } = useTheme(); | ||
| const screenWidth = Dimensions.get('window').width; |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 7/10
Problem: Dimensions.get('window').width is captured once at mount and stored in screenWidth. If the device orientation changes (portrait↔landscape) or the window is resized, the drawer will use a stale screen width for its slide animation distance.
Suggestion: Replace with useWindowDimensions() hook from 'react-native' which re-renders on dimension changes.
| // 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*\)$/); |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 7/10
Problem: The hsla color parsing uses regex to extract the alpha channel from the theme token string. This is fragile — if the theme format ever changes, the regex silently fails and subtleAlpha defaults to 1, producing a fully opaque gradient instead of the intended subtle tint.
Suggestion: Add a __DEV__ warning when the regex doesn't match, so theme format changes are caught early.
| return <Text>Drawer Component is not available for Native mobile apps.</Text>; | ||
| const drawerPadding = 'spacing.6'; | ||
|
|
||
| const _DrawerBody = ({ children }: { children: React.ReactNode }): React.ReactElement => { |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 6/10
Problem: DrawerBody uses a plain ScrollView without keyboardDismissMode. When a user has focused a TextInput inside the drawer body and then scrolls, the keyboard remains open on iOS, covering the content below.
Suggestion: Add keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" to the ScrollView.
|
|
||
| const isStackedDrawer = stackingLevel && stackingLevel > 1; | ||
|
|
||
| const isAtleastOneDrawerOpen = Object.keys(drawerStack).length > 0; |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 9/10
Problem: Variable name isAtleastOneDrawerOpen has a capitalization typo — should be isAtLeastOneDrawerOpen (capitalize the 'L' in 'Least').
Suggestion: Rename to isAtLeastOneDrawerOpen.
There was a problem hiding this comment.
✨ Agentic Resolution ✨: Auto Comment Resolution Triggered (View Logs)
There was a problem hiding this comment.
Fixed: Renamed isAtleastOneDrawerOpen to isAtLeastOneDrawerOpen (capitalized the 'L' in 'Least').
[resolved by agent]
| removeFromDrawerStack({ elementId: drawerId }); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [isOpen]); |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 6/10
Problem: The effect that adds/removes the drawer from the stack has no cleanup function. If the Drawer component unmounts while isOpen is true (e.g. parent conditionally renders), removeFromDrawerStack is never called, leaving a stale entry in drawerStack.
Suggestion: Add a cleanup return to the effect: return () => { removeFromDrawerStack({ elementId: drawerId }); };
Co-authored-by: admin <admin>
|
🤖 Slash AI Review has been triggered. View execution logs |
There was a problem hiding this comment.
✨ Agentic PR Review ✨
UI Review
🔗 Storybook: Preview
✅ 5 passed
Passing checks (5)
| Check |
|---|
| ✅ Drawer Default story (Simple Drawer) |
| ✅ Drawer Stacking story |
| ✅ Initial Focus story |
| ✅ With Custom Header story |
| ✅ With Footer story |
CI / Sanity
✅ 6 passed · ❌ 4 failed · ⏭️ 1 skipped
| Check | Problem |
|---|---|
| Run Tests (1) | Test shard 1 failed. Logs not yet available (run still in progress). Suggestion: Re-run the failed job or check logs once the run completes. |
| Validate Source Code | |
| Knowledgebase Lint | |
| Chromatic Deployment |
Passing checks (6)
| Check |
|---|
| ✅ Run Tests (2) |
| ✅ Run Tests (3) |
| ✅ Run Tests (4) |
| ✅ Storybook Publish: blade |
| ✅ PR Title Check |
| ✅ semgrep-cloud-platform/scan |
Usage
import { Drawer, DrawerHeader, DrawerBody, DrawerFooter } from '@razorpay/blade/components';
<Drawer isOpen={true} onDismiss={() => {}}>
<DrawerHeader title="Title" />
<DrawerBody>Content</DrawerBody>
</Drawer>| children, | ||
| }: AnimatedDrawerContainerProps): React.ReactElement => { | ||
| const { theme } = useTheme(); | ||
| const screenWidth = Dimensions.get('window').width; |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 7/10
Problem: Dimensions.get('window').width is not reactive to screen dimension changes (rotation, fold/unfold, split-screen). If the screen rotates while the drawer is open, the exit animation translates to the stale portrait width.
Suggestion: Replace with useWindowDimensions() for reactivity.
| // 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*\)$/); |
There was a problem hiding this comment.
🔵 [MINOR] · code-quality-critique · confidence: 6/10
Problem: The hsla regex matches both hsl() and hsla() formats. For an hsl() color without alpha, the regex captures lightness as alpha. Currently safe because feedback.background tokens are always hsla().
Suggestion: Require the 'a' in the regex: /hsla([^)],\s([\d.]+)\s*)$/
There was a problem hiding this comment.
✨ Agentic PR Review ✨
Status: Approved ✅
UI Review
✅ 1 passed
Passing checks (1)
| Check |
|---|
| ✅ Drawer native - basic open/close (from PR video) |
Usage
import { Drawer, DrawerHeader, DrawerBody, DrawerFooter } from '@razorpay/blade/components';
<Drawer isOpen={showDrawer} onDismiss={() => setShowDrawer(false)}>
<DrawerHeader title="Title" />
<DrawerBody>Content</DrawerBody>
<DrawerFooter>Footer</DrawerFooter>
</Drawer>







Summary
.native.tsx) implementation for the Drawer component, replacing the previousthrowBladeErrorstubs with real platform-specific rendering.styled-components/native+react-native-reanimatedfor the slide-in panel and dimmed overlay.@gorhom/portalusing a conditional Portal mount (mirrors the workingPopover/Tooltipnative pattern), so the drawer mounts fresh on open and animates to the visible resting state.Screen.Recording.2026-07-09.at.12.53.02.PM.mov
Changes
AnimatedDrawerContainer.native.tsx(reanimated slide-in + overlay),__tests__/Drawer.native.test.tsx(+ snapshot)Drawer.native.tsx,DrawerSubcomponents.native.tsxStackProvider.tsx,BladeProvider.native.tsx.storybook/react-native/preview.tsx— registers aBladeBottomSheetPortalPortalHostin the story decorator (+flex: 1) so portal-based components (Drawer/BottomSheet/Popover/Tooltip/Modal) render inside RN Storybook. Without this,@storybook/react-native-ui's own nestedPortalProvidershadows Blade's host and portal content is silently dropped.docs/Drawer.stories.tsx— removed two invalid bare{' '}string children of aBoxthat crash on native ("Text strings must be rendered within a<Text>").@razorpay/blade.Verification
yarn types:typecheck:native)Notes / Warnings (P2, non-blocking)
Node of type rule not supported as an inline stylestyled-components warning appears while the drawer is mounted; does not affect rendering.🤖 Generated with Claude Code
Made with Cursor