-
Notifications
You must be signed in to change notification settings - Fork 32
feat(components-native): Replace react-native-modalize with react-native-bottom-sheet for ContentOverlay #2819
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: JOB-140604-install-react-native-bottom-sheet-and-replace-react-native-modalize
Are you sure you want to change the base?
Changes from 24 commits
2e1698b
3c1be3f
d720a5a
bbb6b99
46f90a1
8469b9e
5378443
37f2b9a
5c279b1
d16787d
5c4b7d5
8b904cb
f233256
c6db5e6
de63e76
9fe099c
79638b1
e35d0dc
f67bf9e
3b0c18f
1ccfde7
7668ef0
2d179c7
660fa16
7859ace
969e591
2e3c550
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { buildThemedStyles } from "../AtlantisThemeContext"; | ||
|
|
||
| export const useStyles = buildThemedStyles(tokens => { | ||
| const modalBorderRadius = tokens["radius-larger"]; | ||
|
|
||
| return { | ||
| handle: { | ||
| width: tokens["space-largest"], | ||
| height: tokens["space-smaller"] + tokens["space-smallest"], | ||
| backgroundColor: tokens["color-border"], | ||
| borderRadius: tokens["radius-circle"], | ||
| }, | ||
|
|
||
| backdrop: { | ||
| backgroundColor: tokens["color-overlay"], | ||
| }, | ||
|
|
||
| background: { | ||
| borderTopLeftRadius: modalBorderRadius, | ||
| borderTopRightRadius: modalBorderRadius, | ||
| }, | ||
|
|
||
| modalForLargeScreens: { | ||
| width: 640, | ||
| alignSelf: "center", | ||
| }, | ||
|
|
||
| header: { | ||
| flexDirection: "row", | ||
| backgroundColor: tokens["color-surface"], | ||
| zIndex: tokens["elevation-base"], | ||
| minHeight: tokens["space-extravagant"], | ||
| }, | ||
|
|
||
| headerShadow: { | ||
| ...tokens["shadow-base"], | ||
| }, | ||
|
|
||
| childrenStyle: { | ||
| // We need to explicity lower the zIndex because otherwise, the modal content slides over the header shadow. | ||
| zIndex: -1, | ||
| }, | ||
|
|
||
| dismissButton: { | ||
| alignItems: "center", | ||
| }, | ||
|
|
||
| hiddenContent: { | ||
| opacity: 0, | ||
| }, | ||
|
|
||
| title: { | ||
| flex: 1, | ||
| justifyContent: "center", | ||
| paddingLeft: tokens["space-base"], | ||
| }, | ||
|
|
||
| titleWithoutDismiss: { | ||
| paddingRight: tokens["space-base"], | ||
| }, | ||
|
|
||
| titleWithDismiss: { | ||
| paddingRight: tokens["space-smaller"], | ||
| }, | ||
| }; | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,245 @@ | ||
| import React, { useImperativeHandle, useMemo, useRef, useState } from "react"; | ||
| import { AccessibilityInfo, View, findNodeHandle } from "react-native"; | ||
| import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"; | ||
| import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| import { | ||
| BottomSheetBackdrop, | ||
| BottomSheetModal, | ||
| BottomSheetScrollView, | ||
| BottomSheetView, | ||
| } from "@gorhom/bottom-sheet"; | ||
| import type { | ||
| BottomSheetBackdropProps, | ||
| BottomSheetModal as BottomSheetModalType, | ||
| } from "@gorhom/bottom-sheet"; | ||
| import type { ContentOverlayRebuiltProps, ModalBackgroundColor } from "./types"; | ||
| import { useStyles } from "./ContentOverlay.rebuilt.style"; | ||
| import { useBottomSheetModalBackHandler } from "./hooks/useBottomSheetModalBackHandler"; | ||
| import { useIsScreenReaderEnabled } from "../hooks"; | ||
| import { IconButton } from "../IconButton"; | ||
| import { Heading } from "../Heading"; | ||
| import { useAtlantisI18n } from "../hooks/useAtlantisI18n"; | ||
| import { useAtlantisTheme } from "../AtlantisThemeContext"; | ||
|
|
||
| function getModalBackgroundColor( | ||
| variation: ModalBackgroundColor, | ||
| tokens: ReturnType<typeof useAtlantisTheme>["tokens"], | ||
| ) { | ||
| switch (variation) { | ||
| case "surface": | ||
| return tokens["color-surface"]; | ||
| case "background": | ||
| return tokens["color-surface--background"]; | ||
| } | ||
| } | ||
|
|
||
| // eslint-disable-next-line max-statements | ||
| export function ContentOverlayRebuilt({ | ||
| children, | ||
| title, | ||
| accessibilityLabel, | ||
| fullScreen = false, | ||
| showDismiss = false, | ||
| isDraggable = true, | ||
| adjustToContentHeight = false, | ||
| keyboardShouldPersistTaps = false, | ||
| scrollEnabled = false, | ||
| modalBackgroundColor = "surface", | ||
| onClose, | ||
| onOpen, | ||
| onBeforeExit, | ||
| loading = false, | ||
| ref, | ||
| }: ContentOverlayRebuiltProps) { | ||
| const insets = useSafeAreaInsets(); | ||
| const bottomSheetModalRef = useRef<BottomSheetModalType>(null); | ||
| const previousIndexRef = useRef(-1); | ||
| const [currentPosition, setCurrentPosition] = useState<number>(-1); | ||
|
|
||
| const styles = useStyles(); | ||
| const { t } = useAtlantisI18n(); | ||
| const { tokens } = useAtlantisTheme(); | ||
| const isScreenReaderEnabled = useIsScreenReaderEnabled(); | ||
|
|
||
| const isFullScreenOrTopPosition = | ||
| fullScreen || (!adjustToContentHeight && currentPosition === 0); | ||
| const shouldShowDismiss = | ||
| showDismiss || isScreenReaderEnabled || isFullScreenOrTopPosition; | ||
|
|
||
| const draggable = onBeforeExit ? false : isDraggable; | ||
|
|
||
| const [showHeaderShadow, setShowHeaderShadow] = useState<boolean>(false); | ||
| const overlayHeader = useRef<View>(null); | ||
|
|
||
| // If isDraggable is true, we always want to have a snap point at 100% | ||
| // enableDynamicSizing will add another snap point of the content height | ||
| const snapPoints = useMemo(() => ["100%"], []); | ||
|
|
||
| const onCloseController = () => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Existing implementations of
You would have to exit through the dismiss button, or the Android back button. |
||
| if (!onBeforeExit) { | ||
| bottomSheetModalRef.current?.dismiss(); | ||
| } else { | ||
| onBeforeExit(); | ||
| } | ||
| }; | ||
|
|
||
| const { handleSheetPositionChange } = | ||
| useBottomSheetModalBackHandler(onCloseController); | ||
|
|
||
| useImperativeHandle(ref, () => ({ | ||
| open: () => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Map to the existing API to open and close |
||
| bottomSheetModalRef.current?.present(); | ||
| }, | ||
| close: () => { | ||
| bottomSheetModalRef.current?.dismiss(); | ||
| }, | ||
| })); | ||
|
|
||
| const handleChange = (index: number, position: number) => { | ||
| const previousIndex = previousIndexRef.current; | ||
|
|
||
| setCurrentPosition(position); | ||
| handleSheetPositionChange(index); | ||
|
|
||
| if (previousIndex === -1 && index >= 0) { | ||
| // Transitioned from closed to open | ||
| onOpen?.(); | ||
|
|
||
| // Set accessibility focus on header when opened | ||
| if (overlayHeader.current) { | ||
| const reactTag = findNodeHandle(overlayHeader.current); | ||
|
|
||
| if (reactTag) { | ||
| AccessibilityInfo.setAccessibilityFocus(reactTag); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't actually had the chance to test if this is working the way we intend it to. This was brought over from the existing implementation |
||
| } | ||
| } | ||
| } else if (previousIndex >= 0 && index === -1) { | ||
| // Transitioned from open to closed | ||
| onClose?.(); | ||
| } | ||
|
|
||
| previousIndexRef.current = index; | ||
| }; | ||
|
|
||
| const handleOnScroll = ({ | ||
| nativeEvent, | ||
| }: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| setShowHeaderShadow(nativeEvent.contentOffset.y > 0); | ||
| }; | ||
|
|
||
| const renderHeader = () => { | ||
| const closeOverlayA11YLabel = t("ContentOverlay.close", { | ||
| title: title, | ||
| }); | ||
|
|
||
| const headerStyles = [ | ||
| styles.header, | ||
| showHeaderShadow && styles.headerShadow, | ||
| { | ||
| backgroundColor: getModalBackgroundColor(modalBackgroundColor, tokens), | ||
| }, | ||
| ]; | ||
|
|
||
| return ( | ||
| <View testID="ATL-Overlay-Header"> | ||
| <View style={headerStyles}> | ||
| <View | ||
| style={[ | ||
| styles.title, | ||
| shouldShowDismiss | ||
| ? styles.titleWithDismiss | ||
| : styles.titleWithoutDismiss, | ||
| ]} | ||
| > | ||
| <Heading | ||
| level="subtitle" | ||
| variation={loading ? "subdued" : "heading"} | ||
| align={"start"} | ||
| > | ||
| {title} | ||
| </Heading> | ||
| </View> | ||
|
|
||
| {shouldShowDismiss && ( | ||
| <View | ||
| style={styles.dismissButton} | ||
| ref={overlayHeader} | ||
| accessibilityLabel={accessibilityLabel || closeOverlayA11YLabel} | ||
| accessible={true} | ||
| > | ||
| <IconButton | ||
| name="cross" | ||
| customColor={ | ||
| loading ? tokens["color-disabled"] : tokens["color-heading"] | ||
| } | ||
| onPress={() => onCloseController()} | ||
| accessibilityLabel={closeOverlayA11YLabel} | ||
| testID="ATL-Overlay-CloseButton" | ||
| /> | ||
| </View> | ||
| )} | ||
| </View> | ||
| </View> | ||
| ); | ||
| }; | ||
|
|
||
| return ( | ||
| <BottomSheetModal | ||
| ref={bottomSheetModalRef} | ||
| onChange={handleChange} | ||
| backgroundStyle={styles.background} | ||
| handleIndicatorStyle={styles.handle} | ||
| handleComponent={draggable ? undefined : null} | ||
| backdropComponent={props => ( | ||
| <Backdrop {...props} pressBehavior={onBeforeExit ? "none" : "close"} /> | ||
| )} | ||
| name="content-overlay-rebuilt" | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Official documentation states that the name prop is "Modal name to help identify the modal for later on." I am not sure how it could be used or if it even is applicable to how we use ContentOverlay |
||
| snapPoints={snapPoints} | ||
| enablePanDownToClose={draggable} | ||
| enableContentPanningGesture={draggable} | ||
| enableDynamicSizing={!fullScreen} | ||
| topInset={insets.top} | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| > | ||
| {scrollEnabled ? ( | ||
| <BottomSheetScrollView | ||
| style={styles.background} | ||
| contentContainerStyle={{ paddingBottom: insets.bottom }} | ||
| keyboardShouldPersistTaps={ | ||
| keyboardShouldPersistTaps ? "handled" : "never" | ||
| } | ||
| showsVerticalScrollIndicator={false} | ||
| onScroll={handleOnScroll} | ||
| stickyHeaderIndices={[0]} | ||
| > | ||
| {renderHeader()} | ||
| {children} | ||
| </BottomSheetScrollView> | ||
| ) : ( | ||
| <BottomSheetView style={styles.background}> | ||
| {renderHeader()} | ||
| <View style={{ paddingBottom: insets.bottom }}>{children}</View> | ||
| </BottomSheetView> | ||
| )} | ||
| </BottomSheetModal> | ||
| ); | ||
| } | ||
|
|
||
| function Backdrop( | ||
| bottomSheetBackdropProps: BottomSheetBackdropProps & { | ||
| pressBehavior: "none" | "close"; | ||
| }, | ||
| ) { | ||
| const styles = useStyles(); | ||
| const { pressBehavior, ...props } = bottomSheetBackdropProps; | ||
|
|
||
| return ( | ||
| <BottomSheetBackdrop | ||
| {...props} | ||
| appearsOnIndex={0} | ||
| disappearsOnIndex={-1} | ||
| style={styles.backdrop} | ||
| opacity={1} | ||
| pressBehavior={pressBehavior} | ||
| /> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import React, { useRef } from "react"; | ||
| import type { Meta, StoryObj } from "@storybook/react-native-web-vite"; | ||
| import { View } from "react-native"; | ||
| import { SafeAreaProvider } from "react-native-safe-area-context"; | ||
| import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; | ||
| import { Button, Heading, Text } from "@jobber/components-native"; | ||
| import { ContentOverlayRebuilt } from "./ContentOverlay.rebuilt"; | ||
| import type { ContentOverlayRebuiltRef } from "./types"; | ||
|
|
||
| const meta = { | ||
| title: "Components/Overlays/ContentOverlay", | ||
| component: ContentOverlayRebuilt, | ||
| } satisfies Meta<typeof ContentOverlayRebuilt>; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| const BasicTemplate = () => { | ||
| const contentOverlayRef = useRef<ContentOverlayRebuiltRef>(null); | ||
|
|
||
| const openContentOverlay = () => { | ||
| contentOverlayRef.current?.open?.(); | ||
| }; | ||
|
|
||
| const closeContentOverlay = () => { | ||
| contentOverlayRef.current?.close?.(); | ||
| }; | ||
|
|
||
| return ( | ||
| <SafeAreaProvider> | ||
| <BottomSheetModalProvider> | ||
| <View style={{ display: "flex", flexDirection: "column", gap: 16 }}> | ||
| <Heading>Basic ContentOverlay</Heading> | ||
| <Text> | ||
| Note that due to the differences between React Native Web and React | ||
| Native, this does not render 100% properly | ||
| </Text> | ||
| <Button label="Open Content Overlay" onPress={openContentOverlay} /> | ||
| <Button label="Close Content Overlay" onPress={closeContentOverlay} /> | ||
| </View> | ||
| <ContentOverlayRebuilt | ||
| ref={contentOverlayRef} | ||
| title="Content Overlay Title" | ||
| onClose={() => console.log("closed content overlay")} | ||
| onOpen={() => console.log("opened content overlay")} | ||
| > | ||
| <View style={{ padding: 16 }}> | ||
| <Text>This is the content inside the overlay.</Text> | ||
| </View> | ||
| </ContentOverlayRebuilt> | ||
| </BottomSheetModalProvider> | ||
| </SafeAreaProvider> | ||
| ); | ||
| }; | ||
|
|
||
| export const Basic: Story = { | ||
| render: BasicTemplate, | ||
| args: {} as Story["args"], | ||
| }; |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The old implementation had a lot of logic here, but it's significantly simplified here due to how the library handles snap points. From what I can tell, we want to always provide the ability to snap to 100% when
isDraggableis true.So we will always have 100% in
snapPoints.When
enableDynamicSizingis true, it automatically adds another snap point of the content height tosnapPoints. For example,[1000]becomes[400, 1000].