Skip to content

Commit 3a9c638

Browse files
Merge branch 'master' into feat/blade-rn/Drawer
Resolve Storybook preview comment conflict keeping Drawer, BottomSheet, and DatePicker mentions. Co-authored-by: Cursor <cursoragent@cursor.com>
2 parents 7cf80da + 71a4f49 commit 3a9c638

25 files changed

Lines changed: 14900 additions & 832 deletions

.changeset/blade-rn-datepicker.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@razorpay/blade": patch
3+
---
4+
5+
feat(rn): add React Native support for DatePicker

packages/blade/.storybook/react-native/main.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ const WEB_ONLY_STORIES = [
66
'MotionRecipes',
77
'BottomNav',
88
'ChatInput',
9-
'DatePicker',
109
'DetailedView',
1110
'DrawerExamples',
1211
'Fade',

packages/blade/.storybook/react-native/preview.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ export const parameters = {
3434

3535
const styles = StyleSheet.create({
3636
// `flex: 1` lets the container fill the story canvas so absolutely-positioned
37-
// portal content (e.g. the full-height Drawer or BottomSheet) can size against a
38-
// real viewport instead of collapsing to the intrinsic height of the story's
39-
// inline content.
37+
// portal content (e.g. the full-height Drawer / BottomSheet / DatePicker calendar
38+
// sheet) can size against a real viewport instead of collapsing to the intrinsic
39+
// height of the story's inline content.
4040
container: { flex: 1, padding: 16 },
4141
});

packages/blade/.storybook/react-native/storybook.requires.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ const normalizedStories = [
1010
{
1111
titlePrefix: "",
1212
directory: "./src",
13-
files: "**/!(_KitchenSink|*.test|MotionRecipes|BottomNav|ChatInput|DatePicker|DetailedView|DrawerExamples|Fade|ListView|ListViewFigmaExample|Menu|Morph|Move|Settings|SideNav|Slide|RazorSense|Stagger|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|CreationView|TopNav|LightBox|Preview)*.stories.?(ts|tsx|js|jsx)",
14-
importPathMatcher: /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?:(?!(?:_KitchenSink|[^/]*?\.test|MotionRecipes|BottomNav|ChatInput|DatePicker|DetailedView|DrawerExamples|Fade|ListView|ListViewFigmaExample|Menu|Morph|Move|Settings|SideNav|Slide|RazorSense|Stagger|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|CreationView|TopNav|LightBox|Preview))[^/]*?)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/,
13+
files: "**/!(_KitchenSink|*.test|MotionRecipes|BottomNav|ChatInput|DetailedView|DrawerExamples|Fade|ListView|ListViewFigmaExample|Menu|Morph|Move|Settings|SideNav|Slide|RazorSense|Stagger|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|CreationView|TopNav|LightBox|Preview)*.stories.?(ts|tsx|js|jsx)",
14+
importPathMatcher: /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?:(?!(?:_KitchenSink|[^/]*?\.test|MotionRecipes|BottomNav|ChatInput|DetailedView|DrawerExamples|Fade|ListView|ListViewFigmaExample|Menu|Morph|Move|Settings|SideNav|Slide|RazorSense|Stagger|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|CreationView|TopNav|LightBox|Preview))[^/]*?)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/,
1515
// @ts-ignore
1616
req: require.context(
1717
'../../src',
1818
true,
19-
/^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?:(?!(?:_KitchenSink|[^/]*?\.test|MotionRecipes|BottomNav|ChatInput|DatePicker|DetailedView|DrawerExamples|Fade|ListView|ListViewFigmaExample|Menu|Morph|Move|Settings|SideNav|Slide|RazorSense|Stagger|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|CreationView|TopNav|LightBox|Preview))[^/]*?)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/
19+
/^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?:(?!(?:_KitchenSink|[^/]*?\.test|MotionRecipes|BottomNav|ChatInput|DetailedView|DrawerExamples|Fade|ListView|ListViewFigmaExample|Menu|Morph|Move|Settings|SideNav|Slide|RazorSense|Stagger|TableEditableCellAPI|TableFigmaExample|TableStripedRows|Toast|Typography|ChatMessage|CreationView|TopNav|LightBox|Preview))[^/]*?)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/
2020
),
2121
},
2222
{

packages/blade/src/components/BottomSheet/BottomSheet.native.tsx

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ import GorhomBottomSheet, {
77
import type { BottomSheetBackgroundProps } from '@gorhom/bottom-sheet';
88
import React from 'react';
99
import { Portal } from '@gorhom/portal';
10-
import { Dimensions, AccessibilityInfo, findNodeHandle, View, Keyboard } from 'react-native';
10+
import {
11+
Dimensions,
12+
AccessibilityInfo,
13+
findNodeHandle,
14+
View,
15+
Keyboard,
16+
useWindowDimensions,
17+
} from 'react-native';
1118
import { BottomSheetHeader } from './BottomSheetHeader';
1219
import { BottomSheetGrabHandle } from './BottomSheetGrabHandle';
1320
import { BottomSheetBody } from './BottomSheetBody';
@@ -53,6 +60,10 @@ const focusOnElement = (element: React.Component<any, any>): void => {
5360
}
5461
};
5562

63+
// Extra breathing room (home-indicator / safe area) added below the measured
64+
// content when `snapToContentHeight` is on, so the last row is never clipped.
65+
const CONTENT_HEIGHT_SAFE_AREA = 24;
66+
5667
const _BottomSheet = ({
5768
children,
5869
snapPoints = [0.35, 0.5, 0.85],
@@ -62,6 +73,7 @@ const _BottomSheet = ({
6273
isContentPanningGestureEnabled = true,
6374
initialFocusRef,
6475
zIndex = componentZIndices.bottomSheet,
76+
snapToContentHeight = false,
6577
}: BottomSheetProps): React.ReactElement => {
6678
const bottomSheetAndDropdownGlue = useBottomSheetAndDropdownGlue();
6779
const defaultInitialFocusRef = React.useRef<any>(null);
@@ -76,7 +88,7 @@ const _BottomSheet = ({
7688
const [hasBodyPadding, setHasBodyPadding] = React.useState(true);
7789
const [isHeaderEmpty, setIsHeaderEmpty] = React.useState(false);
7890
const initialSnapPoint = React.useRef<number>(0);
79-
const lastSnappedSnapPointRef = React.useRef<string | null>(null);
91+
const lastSnappedSnapPointRef = React.useRef<string | number | null>(null);
8092
const isOpenRef = React.useRef(Boolean(_isOpen));
8193
isOpenRef.current = Boolean(_isOpen);
8294
// Gorhom emits `onClose` for programmatic snap/close as well as user dismiss.
@@ -104,7 +116,12 @@ const _BottomSheet = ({
104116

105117
// if bottomSheet height is >35% & <50% then set initial snapPoint to 35%
106118
useIsomorphicLayoutEffect(() => {
107-
if (bottomSheetAndDropdownGlue?.hasAutoCompleteInHeader) {
119+
if (snapToContentHeight) {
120+
// Content-driven mode has a single derived snap point, so index 0 is the only
121+
// valid open index. Anything higher would be out of range and crash Gorhom's
122+
// `snapToIndex` invariant (`index <= snapPoints.length - 1`).
123+
initialSnapPoint.current = 0;
124+
} else if (bottomSheetAndDropdownGlue?.hasAutoCompleteInHeader) {
108125
// In AutoComplete, we want to open BottomSheet with max height so we set this to last index
109126
initialSnapPoint.current = 2;
110127
} else if (totalHeight > 0) {
@@ -119,10 +136,35 @@ const _BottomSheet = ({
119136
initialSnapPoint.current = 0;
120137
}
121138
}
122-
}, [snapPoints, totalHeight, bottomSheetAndDropdownGlue?.hasAutoCompleteInHeader]);
139+
}, [
140+
snapPoints,
141+
totalHeight,
142+
snapToContentHeight,
143+
bottomSheetAndDropdownGlue?.hasAutoCompleteInHeader,
144+
]);
123145

124-
const windowHeight = Dimensions.get('window').height;
125-
const _snapPoints = React.useMemo(() => {
146+
const { height: windowHeight } = useWindowDimensions();
147+
const _snapPoints = React.useMemo<(string | number)[]>(() => {
148+
// Content-driven sizing: derive a SINGLE snap point from the measured content
149+
// height (header + body + footer) so the sheet hugs its content. The array is
150+
// always length 1 in this mode (index 0 is the only valid open index) — before
151+
// the first measurement lands we use a sensible default fraction so the sheet
152+
// opens smoothly and never requests an out-of-range snap index.
153+
if (snapToContentHeight) {
154+
if (totalHeight > 0) {
155+
// Use an ABSOLUTE pixel snap point (not a percentage). Gorhom resolves a
156+
// percentage against its own container height, which is shorter than the
157+
// window by the top inset (status bar / dynamic island). Dividing the
158+
// measured content by the window height therefore under-sized the sheet and
159+
// clipped the last grid row (forcing a scroll). A pixel value sizes the sheet
160+
// to exactly the measured content regardless of container/window mismatch, so
161+
// every grid view (date / month / year) is fully visible. Capped so very tall
162+
// content still leaves a small gap at the top instead of covering the notch.
163+
const pixelHeight = Math.min(totalHeight + CONTENT_HEIGHT_SAFE_AREA, windowHeight * 0.95);
164+
return [pixelHeight];
165+
}
166+
return ['60%'];
167+
}
126168
if (totalHeight > 0) {
127169
const fittedHeight = computeMaxContent({
128170
maxHeight: windowHeight * snapPoints[2],
@@ -137,7 +179,23 @@ const _BottomSheet = ({
137179
return [`${fittedSnap * 100}%`, `${snapPoints[1] * 100}%`, `${snapPoints[2] * 100}%`];
138180
}
139181
return snapPoints.map((point) => `${point * 100}%`);
140-
}, [snapPoints, totalHeight, headerHeight, footerHeight, contentHeight, windowHeight]);
182+
}, [
183+
snapToContentHeight,
184+
snapPoints,
185+
totalHeight,
186+
headerHeight,
187+
footerHeight,
188+
contentHeight,
189+
windowHeight,
190+
]);
191+
192+
// Always clamp the open index into the CURRENT snap-points range. The snap-points
193+
// array length can change at runtime (e.g. content-driven sizing collapses it to a
194+
// single point), and requesting a now-nonexistent index throws Gorhom's invariant.
195+
const getSafeSnapIndex = React.useCallback(
196+
(index: number) => Math.min(Math.max(index, 0), _snapPoints.length - 1),
197+
[_snapPoints.length],
198+
);
141199

142200
const dismissSheet = React.useCallback(() => {
143201
if (!isDismissible) return;
@@ -158,8 +216,8 @@ const _BottomSheet = ({
158216

159217
const handleOnOpen = React.useCallback(() => {
160218
suppressDismiss();
161-
sheetRef.current?.snapToIndex(initialSnapPoint.current);
162-
}, [suppressDismiss]);
219+
sheetRef.current?.snapToIndex(getSafeSnapIndex(initialSnapPoint.current));
220+
}, [suppressDismiss, getSafeSnapIndex]);
163221

164222
const handleOnClose = React.useCallback(() => {
165223
suppressDismiss();
@@ -188,13 +246,13 @@ const _BottomSheet = ({
188246

189247
React.useEffect(() => {
190248
if (!_isOpen || totalHeight === 0) return;
191-
const targetIndex = initialSnapPoint.current;
249+
const targetIndex = getSafeSnapIndex(initialSnapPoint.current);
192250
const targetSnapPoint = _snapPoints[targetIndex];
193251
if (lastSnappedSnapPointRef.current === targetSnapPoint) return;
194252
lastSnappedSnapPointRef.current = targetSnapPoint;
195253
suppressDismiss();
196254
sheetRef.current?.snapToIndex(targetIndex);
197-
}, [_isOpen, totalHeight, _snapPoints, suppressDismiss]);
255+
}, [_isOpen, totalHeight, _snapPoints, suppressDismiss, getSafeSnapIndex]);
198256

199257
// let the Dropdown component know that it's rendering a bottomsheet
200258
React.useEffect(() => {
@@ -237,6 +295,9 @@ const _BottomSheet = ({
237295

238296
const renderBackdrop = React.useCallback(
239297
(props: any): React.ReactElement => {
298+
// `isDismissible` MUST be forwarded — the backdrop uses it to decide its
299+
// `pressBehavior` ('close' vs 'none'). Without it, tapping outside the sheet
300+
// does nothing (the tap-outside-to-close affordance is lost).
240301
return (
241302
<BottomSheetBackdrop {...props} zIndex={bottomSheetZIndex} isDismissible={isDismissible} />
242303
);
@@ -357,7 +418,7 @@ const _BottomSheet = ({
357418
ref={sheetRef}
358419
// on initial render if _isOpen is true we want to render the sheet at initialSnapPoint
359420
// otherwise we want to render it at -1 so that it is not visible
360-
index={_isOpen ? initialSnapPoint.current : -1}
421+
index={_isOpen ? getSafeSnapIndex(initialSnapPoint.current) : -1}
361422
containerStyle={{ zIndex: bottomSheetZIndex, elevation: bottomSheetZIndex }}
362423
animateOnMount={true}
363424
handleComponent={renderHandle}

packages/blade/src/components/BottomSheet/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ type BottomSheetProps = {
5353
* @default 100
5454
*/
5555
zIndex?: number;
56+
/**
57+
* When `true`, the sheet sizes itself to its content instead of resting on the
58+
* provided `snapPoints`. The single snap point is derived from the measured
59+
* header + body + footer height (clamped to the screen), so there is no empty
60+
* gap below the content and the sheet grows/shrinks as the content changes.
61+
*
62+
* @default false
63+
*/
64+
snapToContentHeight?: boolean;
5665
} & DataAnalyticsAttribute;
5766

5867
type BottomSheetHeaderProps = Pick<

0 commit comments

Comments
 (0)