Skip to content

Commit ac1d4fb

Browse files
authored
feat: add non dismissible modal & bottomsheet (#2945)
* feat: add non dismissible modal * chore: update modal docs * feat: update changeset * fix: update knowledge base * feat: add bottom sheet as well * chore: added comments * chore: update changelog * chore: update comments * chore: update comments * chore: update changelog * chore: self review changes * chore: update native snaps * chore: remove test * chore: update syntax * fix: add test * chore: add more test cases * fix: add should close logic
1 parent 0c8494a commit ac1d4fb

24 files changed

+397
-96
lines changed

.changeset/late-pumas-sell.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@razorpay/blade': minor
3+
'@razorpay/blade-mcp': minor
4+
---
5+
6+
feat(blade): add support for non-dismissible modals & bottomsheet
7+
8+
Introduces a new prop `isDismissible` in `Modal` and `BottomSheet` which can be used to prevent users from accidentally dismissing modals and bottomSheet by clicking outside or pressing the escape key. When `isDismissible={false}`, the close button is automatically hidden and the modal and bottomSheet can only be closed through explicit user actions.
9+
10+
```jsx
11+
<Modal
12+
isOpen={isOpen}
13+
isDismissible={false}
14+
>
15+
// .... modal content ....
16+
</Modal>
17+
```
18+
19+
```jsx
20+
<BottomSheet isOpen={isOpen} isDismissible={false}>
21+
// .... bottomsheet component ....
22+
</BottomSheet>
23+
24+
```
25+

packages/blade-mcp/knowledgebase/components/BottomSheet.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ type BottomSheetProps = {
2525
*/
2626
onDismiss: () => void;
2727

28+
/**
29+
* Whether the bottom sheet can be dismissed by tapping backdrop, swiping down.
30+
* @default true
31+
*/
32+
isDismissible?: boolean;
33+
2834
/**
2935
* The content of the BottomSheet
3036
*/

packages/blade-mcp/knowledgebase/components/Modal.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Modal
44

55
## Description
66

7-
Modal is a dialog component that appears in front of the app content to provide critical information or request user input. It's designed to focus user attention, disabling all other interactions until explicitly dismissed. Modals are accessible, can be dismissed via escape key, clicking outside, or a close button, and come in three sizes: small, medium, and large.
7+
Modal is a dialog component that appears in front of the app content to provide critical information or request user input. It's designed to focus user attention, disabling all other interactions until explicitly dismissed. Modals are accessible, can be dismissed via escape key, clicking outside, or a close button (when dismissible), and come in three sizes: small, medium, and large.
88

99
## TypeScript Types
1010

@@ -27,6 +27,13 @@ type ModalProps = {
2727
* Callback function when user clicks on close button or outside the modal or on pressing escape key.
2828
*/
2929
onDismiss: () => void;
30+
/**
31+
* Whether the modal can be dismissed by clicking outside or pressing escape key
32+
* @default true
33+
* @note
34+
* If isDismissible is false, the modal will not be dismissed when the user clicks outside the modal or presses the escape key and the close button will not be shown. you need to handle the closing of the modal from your own code. also onDismiss will not be called.
35+
*/
36+
isDismissible?: boolean;
3037
/**
3138
* Ref to the element to be focused on opening the modal.
3239
*/

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const _BottomSheet = ({
5151
snapPoints = [0.35, 0.5, 0.85],
5252
isOpen,
5353
onDismiss,
54+
isDismissible = true,
5455
initialFocusRef,
5556
zIndex = componentZIndices.bottomSheet,
5657
}: BottomSheetProps): React.ReactElement => {
@@ -100,9 +101,11 @@ const _BottomSheet = ({
100101
]);
101102

102103
const close = React.useCallback(() => {
103-
onDismiss?.();
104-
bottomSheetAndDropdownGlue?.onBottomSheetDismiss();
105-
}, [bottomSheetAndDropdownGlue, onDismiss]);
104+
if (isDismissible) {
105+
onDismiss?.();
106+
bottomSheetAndDropdownGlue?.onBottomSheetDismiss?.();
107+
}
108+
}, [isDismissible, onDismiss, bottomSheetAndDropdownGlue]);
106109

107110
const handleOnOpen = React.useCallback(() => {
108111
sheetRef.current?.snapToIndex(initialSnapPoint.current);
@@ -209,14 +212,23 @@ const _BottomSheet = ({
209212
setContentHeight,
210213
setFooterHeight,
211214
setHeaderHeight,
215+
isDismissible,
212216
scrollRef: () => {},
213217
bind: {} as never,
214218
defaultInitialFocusRef,
215219
isHeaderFloating,
216220
setHasBodyPadding,
217221
setIsHeaderEmpty,
218222
}),
219-
[_isOpen, contentHeight, footerHeight, handleOnClose, headerHeight, isHeaderFloating],
223+
[
224+
_isOpen,
225+
contentHeight,
226+
footerHeight,
227+
handleOnClose,
228+
headerHeight,
229+
isHeaderFloating,
230+
isDismissible,
231+
],
220232
);
221233

222234
// Hack: We need to <Portal> the GorhomBottomSheet to the root of the react-native app
@@ -275,7 +287,7 @@ const _BottomSheet = ({
275287
}
276288
: {}
277289
}
278-
enablePanDownToClose
290+
enablePanDownToClose={isDismissible}
279291
enableOverDrag
280292
enableContentPanningGesture
281293
ref={sheetRef}

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,3 +1171,55 @@ const ProductUseCase1Example: StoryFn<typeof BottomSheetComponent> = () => {
11711171
};
11721172

11731173
export const ProductUseCase1 = ProductUseCase1Example.bind({});
1174+
1175+
const NonDismissibleTemplate: StoryFn<typeof BottomSheetComponent> = () => {
1176+
const [isOpen, setIsOpen] = React.useState(false);
1177+
1178+
return (
1179+
<BaseBox>
1180+
<Button onClick={() => setIsOpen(true)}>Open Non-Dismissible BottomSheet</Button>
1181+
<BottomSheetComponent isOpen={isOpen} isDismissible={false}>
1182+
<BottomSheetHeader
1183+
title="Important Action Required"
1184+
subtitle="This action requires explicit confirmation"
1185+
/>
1186+
<BottomSheetBody>
1187+
<Box marginBottom="spacing.4">
1188+
<Badge color="notice">Notice</Badge>
1189+
</Box>
1190+
<Text marginBottom="spacing.4">
1191+
This is a non-dismissible bottom sheet. Notice there's no close button (X) in the
1192+
header. Try swiping down, tapping outside, or pressing the escape key - it won't close.
1193+
</Text>
1194+
<Text color="surface.text.gray.subtle">
1195+
You must click one of the buttons below to proceed. This pattern is useful for critical
1196+
actions that require explicit user confirmation.
1197+
</Text>
1198+
</BottomSheetBody>
1199+
<BottomSheetFooter>
1200+
<Box
1201+
display="flex"
1202+
gap="spacing.3"
1203+
justifyContent="flex-end"
1204+
width="100%"
1205+
flexDirection={isReactNative() ? 'column' : 'row'}
1206+
>
1207+
<Button
1208+
variant="secondary"
1209+
onClick={() => setIsOpen(false)}
1210+
isFullWidth={isReactNative()}
1211+
>
1212+
Cancel
1213+
</Button>
1214+
<Button onClick={() => setIsOpen(true)} variant="primary" isFullWidth={isReactNative()}>
1215+
Confirm Action
1216+
</Button>
1217+
</Box>
1218+
</BottomSheetFooter>
1219+
</BottomSheetComponent>
1220+
</BaseBox>
1221+
);
1222+
};
1223+
1224+
export const NonDismissible = NonDismissibleTemplate.bind({});
1225+
NonDismissible.storyName = 'Non-Dismissible BottomSheet';

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

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const _BottomSheet = ({
7575
children,
7676
initialFocusRef,
7777
snapPoints = [0.35, 0.5, 0.85],
78+
isDismissible = true,
7879
zIndex = componentZIndices.bottomSheet,
7980
...dataAnalyticsProps
8081
}: BottomSheetProps): React.ReactElement => {
@@ -216,10 +217,12 @@ const _BottomSheet = ({
216217
}, [setPositionY]);
217218

218219
const close = React.useCallback(() => {
219-
onDismiss?.();
220-
bottomSheetAndDropdownGlue?.onBottomSheetDismiss();
220+
if (isDismissible) {
221+
onDismiss?.();
222+
bottomSheetAndDropdownGlue?.onBottomSheetDismiss?.();
223+
}
221224
returnFocus();
222-
}, [bottomSheetAndDropdownGlue, onDismiss, returnFocus]);
225+
}, [isDismissible, onDismiss, bottomSheetAndDropdownGlue, returnFocus]);
223226

224227
// sync controlled state to our actions
225228
React.useEffect(() => {
@@ -330,10 +333,20 @@ const _BottomSheet = ({
330333

331334
const shouldClose = rawY < lowerestSnap;
332335
if (shouldClose) {
333-
setIsDragging(false);
334-
cancel();
335-
close();
336-
return;
336+
if (isDismissible) {
337+
// Allow closing if dismissible
338+
setIsDragging(false);
339+
cancel();
340+
close();
341+
return;
342+
} else {
343+
// If not dismissible, snap back to first snap point instead of closing
344+
setIsDragging(false);
345+
cancel();
346+
const firstSnapPoint = dimensions.height * snapPoints[0];
347+
setPositionY(firstSnapPoint, true);
348+
return;
349+
}
337350
}
338351

339352
// if we stop dragging assign snap to the nearest point
@@ -420,6 +433,7 @@ const _BottomSheet = ({
420433
bind,
421434
defaultInitialFocusRef,
422435
isHeaderFloating,
436+
isDismissible,
423437
}),
424438
[
425439
isVisible,
@@ -437,6 +451,7 @@ const _BottomSheet = ({
437451
bind,
438452
defaultInitialFocusRef,
439453
isHeaderFloating,
454+
isDismissible,
440455
],
441456
);
442457

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { BottomSheetBackdrop as GorhomBottomSheetBackdrop } from '@gorhom/bottom
44
import { useTheme } from '~components/BladeProvider';
55

66
const BottomSheetBackdrop = (
7-
props: BottomSheetBackdropProps & { zIndex: number },
7+
props: BottomSheetBackdropProps & { zIndex: number; isDismissible: boolean },
88
): React.ReactElement => {
99
const { theme } = useTheme();
1010

@@ -13,7 +13,7 @@ const BottomSheetBackdrop = (
1313
{...props}
1414
appearsOnIndex={0}
1515
disappearsOnIndex={-1}
16-
pressBehavior="close"
16+
pressBehavior={props.isDismissible ? 'close' : 'none'}
1717
opacity={1}
1818
style={[
1919
props.style,

packages/blade/src/components/BottomSheet/BottomSheetBackdrop.web.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ const StyledBottomSheetBackdrop = styled(BaseBox)<{ isOpen: boolean }>(({ theme,
1919
});
2020

2121
const BottomSheetBackdrop = ({ zIndex }: { zIndex: number }): React.ReactElement => {
22-
const { close, isOpen } = useBottomSheetContext();
22+
const { close, isOpen, isDismissible } = useBottomSheetContext();
2323

2424
return (
2525
<StyledBottomSheetBackdrop
2626
{...metaAttribute({ testID: 'bottomsheet-backdrop' })}
2727
onClick={() => {
28-
close();
28+
if (isDismissible) {
29+
close?.();
30+
}
2931
}}
3032
isOpen={isOpen}
3133
opacity={isOpen ? 1 : 0}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const BottomSheetCloseButton = (): React.ReactElement => {
1111
size="large"
1212
icon={CloseIcon}
1313
accessibilityLabel="Close"
14-
onClick={close}
14+
onClick={() => close?.()}
1515
/>
1616
);
1717
};

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

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const BottomSheetEmptyHeader = React.forwardRef<BladeElementRef, BottomSheetEmpt
3838
},
3939
ref,
4040
) => {
41-
const { close, isHeaderFloating } = useBottomSheetContext();
41+
const { close, isDismissible, isHeaderFloating } = useBottomSheetContext();
4242
const webOnlyEventHandlers: Record<string, any> = isReactNative()
4343
? {}
4444
: {
@@ -71,31 +71,33 @@ const BottomSheetEmptyHeader = React.forwardRef<BladeElementRef, BottomSheetEmpt
7171
right="spacing.0"
7272
{...webOnlyEventHandlers}
7373
>
74-
<BaseBox
75-
display="flex"
76-
alignItems="center"
77-
justifyContent="center"
78-
position="absolute"
79-
// the bottomsheet handle has a height of 16px + 4px padding
80-
// we need to make put the close button at 16px from top so adjusting the 4px
81-
// cannot use position=fixed because RN won't support it
82-
top={isHeaderFloating ? 'spacing.0' : makeSpace(-size[4])}
83-
right="spacing.5"
84-
width={makeSize(size[28])}
85-
height={makeSize(size[28])}
86-
flexShrink={0}
87-
backgroundColor="popup.background.subtle"
88-
borderRadius="max"
89-
zIndex={100}
90-
>
91-
<IconButton
92-
ref={ref}
93-
size="large"
94-
icon={CloseIcon}
95-
accessibilityLabel="Close"
96-
onClick={close}
97-
/>
98-
</BaseBox>
74+
{isDismissible && (
75+
<BaseBox
76+
display="flex"
77+
alignItems="center"
78+
justifyContent="center"
79+
position="absolute"
80+
// the bottomsheet handle has a height of 16px + 4px padding
81+
// we need to make put the close button at 16px from top so adjusting the 4px
82+
// cannot use position=fixed because RN won't support it
83+
top={isHeaderFloating ? 'spacing.0' : makeSpace(-size[4])}
84+
right="spacing.5"
85+
width={makeSize(size[28])}
86+
height={makeSize(size[28])}
87+
flexShrink={0}
88+
backgroundColor="popup.background.subtle"
89+
borderRadius="max"
90+
zIndex={100}
91+
>
92+
<IconButton
93+
ref={ref}
94+
size="large"
95+
icon={CloseIcon}
96+
accessibilityLabel="Close"
97+
onClick={() => close?.()}
98+
/>
99+
</BaseBox>
100+
)}
99101
</BaseBox>
100102
);
101103
},

0 commit comments

Comments
 (0)