diff options
Diffstat (limited to 'src/components/Dialog')
-rw-r--r-- | src/components/Dialog/context.ts | 28 | ||||
-rw-r--r-- | src/components/Dialog/index.tsx | 229 | ||||
-rw-r--r-- | src/components/Dialog/index.web.tsx | 67 | ||||
-rw-r--r-- | src/components/Dialog/types.ts | 62 |
4 files changed, 262 insertions, 124 deletions
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index b28b9f5a2..859f8edd7 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -1,7 +1,11 @@ import React from 'react' import {useDialogStateContext} from '#/state/dialogs' -import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types' +import { + DialogContextProps, + DialogControlRefProps, + DialogOuterProps, +} from '#/components/Dialog/types' export const Context = React.createContext<DialogContextProps>({ close: () => {}, @@ -11,9 +15,9 @@ export function useDialogContext() { return React.useContext(Context) } -export function useDialogControl() { +export function useDialogControl(): DialogOuterProps['control'] { const id = React.useId() - const control = React.useRef<DialogControlProps>({ + const control = React.useRef<DialogControlRefProps>({ open: () => {}, close: () => {}, }) @@ -27,9 +31,17 @@ export function useDialogControl() { } }, [id, activeDialogs]) - return { - ref: control, - open: () => control.current.open(), - close: () => control.current.close(), - } + return React.useMemo<DialogOuterProps['control']>( + () => ({ + id, + ref: control, + open: () => { + control.current.open() + }, + close: cb => { + control.current.close(cb) + }, + }), + [id, control], + ) } diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 9132e68de..07e101f85 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -1,46 +1,114 @@ import React, {useImperativeHandle} from 'react' -import {View, Dimensions} from 'react-native' +import {Dimensions, Pressable, View} from 'react-native' +import Animated, {useAnimatedStyle} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import BottomSheet, { - BottomSheetBackdrop, + BottomSheetBackdropProps, BottomSheetScrollView, + BottomSheetScrollViewMethods, BottomSheetTextInput, BottomSheetView, -} from '@gorhom/bottom-sheet' -import {useSafeAreaInsets} from 'react-native-safe-area-context' - -import {useTheme, atoms as a} from '#/alf' -import {Portal} from '#/components/Portal' -import {createInput} from '#/components/forms/TextField' + useBottomSheet, + WINDOW_HEIGHT, +} from '@discord/bottom-sheet/src' +import {logger} from '#/logger' +import {useDialogStateControlContext} from '#/state/dialogs' +import {isNative} from 'platform/detection' +import {atoms as a, flatten, useTheme} from '#/alf' +import {Context} from '#/components/Dialog/context' import { - DialogOuterProps, DialogControlProps, DialogInnerProps, + DialogOuterProps, } from '#/components/Dialog/types' -import {Context} from '#/components/Dialog/context' +import {createInput} from '#/components/forms/TextField' +import {Portal} from '#/components/Portal' -export {useDialogControl, useDialogContext} from '#/components/Dialog/context' +export {useDialogContext, useDialogControl} from '#/components/Dialog/context' export * from '#/components/Dialog/types' // @ts-ignore export const Input = createInput(BottomSheetTextInput) +function Backdrop(props: BottomSheetBackdropProps) { + const t = useTheme() + const bottomSheet = useBottomSheet() + + const animatedStyle = useAnimatedStyle(() => { + const opacity = + (Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000 + + return { + opacity: Math.min(Math.max(opacity, 0), 0.55), + } + }) + + const onPress = React.useCallback(() => { + bottomSheet.close() + }, [bottomSheet]) + + return ( + <Animated.View + style={[ + t.atoms.bg_contrast_300, + { + top: 0, + left: 0, + right: 0, + bottom: 0, + position: 'absolute', + }, + animatedStyle, + ]}> + <Pressable + accessibilityRole="button" + accessibilityLabel="Dialog backdrop" + accessibilityHint="Press the backdrop to close the dialog" + style={{flex: 1}} + onPress={onPress} + /> + </Animated.View> + ) +} + export function Outer({ children, control, onClose, nativeOptions, + testID, }: React.PropsWithChildren<DialogOuterProps>) { const t = useTheme() const sheet = React.useRef<BottomSheet>(null) const sheetOptions = nativeOptions?.sheet || {} const hasSnapPoints = !!sheetOptions.snapPoints const insets = useSafeAreaInsets() + const closeCallback = React.useRef<() => void>() + const {setDialogIsOpen} = useDialogStateControlContext() - const open = React.useCallback<DialogControlProps['open']>((i = 0) => { - sheet.current?.snapToIndex(i) - }, []) + /* + * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet` + */ + const [openIndex, setOpenIndex] = React.useState(-1) + + /* + * `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open. + */ + const isOpen = openIndex > -1 - const close = React.useCallback(() => { + const open = React.useCallback<DialogControlProps['open']>( + ({index} = {}) => { + setDialogIsOpen(control.id, true) + // can be set to any index of `snapPoints`, but `0` is the first i.e. "open" + setOpenIndex(index || 0) + }, + [setOpenIndex, setDialogIsOpen, control.id], + ) + + const close = React.useCallback<DialogControlProps['close']>(cb => { + if (cb && typeof cb === 'function') { + closeCallback.current = cb + } sheet.current?.close() }, []) @@ -53,103 +121,120 @@ export function Outer({ [open, close], ) - const onChange = React.useCallback( - (index: number) => { - if (index === -1) { - onClose?.() - } - }, - [onClose], - ) + const onCloseInner = React.useCallback(() => { + try { + closeCallback.current?.() + } catch (e: any) { + logger.error(`Dialog closeCallback failed`, { + message: e.message, + }) + } finally { + closeCallback.current = undefined + } + setDialogIsOpen(control.id, false) + onClose?.() + setOpenIndex(-1) + }, [control.id, onClose, setDialogIsOpen]) const context = React.useMemo(() => ({close}), [close]) return ( - <Portal> - <BottomSheet - enableDynamicSizing={!hasSnapPoints} - enablePanDownToClose - keyboardBehavior="interactive" - android_keyboardInputMode="adjustResize" - keyboardBlurBehavior="restore" - topInset={insets.top} - {...sheetOptions} - ref={sheet} - index={-1} - backgroundStyle={{backgroundColor: 'transparent'}} - backdropComponent={props => ( - <BottomSheetBackdrop - opacity={0.4} - appearsOnIndex={0} - disappearsOnIndex={-1} - {...props} - /> - )} - handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} - handleStyle={{display: 'none'}} - onChange={onChange}> - <Context.Provider value={context}> - <View - style={[ - a.absolute, - a.inset_0, - t.atoms.bg, - { - borderTopLeftRadius: 40, - borderTopRightRadius: 40, - height: Dimensions.get('window').height * 2, - }, - ]} - /> - {children} - </Context.Provider> - </BottomSheet> - </Portal> + isOpen && ( + <Portal> + <View + // iOS + accessibilityViewIsModal + // Android + importantForAccessibility="yes" + style={[a.absolute, a.inset_0]} + testID={testID}> + <BottomSheet + enableDynamicSizing={!hasSnapPoints} + enablePanDownToClose + keyboardBehavior="interactive" + android_keyboardInputMode="adjustResize" + keyboardBlurBehavior="restore" + topInset={insets.top} + {...sheetOptions} + snapPoints={sheetOptions.snapPoints || ['100%']} + ref={sheet} + index={openIndex} + backgroundStyle={{backgroundColor: 'transparent'}} + backdropComponent={Backdrop} + handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} + handleStyle={{display: 'none'}} + onClose={onCloseInner}> + <Context.Provider value={context}> + <View + style={[ + a.absolute, + a.inset_0, + t.atoms.bg, + { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + height: Dimensions.get('window').height * 2, + }, + ]} + /> + {children} + </Context.Provider> + </BottomSheet> + </View> + </Portal> + ) ) } -// TODO a11y props here, or is that handled by the sheet? -export function Inner(props: DialogInnerProps) { +export function Inner({children, style}: DialogInnerProps) { const insets = useSafeAreaInsets() return ( <BottomSheetView style={[ - a.p_lg, + a.p_xl, { paddingTop: 40, borderTopLeftRadius: 40, borderTopRightRadius: 40, paddingBottom: insets.bottom + a.pb_5xl.paddingBottom, }, + flatten(style), ]}> - {props.children} + {children} </BottomSheetView> ) } -export function ScrollableInner(props: DialogInnerProps) { +export const ScrollableInner = React.forwardRef< + BottomSheetScrollViewMethods, + DialogInnerProps +>(function ScrollableInner({children, style}, ref) { const insets = useSafeAreaInsets() return ( <BottomSheetScrollView keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" style={[ a.flex_1, // main diff is this a.p_xl, + a.h_full, { paddingTop: 40, borderTopLeftRadius: 40, borderTopRightRadius: 40, }, - ]}> - {props.children} + flatten(style), + ]} + contentContainerStyle={isNative ? a.pb_4xl : undefined} + ref={ref}> + {children} <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> </BottomSheetScrollView> ) -} +}) export function Handle() { const t = useTheme() + return ( <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}> <View diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 305c00e97..038f6295a 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -5,11 +5,14 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useTheme, atoms as a, useBreakpoints, web} from '#/alf' +import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf' import {Portal} from '#/components/Portal' import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types' import {Context} from '#/components/Dialog/context' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {useDialogStateControlContext} from '#/state/dialogs' export {useDialogControl, useDialogContext} from '#/components/Dialog/context' export * from '#/components/Dialog/types' @@ -18,27 +21,30 @@ export {Input} from '#/components/forms/TextField' const stopPropagation = (e: any) => e.stopPropagation() export function Outer({ + children, control, onClose, - children, }: React.PropsWithChildren<DialogOuterProps>) { const {_} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() const [isOpen, setIsOpen] = React.useState(false) const [isVisible, setIsVisible] = React.useState(true) + const {setDialogIsOpen} = useDialogStateControlContext() const open = React.useCallback(() => { setIsOpen(true) - }, [setIsOpen]) + setDialogIsOpen(control.id, true) + }, [setIsOpen, setDialogIsOpen, control.id]) const close = React.useCallback(async () => { setIsVisible(false) await new Promise(resolve => setTimeout(resolve, 150)) setIsOpen(false) setIsVisible(true) + setDialogIsOpen(control.id, false) onClose?.() - }, [onClose, setIsOpen]) + }, [onClose, setIsOpen, setDialogIsOpen, control.id]) useImperativeHandle( control.ref, @@ -93,7 +99,7 @@ export function Outer({ style={[ web(a.fixed), a.inset_0, - {opacity: 0.5, backgroundColor: t.palette.black}, + {opacity: 0.8, backgroundColor: t.palette.black}, ]} /> )} @@ -147,7 +153,7 @@ export function Inner({ a.rounded_md, a.w_full, a.border, - gtMobile ? a.p_xl : a.p_lg, + gtMobile ? a.p_2xl : a.p_xl, t.atoms.bg, { maxWidth: 600, @@ -156,7 +162,7 @@ export function Inner({ shadowOpacity: t.name === 'light' ? 0.1 : 0.4, shadowRadius: 30, }, - ...(Array.isArray(style) ? style : [style || {}]), + flatten(style), ]}> {children} </Animated.View> @@ -170,25 +176,28 @@ export function Handle() { return null } -/** - * TODO(eric) unused rn - */ -// export function Close() { -// const {_} = useLingui() -// const t = useTheme() -// const {close} = useDialogContext() -// return ( -// <View -// style={[ -// a.absolute, -// a.z_10, -// { -// top: a.pt_lg.paddingTop, -// right: a.pr_lg.paddingRight, -// }, -// ]}> -// <Button onPress={close} label={_(msg`Close active dialog`)}> -// </Button> -// </View> -// ) -// } +export function Close() { + const {_} = useLingui() + const {close} = React.useContext(Context) + return ( + <View + style={[ + a.absolute, + a.z_10, + { + top: a.pt_md.paddingTop, + right: a.pr_md.paddingRight, + }, + ]}> + <Button + size="small" + variant="ghost" + color="secondary" + shape="round" + onPress={() => close()} + label={_(msg`Close active dialog`)}> + <ButtonIcon icon={X} size="md" /> + </Button> + </View> + ) +} diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index d36784183..1ddab02ee 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -1,43 +1,75 @@ import React from 'react' -import type {ViewStyle, AccessibilityProps} from 'react-native' -import {BottomSheetProps} from '@gorhom/bottom-sheet' +import type { + AccessibilityProps, + GestureResponderEvent, + ScrollViewProps, +} from 'react-native' +import {BottomSheetProps} from '@discord/bottom-sheet/src' + +import {ViewStyleProp} from '#/alf' type A11yProps = Required<AccessibilityProps> +/** + * Mutated by useImperativeHandle to provide a public API for controlling the + * dialog. The methods here will actually become the handlers defined within + * the `Dialog.Outer` component. + * + * `Partial<GestureResponderEvent>` here allows us to add this directly to the + * `onPress` prop of a button, for example. If this type was not added, we + * would need to create a function to wrap `.open()` with. + */ +export type DialogControlRefProps = { + open: ( + options?: DialogControlOpenOptions & Partial<GestureResponderEvent>, + ) => void + close: (callback?: () => void) => void +} + +/** + * The return type of the useDialogControl hook. + */ +export type DialogControlProps = DialogControlRefProps & { + id: string + ref: React.RefObject<DialogControlRefProps> + isOpen?: boolean +} + export type DialogContextProps = { - close: () => void + close: DialogControlProps['close'] } -export type DialogControlProps = { - open: (index?: number) => void - close: () => void +export type DialogControlOpenOptions = { + /** + * NATIVE ONLY + * + * Optional index of the snap point to open the bottom sheet to. Defaults to + * 0, which is the first snap point (i.e. "open"). + */ + index?: number } export type DialogOuterProps = { - control: { - ref: React.RefObject<DialogControlProps> - open: (index?: number) => void - close: () => void - } + control: DialogControlProps onClose?: () => void nativeOptions?: { sheet?: Omit<BottomSheetProps, 'children'> } webOptions?: {} + testID?: string } -type DialogInnerPropsBase<T> = React.PropsWithChildren<{ - style?: ViewStyle -}> & - T +type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T export type DialogInnerProps = | DialogInnerPropsBase<{ label?: undefined accessibilityLabelledBy: A11yProps['aria-labelledby'] accessibilityDescribedBy: string + keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] }> | DialogInnerPropsBase<{ label: string accessibilityLabelledBy?: undefined accessibilityDescribedBy?: undefined + keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] }> |