diff options
Diffstat (limited to 'src/view/com/util/forms')
-rw-r--r-- | src/view/com/util/forms/DropdownButton.tsx | 125 | ||||
-rw-r--r-- | src/view/com/util/forms/NativeDropdown.tsx | 250 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 148 |
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> + ) +} |