about summary refs log tree commit diff
path: root/src/components/Dialog
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/Dialog')
-rw-r--r--src/components/Dialog/context.ts35
-rw-r--r--src/components/Dialog/index.tsx162
-rw-r--r--src/components/Dialog/index.web.tsx194
-rw-r--r--src/components/Dialog/types.ts43
4 files changed, 434 insertions, 0 deletions
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
new file mode 100644
index 000000000..b28b9f5a2
--- /dev/null
+++ b/src/components/Dialog/context.ts
@@ -0,0 +1,35 @@
+import React from 'react'
+
+import {useDialogStateContext} from '#/state/dialogs'
+import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
+
+export const Context = React.createContext<DialogContextProps>({
+  close: () => {},
+})
+
+export function useDialogContext() {
+  return React.useContext(Context)
+}
+
+export function useDialogControl() {
+  const id = React.useId()
+  const control = React.useRef<DialogControlProps>({
+    open: () => {},
+    close: () => {},
+  })
+  const {activeDialogs} = useDialogStateContext()
+
+  React.useEffect(() => {
+    activeDialogs.current.set(id, control)
+    return () => {
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+      activeDialogs.current.delete(id)
+    }
+  }, [id, activeDialogs])
+
+  return {
+    ref: control,
+    open: () => control.current.open(),
+    close: () => control.current.close(),
+  }
+}
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
new file mode 100644
index 000000000..44e4dc8a7
--- /dev/null
+++ b/src/components/Dialog/index.tsx
@@ -0,0 +1,162 @@
+import React, {useImperativeHandle} from 'react'
+import {View, Dimensions} from 'react-native'
+import BottomSheet, {
+  BottomSheetBackdrop,
+  BottomSheetScrollView,
+  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'
+
+import {
+  DialogOuterProps,
+  DialogControlProps,
+  DialogInnerProps,
+} from '#/components/Dialog/types'
+import {Context} from '#/components/Dialog/context'
+
+export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
+export * from '#/components/Dialog/types'
+// @ts-ignore
+export const Input = createInput(BottomSheetTextInput)
+
+export function Outer({
+  children,
+  control,
+  onClose,
+  nativeOptions,
+}: React.PropsWithChildren<DialogOuterProps>) {
+  const t = useTheme()
+  const sheet = React.useRef<BottomSheet>(null)
+  const sheetOptions = nativeOptions?.sheet || {}
+  const hasSnapPoints = !!sheetOptions.snapPoints
+
+  const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
+    sheet.current?.snapToIndex(i)
+  }, [])
+
+  const close = React.useCallback(() => {
+    sheet.current?.close()
+    onClose?.()
+  }, [onClose])
+
+  useImperativeHandle(
+    control.ref,
+    () => ({
+      open,
+      close,
+    }),
+    [open, close],
+  )
+
+  const context = React.useMemo(() => ({close}), [close])
+
+  return (
+    <Portal>
+      <BottomSheet
+        enableDynamicSizing={!hasSnapPoints}
+        enablePanDownToClose
+        keyboardBehavior="interactive"
+        android_keyboardInputMode="adjustResize"
+        keyboardBlurBehavior="restore"
+        {...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'}}
+        onClose={onClose}>
+        <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>
+  )
+}
+
+// TODO a11y props here, or is that handled by the sheet?
+export function Inner(props: DialogInnerProps) {
+  const insets = useSafeAreaInsets()
+  return (
+    <BottomSheetView
+      style={[
+        a.p_lg,
+        a.pt_3xl,
+        {
+          borderTopLeftRadius: 40,
+          borderTopRightRadius: 40,
+          paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
+        },
+      ]}>
+      {props.children}
+    </BottomSheetView>
+  )
+}
+
+export function ScrollableInner(props: DialogInnerProps) {
+  const insets = useSafeAreaInsets()
+  return (
+    <BottomSheetScrollView
+      style={[
+        a.flex_1, // main diff is this
+        a.p_lg,
+        a.pt_3xl,
+        {
+          borderTopLeftRadius: 40,
+          borderTopRightRadius: 40,
+        },
+      ]}>
+      {props.children}
+      <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
+    </BottomSheetScrollView>
+  )
+}
+
+export function Handle() {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.absolute,
+        a.rounded_sm,
+        a.z_10,
+        {
+          top: a.pt_lg.paddingTop,
+          width: 35,
+          height: 4,
+          alignSelf: 'center',
+          backgroundColor: t.palette.contrast_900,
+          opacity: 0.5,
+        },
+      ]}
+    />
+  )
+}
+
+export function Close() {
+  return null
+}
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
new file mode 100644
index 000000000..305c00e97
--- /dev/null
+++ b/src/components/Dialog/index.web.tsx
@@ -0,0 +1,194 @@
+import React, {useImperativeHandle} from 'react'
+import {View, TouchableWithoutFeedback} from 'react-native'
+import {FocusScope} from '@tamagui/focus-scope'
+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 {Portal} from '#/components/Portal'
+
+import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
+import {Context} from '#/components/Dialog/context'
+
+export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
+export * from '#/components/Dialog/types'
+export {Input} from '#/components/forms/TextField'
+
+const stopPropagation = (e: any) => e.stopPropagation()
+
+export function Outer({
+  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 open = React.useCallback(() => {
+    setIsOpen(true)
+  }, [setIsOpen])
+
+  const close = React.useCallback(async () => {
+    setIsVisible(false)
+    await new Promise(resolve => setTimeout(resolve, 150))
+    setIsOpen(false)
+    setIsVisible(true)
+    onClose?.()
+  }, [onClose, setIsOpen])
+
+  useImperativeHandle(
+    control.ref,
+    () => ({
+      open,
+      close,
+    }),
+    [open, close],
+  )
+
+  React.useEffect(() => {
+    if (!isOpen) return
+
+    function handler(e: KeyboardEvent) {
+      if (e.key === 'Escape') close()
+    }
+
+    document.addEventListener('keydown', handler)
+
+    return () => document.removeEventListener('keydown', handler)
+  }, [isOpen, close])
+
+  const context = React.useMemo(
+    () => ({
+      close,
+    }),
+    [close],
+  )
+
+  return (
+    <>
+      {isOpen && (
+        <Portal>
+          <Context.Provider value={context}>
+            <TouchableWithoutFeedback
+              accessibilityHint={undefined}
+              accessibilityLabel={_(msg`Close active dialog`)}
+              onPress={close}>
+              <View
+                style={[
+                  web(a.fixed),
+                  a.inset_0,
+                  a.z_10,
+                  a.align_center,
+                  gtMobile ? a.p_lg : a.p_md,
+                  {overflowY: 'auto'},
+                ]}>
+                {isVisible && (
+                  <Animated.View
+                    entering={FadeIn.duration(150)}
+                    // exiting={FadeOut.duration(150)}
+                    style={[
+                      web(a.fixed),
+                      a.inset_0,
+                      {opacity: 0.5, backgroundColor: t.palette.black},
+                    ]}
+                  />
+                )}
+
+                <View
+                  style={[
+                    a.w_full,
+                    a.z_20,
+                    a.justify_center,
+                    a.align_center,
+                    {
+                      minHeight: web('calc(90vh - 36px)') || undefined,
+                    },
+                  ]}>
+                  {isVisible ? children : null}
+                </View>
+              </View>
+            </TouchableWithoutFeedback>
+          </Context.Provider>
+        </Portal>
+      )}
+    </>
+  )
+}
+
+export function Inner({
+  children,
+  style,
+  label,
+  accessibilityLabelledBy,
+  accessibilityDescribedBy,
+}: DialogInnerProps) {
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  return (
+    <FocusScope loop enabled trapped>
+      <Animated.View
+        role="dialog"
+        aria-role="dialog"
+        aria-label={label}
+        aria-labelledby={accessibilityLabelledBy}
+        aria-describedby={accessibilityDescribedBy}
+        // @ts-ignore web only -prf
+        onClick={stopPropagation}
+        onStartShouldSetResponder={_ => true}
+        onTouchEnd={stopPropagation}
+        entering={FadeInDown.duration(100)}
+        // exiting={FadeOut.duration(100)}
+        style={[
+          a.relative,
+          a.rounded_md,
+          a.w_full,
+          a.border,
+          gtMobile ? a.p_xl : a.p_lg,
+          t.atoms.bg,
+          {
+            maxWidth: 600,
+            borderColor: t.palette.contrast_200,
+            shadowColor: t.palette.black,
+            shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
+            shadowRadius: 30,
+          },
+          ...(Array.isArray(style) ? style : [style || {}]),
+        ]}>
+        {children}
+      </Animated.View>
+    </FocusScope>
+  )
+}
+
+export const ScrollableInner = Inner
+
+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>
+//   )
+// }
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
new file mode 100644
index 000000000..d36784183
--- /dev/null
+++ b/src/components/Dialog/types.ts
@@ -0,0 +1,43 @@
+import React from 'react'
+import type {ViewStyle, AccessibilityProps} from 'react-native'
+import {BottomSheetProps} from '@gorhom/bottom-sheet'
+
+type A11yProps = Required<AccessibilityProps>
+
+export type DialogContextProps = {
+  close: () => void
+}
+
+export type DialogControlProps = {
+  open: (index?: number) => void
+  close: () => void
+}
+
+export type DialogOuterProps = {
+  control: {
+    ref: React.RefObject<DialogControlProps>
+    open: (index?: number) => void
+    close: () => void
+  }
+  onClose?: () => void
+  nativeOptions?: {
+    sheet?: Omit<BottomSheetProps, 'children'>
+  }
+  webOptions?: {}
+}
+
+type DialogInnerPropsBase<T> = React.PropsWithChildren<{
+  style?: ViewStyle
+}> &
+  T
+export type DialogInnerProps =
+  | DialogInnerPropsBase<{
+      label?: undefined
+      accessibilityLabelledBy: A11yProps['aria-labelledby']
+      accessibilityDescribedBy: string
+    }>
+  | DialogInnerPropsBase<{
+      label: string
+      accessibilityLabelledBy?: undefined
+      accessibilityDescribedBy?: undefined
+    }>