about summary refs log tree commit diff
path: root/src/view/com/util/forms
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util/forms')
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx125
-rw-r--r--src/view/com/util/forms/NativeDropdown.tsx250
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx148
3 files changed, 406 insertions, 117 deletions
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index 046610b29..a1ee3d589 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -14,14 +14,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../text/Text'
 import {Button, ButtonType} from './Button'
 import {colors} from 'lib/styles'
-import {toShareUrl} from 'lib/strings/url-helpers'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
-import {isWeb} from 'platform/detection'
-import {shareUrl} from 'lib/sharing'
+import {HITSLOP_10} from 'lib/constants'
 
-const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 const ESTIMATED_BTN_HEIGHT = 50
 const ESTIMATED_SEP_HEIGHT = 16
 const ESTIMATED_HEADING_HEIGHT = 60
@@ -140,7 +136,7 @@ export function DropdownButton({
         testID={testID}
         style={style}
         onPress={onPress}
-        hitSlop={HITSLOP}
+        hitSlop={HITSLOP_10}
         ref={ref1}
         accessibilityRole="button"
         accessibilityLabel={accessibilityLabel || `Opens ${numItems} options`}
@@ -163,112 +159,6 @@ export function DropdownButton({
   )
 }
 
-export function PostDropdownBtn({
-  testID,
-  style,
-  children,
-  itemUri,
-  itemCid,
-  itemHref,
-  isAuthor,
-  isThreadMuted,
-  onCopyPostText,
-  onOpenTranslate,
-  onToggleThreadMute,
-  onDeletePost,
-}: {
-  testID?: string
-  style?: StyleProp<ViewStyle>
-  children?: React.ReactNode
-  itemUri: string
-  itemCid: string
-  itemHref: string
-  itemTitle: string
-  isAuthor: boolean
-  isThreadMuted: boolean
-  onCopyPostText: () => void
-  onOpenTranslate: () => void
-  onToggleThreadMute: () => void
-  onDeletePost: () => void
-}) {
-  const store = useStores()
-
-  const dropdownItems: DropdownItem[] = [
-    {
-      testID: 'postDropdownTranslateBtn',
-      icon: 'language',
-      label: 'Translate...',
-      onPress() {
-        onOpenTranslate()
-      },
-    },
-    {
-      testID: 'postDropdownCopyTextBtn',
-      icon: ['far', 'paste'],
-      label: 'Copy post text',
-      onPress() {
-        onCopyPostText()
-      },
-    },
-    {
-      testID: 'postDropdownShareBtn',
-      icon: 'share',
-      label: 'Share...',
-      onPress() {
-        const url = toShareUrl(itemHref)
-        shareUrl(url)
-      },
-    },
-    {sep: true},
-    {
-      testID: 'postDropdownMuteThreadBtn',
-      icon: 'comment-slash',
-      label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
-      onPress() {
-        onToggleThreadMute()
-      },
-    },
-    {sep: true},
-    !isAuthor && {
-      testID: 'postDropdownReportBtn',
-      icon: 'circle-exclamation',
-      label: 'Report post',
-      onPress() {
-        store.shell.openModal({
-          name: 'report-post',
-          postUri: itemUri,
-          postCid: itemCid,
-        })
-      },
-    },
-    isAuthor && {
-      testID: 'postDropdownDeleteBtn',
-      icon: ['far', 'trash-can'],
-      label: 'Delete post',
-      onPress() {
-        store.shell.openModal({
-          name: 'confirm',
-          title: 'Delete this post?',
-          message: 'Are you sure? This can not be undone.',
-          onPressConfirm: onDeletePost,
-        })
-      },
-    },
-  ].filter(Boolean) as DropdownItem[]
-
-  return (
-    <DropdownButton
-      testID={testID}
-      style={style}
-      items={dropdownItems}
-      menuWidth={isWeb ? 220 : 200}
-      accessibilityLabel="Additional post actions"
-      accessibilityHint="">
-      {children}
-    </DropdownButton>
-  )
-}
-
 function createDropdownMenu(
   x: number,
   y: number,
@@ -324,15 +214,16 @@ const DropdownItems = ({
 
   const numItems = items.filter(isBtn).length
 
+  // TODO: Refactor dropdown components to:
+  // - (On web, if not handled by React Native) use semantic <select />
+  // and <option /> elements for keyboard navigation out of the box
+  // - (On mobile) be buttons by default, accept `label` and `nativeID`
+  // props, and always have an explicit label
   return (
     <>
+      {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */}
       <TouchableWithoutFeedback
         onPress={onOuterPress}
-        // TODO: Refactor dropdown components to:
-        // - (On web, if not handled by React Native) use semantic <select />
-        // and <option /> elements for keyboard navigation out of the box
-        // - (On mobile) be buttons by default, accept `label` and `nativeID`
-        // props, and always have an explicit label
         accessibilityRole="button"
         accessibilityLabel="Toggle dropdown"
         accessibilityHint="">
diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx
new file mode 100644
index 000000000..d8f16ce19
--- /dev/null
+++ b/src/view/com/util/forms/NativeDropdown.tsx
@@ -0,0 +1,250 @@
+import React from 'react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import * as DropdownMenu from 'zeego/dropdown-menu'
+import {
+  Pressable,
+  StyleSheet,
+  Platform,
+  StyleProp,
+  ViewStyle,
+} from 'react-native'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isWeb} from 'platform/detection'
+import {useTheme} from 'lib/ThemeContext'
+import {HITSLOP_10} from 'lib/constants'
+
+// Custom Dropdown Menu Components
+// ==
+export const DropdownMenuRoot = DropdownMenu.Root
+export const DropdownMenuTrigger = DropdownMenu.Trigger
+export const DropdownMenuContent = DropdownMenu.Content
+type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
+export const DropdownMenuItem = DropdownMenu.create(
+  (props: ItemProps & {testID?: string}) => {
+    const pal = usePalette('default')
+    const theme = useTheme()
+    const [focused, setFocused] = React.useState(false)
+    const {borderColor: backgroundColor} =
+      theme.colorScheme === 'dark' ? pal.borderDark : pal.border
+
+    return (
+      <DropdownMenu.Item
+        {...props}
+        style={[styles.item, focused && {backgroundColor: backgroundColor}]}
+        onFocus={() => {
+          setFocused(true)
+          props.onFocus && props.onFocus()
+        }}
+        onBlur={() => {
+          setFocused(false)
+          props.onBlur && props.onBlur()
+        }}
+      />
+    )
+  },
+  'Item',
+)
+type TitleProps = React.ComponentProps<(typeof DropdownMenu)['ItemTitle']>
+export const DropdownMenuItemTitle = DropdownMenu.create(
+  (props: TitleProps) => {
+    const pal = usePalette('default')
+    return (
+      <DropdownMenu.ItemTitle
+        {...props}
+        style={[props.style, pal.text, styles.itemTitle]}
+      />
+    )
+  },
+  'ItemTitle',
+)
+type IconProps = React.ComponentProps<(typeof DropdownMenu)['ItemIcon']>
+export const DropdownMenuItemIcon = DropdownMenu.create((props: IconProps) => {
+  return <DropdownMenu.ItemIcon {...props} />
+}, 'ItemIcon')
+type SeparatorProps = React.ComponentProps<(typeof DropdownMenu)['Separator']>
+export const DropdownMenuSeparator = DropdownMenu.create(
+  (props: SeparatorProps) => {
+    const pal = usePalette('default')
+    const theme = useTheme()
+    const {borderColor: separatorColor} =
+      theme.colorScheme === 'dark' ? pal.borderDark : pal.border
+    return (
+      <DropdownMenu.Separator
+        {...props}
+        style={[
+          props.style,
+          styles.separator,
+          {backgroundColor: separatorColor},
+        ]}
+      />
+    )
+  },
+  'Separator',
+)
+
+// Types for Dropdown Menu and Items
+export type DropdownItem = {
+  label: string | 'separator'
+  onPress?: () => void
+  testID?: string
+  icon?: {
+    ios: MenuItemCommonProps['ios']
+    android: string
+    web: IconProp
+  }
+}
+type Props = {
+  items: DropdownItem[]
+  children?: React.ReactNode
+  testID?: string
+}
+
+/* The `NativeDropdown` function uses native iOS and Android dropdown menus.
+ * It also creates a animated custom dropdown for web that uses
+ * Radix UI primitives under the hood
+ * @prop {DropdownItem[]} items - An array of dropdown items
+ * @prop {React.ReactNode} children - A custom dropdown trigger
+ */
+export function NativeDropdown({items, children, testID}: Props) {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const dropDownBackgroundColor =
+    theme.colorScheme === 'dark' ? pal.btn : pal.viewLight
+  const defaultCtrlColor = React.useMemo(
+    () => ({
+      color: theme.palette.default.postCtrl,
+    }),
+    [theme],
+  ) as StyleProp<ViewStyle>
+
+  return (
+    <DropdownMenuRoot>
+      <DropdownMenuTrigger action="press">
+        <Pressable
+          testID={testID}
+          accessibilityRole="button"
+          style={({pressed}) => [{opacity: pressed ? 0.5 : 1}]}
+          hitSlop={HITSLOP_10}>
+          {children ? (
+            children
+          ) : (
+            <FontAwesomeIcon
+              icon="ellipsis"
+              size={20}
+              style={[defaultCtrlColor, styles.ellipsis]}
+            />
+          )}
+        </Pressable>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent
+        style={[styles.content, dropDownBackgroundColor]}
+        loop>
+        {items.map((item, index) => {
+          if (item.label === 'separator') {
+            return (
+              <DropdownMenuSeparator
+                key={getKey(item.label, index, item.testID)}
+              />
+            )
+          }
+          if (index > 1 && items[index - 1].label === 'separator') {
+            return (
+              <DropdownMenu.Group key={getKey(item.label, index, item.testID)}>
+                <DropdownMenuItem
+                  key={getKey(item.label, index, item.testID)}
+                  onSelect={item.onPress}>
+                  <DropdownMenuItemTitle>{item.label}</DropdownMenuItemTitle>
+                  {item.icon && (
+                    <DropdownMenuItemIcon
+                      ios={item.icon.ios}
+                      // androidIconName={item.icon.android} TODO: Add custom android icon support, because these ones are based on https://developer.android.com/reference/android/R.drawable.html and they are ugly
+                    >
+                      <FontAwesomeIcon
+                        icon={item.icon.web}
+                        size={20}
+                        style={[pal.text]}
+                      />
+                    </DropdownMenuItemIcon>
+                  )}
+                </DropdownMenuItem>
+              </DropdownMenu.Group>
+            )
+          }
+          return (
+            <DropdownMenuItem
+              key={getKey(item.label, index, item.testID)}
+              onSelect={item.onPress}>
+              <DropdownMenuItemTitle>{item.label}</DropdownMenuItemTitle>
+              {item.icon && (
+                <DropdownMenuItemIcon
+                  ios={item.icon.ios}
+                  // androidIconName={item.icon.android}
+                >
+                  <FontAwesomeIcon
+                    icon={item.icon.web}
+                    size={20}
+                    style={[pal.text]}
+                  />
+                </DropdownMenuItemIcon>
+              )}
+            </DropdownMenuItem>
+          )
+        })}
+      </DropdownMenuContent>
+    </DropdownMenuRoot>
+  )
+}
+
+const getKey = (label: string, index: number, id?: string) => {
+  if (id) {
+    return id
+  }
+  return `${label}_${index}`
+}
+
+const styles = StyleSheet.create({
+  separator: {
+    height: 1,
+    marginVertical: 4,
+  },
+  ellipsis: {
+    padding: isWeb ? 0 : 10,
+  },
+  content: {
+    backgroundColor: '#f0f0f0',
+    borderRadius: 8,
+    paddingVertical: 4,
+    paddingHorizontal: 4,
+    marginTop: 6,
+    ...Platform.select({
+      web: {
+        animationDuration: '400ms',
+        animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
+        willChange: 'transform, opacity',
+        animationKeyframes: {
+          '0%': {opacity: 0, transform: [{scale: 0.5}]},
+          '100%': {opacity: 1, transform: [{scale: 1}]},
+        },
+        boxShadow:
+          '0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)',
+        transformOrigin: 'var(--radix-dropdown-menu-content-transform-origin)',
+      },
+    }),
+  },
+  item: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    columnGap: 20,
+    // @ts-ignore -web
+    cursor: 'pointer',
+    paddingVertical: 8,
+    paddingHorizontal: 12,
+    borderRadius: 8,
+  },
+  itemTitle: {
+    fontSize: 18,
+  },
+})
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
new file mode 100644
index 000000000..ad9ba1619
--- /dev/null
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -0,0 +1,148 @@
+import React from 'react'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {useStores} from 'state/index'
+import {shareUrl} from 'lib/sharing'
+import {
+  NativeDropdown,
+  DropdownItem as NativeDropdownItem,
+} from './NativeDropdown'
+import {Pressable} from 'react-native'
+
+export function PostDropdownBtn({
+  testID,
+  itemUri,
+  itemCid,
+  itemHref,
+  isAuthor,
+  isThreadMuted,
+  onCopyPostText,
+  onOpenTranslate,
+  onToggleThreadMute,
+  onDeletePost,
+}: {
+  testID: string
+  itemUri: string
+  itemCid: string
+  itemHref: string
+  itemTitle: string
+  isAuthor: boolean
+  isThreadMuted: boolean
+  onCopyPostText: () => void
+  onOpenTranslate: () => void
+  onToggleThreadMute: () => void
+  onDeletePost: () => void
+}) {
+  const store = useStores()
+
+  const dropdownItems: NativeDropdownItem[] = [
+    {
+      label: 'Translate',
+      onPress() {
+        onOpenTranslate()
+      },
+      testID: 'postDropdownTranslateBtn',
+      icon: {
+        ios: {
+          name: 'character.book.closed',
+        },
+        android: 'ic_menu_sort_alphabetically',
+        web: 'language',
+      },
+    },
+    {
+      label: 'Copy post text',
+      onPress() {
+        onCopyPostText()
+      },
+      testID: 'postDropdownCopyTextBtn',
+      icon: {
+        ios: {
+          name: 'doc.on.doc',
+        },
+        android: 'ic_menu_edit',
+        web: ['far', 'paste'],
+      },
+    },
+    {
+      label: 'Share',
+      onPress() {
+        const url = toShareUrl(itemHref)
+        shareUrl(url)
+      },
+      testID: 'postDropdownShareBtn',
+      icon: {
+        ios: {
+          name: 'square.and.arrow.up',
+        },
+        android: 'ic_menu_share',
+        web: 'share',
+      },
+    },
+    {
+      label: 'separator',
+    },
+    {
+      label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
+      onPress() {
+        onToggleThreadMute()
+      },
+      testID: 'postDropdownMuteThreadBtn',
+      icon: {
+        ios: {
+          name: 'speaker.slash',
+        },
+        android: 'ic_lock_silent_mode',
+        web: 'comment-slash',
+      },
+    },
+    {
+      label: 'separator',
+    },
+    {
+      label: 'Report post',
+      onPress() {
+        store.shell.openModal({
+          name: 'report-post',
+          postUri: itemUri,
+          postCid: itemCid,
+        })
+      },
+      testID: 'postDropdownReportBtn',
+      icon: {
+        ios: {
+          name: 'exclamationmark.triangle',
+        },
+        android: 'ic_menu_report_image',
+        web: 'circle-exclamation',
+      },
+    },
+    isAuthor && {
+      label: 'separator',
+    },
+    isAuthor && {
+      label: 'Delete post',
+      onPress() {
+        store.shell.openModal({
+          name: 'confirm',
+          title: 'Delete this post?',
+          message: 'Are you sure? This can not be undone.',
+          onPressConfirm: onDeletePost,
+        })
+      },
+      testID: 'postDropdownDeleteBtn',
+      icon: {
+        ios: {
+          name: 'trash',
+        },
+        android: 'ic_menu_delete',
+        web: ['far', 'trash-can'],
+      },
+    },
+  ].filter(Boolean) as NativeDropdownItem[]
+
+  return (
+    <Pressable testID={testID} accessibilityRole="button">
+      <NativeDropdown items={dropdownItems} />
+    </Pressable>
+  )
+}