diff options
Diffstat (limited to 'src/view/com/util/forms')
-rw-r--r-- | src/view/com/util/forms/Button.tsx | 120 | ||||
-rw-r--r-- | src/view/com/util/forms/DropdownButton.tsx | 238 | ||||
-rw-r--r-- | src/view/com/util/forms/RadioButton.tsx | 135 | ||||
-rw-r--r-- | src/view/com/util/forms/RadioGroup.tsx | 11 | ||||
-rw-r--r-- | src/view/com/util/forms/ToggleButton.tsx | 165 |
5 files changed, 646 insertions, 23 deletions
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx new file mode 100644 index 000000000..b5c4da19d --- /dev/null +++ b/src/view/com/util/forms/Button.tsx @@ -0,0 +1,120 @@ +import React from 'react' +import { + StyleProp, + StyleSheet, + TextStyle, + TouchableOpacity, + ViewStyle, +} from 'react-native' +import {Text} from '../text/Text' +import {useTheme} from '../../../lib/ThemeContext' +import {choose} from '../../../../lib/functions' + +export type ButtonType = + | 'primary' + | 'secondary' + | 'inverted' + | 'primary-outline' + | 'secondary-outline' + | 'primary-light' + | 'secondary-light' + | 'default-light' + +export function Button({ + type = 'primary', + label, + style, + onPress, + children, +}: React.PropsWithChildren<{ + type?: ButtonType + label?: string + style?: StyleProp<ViewStyle> + onPress?: () => void +}>) { + const theme = useTheme() + const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, { + primary: { + backgroundColor: theme.palette.primary.background, + }, + secondary: { + backgroundColor: theme.palette.secondary.background, + }, + inverted: { + backgroundColor: theme.palette.inverted.background, + }, + 'primary-outline': { + backgroundColor: theme.palette.default.background, + borderWidth: 1, + borderColor: theme.palette.primary.border, + }, + 'secondary-outline': { + backgroundColor: theme.palette.default.background, + borderWidth: 1, + borderColor: theme.palette.secondary.border, + }, + 'primary-light': { + backgroundColor: theme.palette.default.background, + }, + 'secondary-light': { + backgroundColor: theme.palette.default.background, + }, + 'default-light': { + backgroundColor: theme.palette.default.background, + }, + }) + const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, { + primary: { + color: theme.palette.primary.text, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + secondary: { + color: theme.palette.secondary.text, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + inverted: { + color: theme.palette.inverted.text, + fontWeight: theme.palette.inverted.isLowContrast ? '500' : undefined, + }, + 'primary-outline': { + color: theme.palette.primary.textInverted, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + 'secondary-outline': { + color: theme.palette.secondary.textInverted, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + 'primary-light': { + color: theme.palette.primary.textInverted, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + 'secondary-light': { + color: theme.palette.secondary.textInverted, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + 'default-light': { + color: theme.palette.default.text, + fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, + }, + }) + return ( + <TouchableOpacity + style={[outerStyle, styles.outer, style]} + onPress={onPress}> + {label ? ( + <Text type="button" style={[labelStyle]}> + {label} + </Text> + ) : ( + children + )} + </TouchableOpacity> + ) +} + +const styles = StyleSheet.create({ + outer: { + paddingHorizontal: 10, + paddingVertical: 8, + }, +}) diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx new file mode 100644 index 000000000..c81ccf6c5 --- /dev/null +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -0,0 +1,238 @@ +import React, {useRef} from 'react' +import { + Share, + StyleProp, + StyleSheet, + TouchableOpacity, + TouchableWithoutFeedback, + View, + ViewStyle, +} from 'react-native' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import RootSiblings from 'react-native-root-siblings' +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' +import {useStores} from '../../../../state' +import {ReportPostModal, ConfirmModal} from '../../../../state/models/shell-ui' +import {TABS_ENABLED} from '../../../../build-flags' + +const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} + +export interface DropdownItem { + icon?: IconProp + label: string + onPress: () => void +} + +export type DropdownButtonType = ButtonType | 'bare' + +export function DropdownButton({ + type = 'bare', + style, + items, + label, + menuWidth, + children, +}: { + type: DropdownButtonType + style?: StyleProp<ViewStyle> + items: DropdownItem[] + label?: string + menuWidth?: number + children?: React.ReactNode +}) { + const ref = useRef<TouchableOpacity>(null) + + const onPress = () => { + ref.current?.measure( + ( + _x: number, + _y: number, + width: number, + height: number, + pageX: number, + pageY: number, + ) => { + if (!menuWidth) { + menuWidth = 200 + } + createDropdownMenu( + pageX + width - menuWidth, + pageY + height, + menuWidth, + items, + ) + }, + ) + } + + if (type === 'bare') { + return ( + <TouchableOpacity + style={style} + onPress={onPress} + hitSlop={HITSLOP} + ref={ref}> + {children} + </TouchableOpacity> + ) + } + return ( + <View ref={ref}> + <Button onPress={onPress} style={style} label={label}> + {children} + </Button> + </View> + ) +} + +export function PostDropdownBtn({ + style, + children, + itemHref, + isAuthor, + onCopyPostText, + onDeletePost, +}: { + style?: StyleProp<ViewStyle> + children?: React.ReactNode + itemHref: string + itemTitle: string + isAuthor: boolean + onCopyPostText: () => void + onDeletePost: () => void +}) { + const store = useStores() + + const dropdownItems: DropdownItem[] = [ + TABS_ENABLED + ? { + icon: ['far', 'clone'], + label: 'Open in new tab', + onPress() { + store.nav.newTab(itemHref) + }, + } + : undefined, + { + icon: ['far', 'paste'], + label: 'Copy post text', + onPress() { + onCopyPostText() + }, + }, + { + icon: 'share', + label: 'Share...', + onPress() { + Share.share({url: toShareUrl(itemHref)}) + }, + }, + { + icon: 'circle-exclamation', + label: 'Report post', + onPress() { + store.shell.openModal(new ReportPostModal(itemHref)) + }, + }, + isAuthor + ? { + icon: ['far', 'trash-can'], + label: 'Delete post', + onPress() { + store.shell.openModal( + new ConfirmModal( + 'Delete this post?', + 'Are you sure? This can not be undone.', + onDeletePost, + ), + ) + }, + } + : undefined, + ].filter(Boolean) as DropdownItem[] + + return ( + <DropdownButton style={style} items={dropdownItems} menuWidth={200}> + {children} + </DropdownButton> + ) +} + +function createDropdownMenu( + x: number, + y: number, + width: number, + items: DropdownItem[], +): RootSiblings { + const onPressItem = (index: number) => { + sibling.destroy() + items[index].onPress() + } + const onOuterPress = () => sibling.destroy() + const sibling = new RootSiblings( + ( + <> + <TouchableWithoutFeedback onPress={onOuterPress}> + <View style={styles.bg} /> + </TouchableWithoutFeedback> + <View style={[styles.menu, {left: x, top: y, width}]}> + {items.map((item, index) => ( + <TouchableOpacity + key={index} + style={[styles.menuItem]} + onPress={() => onPressItem(index)}> + {item.icon && ( + <FontAwesomeIcon style={styles.icon} icon={item.icon} /> + )} + <Text style={styles.label}>{item.label}</Text> + </TouchableOpacity> + ))} + </View> + </> + ), + ) + return sibling +} + +const styles = StyleSheet.create({ + bg: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: '#000', + opacity: 0.1, + }, + menu: { + position: 'absolute', + backgroundColor: '#fff', + borderRadius: 14, + opacity: 1, + paddingVertical: 6, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingLeft: 15, + paddingRight: 40, + }, + menuItemBorder: { + borderTopWidth: 1, + borderTopColor: colors.gray1, + marginTop: 4, + paddingTop: 12, + }, + icon: { + marginLeft: 6, + marginRight: 8, + }, + label: { + fontSize: 18, + }, +}) diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index 9da404bea..81489c447 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -1,24 +1,126 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {Text} from '../Text' -import {colors} from '../../../lib/styles' +import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' +import {Text} from '../text/Text' +import {Button, ButtonType} from './Button' +import {useTheme} from '../../../lib/ThemeContext' +import {choose} from '../../../../lib/functions' export function RadioButton({ + type = 'default-light', label, isSelected, + style, onPress, }: { + type?: ButtonType label: string isSelected: boolean + style?: StyleProp<ViewStyle> onPress: () => void }) { + const theme = useTheme() + const circleStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, { + primary: { + borderColor: theme.palette.primary.text, + }, + secondary: { + borderColor: theme.palette.secondary.text, + }, + inverted: { + borderColor: theme.palette.inverted.text, + }, + 'primary-outline': { + borderColor: theme.palette.primary.border, + }, + 'secondary-outline': { + borderColor: theme.palette.secondary.border, + }, + 'primary-light': { + borderColor: theme.palette.primary.border, + }, + 'secondary-light': { + borderColor: theme.palette.secondary.border, + }, + 'default-light': { + borderColor: theme.palette.default.border, + }, + }) + const circleFillStyle = choose<TextStyle, Record<ButtonType, TextStyle>>( + type, + { + primary: { + backgroundColor: theme.palette.primary.text, + }, + secondary: { + backgroundColor: theme.palette.secondary.text, + }, + inverted: { + backgroundColor: theme.palette.inverted.text, + }, + 'primary-outline': { + backgroundColor: theme.palette.primary.background, + }, + 'secondary-outline': { + backgroundColor: theme.palette.secondary.background, + }, + 'primary-light': { + backgroundColor: theme.palette.primary.background, + }, + 'secondary-light': { + backgroundColor: theme.palette.secondary.background, + }, + 'default-light': { + backgroundColor: theme.palette.primary.background, + }, + }, + ) + const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, { + primary: { + color: theme.palette.primary.text, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + secondary: { + color: theme.palette.secondary.text, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + inverted: { + color: theme.palette.inverted.text, + fontWeight: theme.palette.inverted.isLowContrast ? '500' : undefined, + }, + 'primary-outline': { + color: theme.palette.primary.textInverted, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + 'secondary-outline': { + color: theme.palette.secondary.textInverted, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + 'primary-light': { + color: theme.palette.primary.textInverted, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + 'secondary-light': { + color: theme.palette.secondary.textInverted, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + 'default-light': { + color: theme.palette.default.text, + fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, + }, + }) return ( - <TouchableOpacity style={styles.outer} onPress={onPress}> - <View style={styles.circle}> - {isSelected ? <View style={styles.circleFill} /> : undefined} + <Button type={type} onPress={onPress} style={style}> + <View style={styles.outer}> + <View style={[circleStyle, styles.circle]}> + {isSelected ? ( + <View style={[circleFillStyle, styles.circleFill]} /> + ) : undefined} + </View> + <Text type="button" style={[labelStyle, styles.label]}> + {label} + </Text> </View> - <Text style={styles.label}>{label}</Text> - </TouchableOpacity> + </Button> ) } @@ -26,30 +128,21 @@ const styles = StyleSheet.create({ outer: { flexDirection: 'row', alignItems: 'center', - marginBottom: 5, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.gray2, - paddingHorizontal: 10, - paddingVertical: 8, }, circle: { - width: 30, - height: 30, + width: 26, + height: 26, borderRadius: 15, padding: 4, borderWidth: 1, - borderColor: colors.gray3, marginRight: 10, }, circleFill: { - width: 20, - height: 20, + width: 16, + height: 16, borderRadius: 10, - backgroundColor: colors.blue3, }, label: { flex: 1, - fontSize: 17, }, }) diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx index 6684cde5c..9abc2345f 100644 --- a/src/view/com/util/forms/RadioGroup.tsx +++ b/src/view/com/util/forms/RadioGroup.tsx @@ -1,6 +1,7 @@ import React, {useState} from 'react' import {View} from 'react-native' import {RadioButton} from './RadioButton' +import {ButtonType} from './Button' export interface RadioGroupItem { label: string @@ -8,22 +9,28 @@ export interface RadioGroupItem { } export function RadioGroup({ + type, items, + initialSelection = '', onSelect, }: { + type?: ButtonType items: RadioGroupItem[] + initialSelection?: string onSelect: (key: string) => void }) { - const [selection, setSelection] = useState<string>('') + const [selection, setSelection] = useState<string>(initialSelection) const onSelectInner = (key: string) => { setSelection(key) onSelect(key) } return ( <View> - {items.map(item => ( + {items.map((item, i) => ( <RadioButton key={item.key} + style={i !== 0 ? {marginTop: 2} : undefined} + type={type} label={item.label} isSelected={item.key === selection} onPress={() => onSelectInner(item.key)} diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx new file mode 100644 index 000000000..77e8fa203 --- /dev/null +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -0,0 +1,165 @@ +import React from 'react' +import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' +import {Text} from '../text/Text' +import {Button, ButtonType} from './Button' +import {useTheme} from '../../../lib/ThemeContext' +import {choose} from '../../../../lib/functions' +import {colors} from '../../../lib/styles' + +export function ToggleButton({ + type = 'default-light', + label, + isSelected, + style, + onPress, +}: { + type?: ButtonType + label: string + isSelected: boolean + style?: StyleProp<ViewStyle> + onPress: () => void +}) { + const theme = useTheme() + const circleStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, { + primary: { + borderColor: theme.palette.primary.text, + }, + secondary: { + borderColor: theme.palette.secondary.text, + }, + inverted: { + borderColor: theme.palette.inverted.text, + }, + 'primary-outline': { + borderColor: theme.palette.primary.border, + }, + 'secondary-outline': { + borderColor: theme.palette.secondary.border, + }, + 'primary-light': { + borderColor: theme.palette.primary.border, + }, + 'secondary-light': { + borderColor: theme.palette.secondary.border, + }, + 'default-light': { + borderColor: theme.palette.default.border, + }, + }) + const circleFillStyle = choose<TextStyle, Record<ButtonType, TextStyle>>( + type, + { + primary: { + backgroundColor: theme.palette.primary.text, + opacity: isSelected ? 1 : 0.33, + }, + secondary: { + backgroundColor: theme.palette.secondary.text, + opacity: isSelected ? 1 : 0.33, + }, + inverted: { + backgroundColor: theme.palette.inverted.text, + opacity: isSelected ? 1 : 0.33, + }, + 'primary-outline': { + backgroundColor: theme.palette.primary.background, + opacity: isSelected ? 1 : 0.5, + }, + 'secondary-outline': { + backgroundColor: theme.palette.secondary.background, + opacity: isSelected ? 1 : 0.5, + }, + 'primary-light': { + backgroundColor: theme.palette.primary.background, + opacity: isSelected ? 1 : 0.5, + }, + 'secondary-light': { + backgroundColor: theme.palette.secondary.background, + opacity: isSelected ? 1 : 0.5, + }, + 'default-light': { + backgroundColor: isSelected + ? theme.palette.primary.background + : colors.gray3, + }, + }, + ) + const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, { + primary: { + color: theme.palette.primary.text, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + secondary: { + color: theme.palette.secondary.text, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + inverted: { + color: theme.palette.inverted.text, + fontWeight: theme.palette.inverted.isLowContrast ? '500' : undefined, + }, + 'primary-outline': { + color: theme.palette.primary.textInverted, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + 'secondary-outline': { + color: theme.palette.secondary.textInverted, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + 'primary-light': { + color: theme.palette.primary.textInverted, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + 'secondary-light': { + color: theme.palette.secondary.textInverted, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + 'default-light': { + color: theme.palette.default.text, + fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, + }, + }) + return ( + <Button type={type} onPress={onPress} style={style}> + <View style={styles.outer}> + <View style={[circleStyle, styles.circle]}> + <View + style={[ + circleFillStyle, + styles.circleFill, + isSelected ? styles.circleFillSelected : undefined, + ]} + /> + </View> + <Text type="button" style={[labelStyle, styles.label]}> + {label} + </Text> + </View> + </Button> + ) +} + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'row', + alignItems: 'center', + }, + circle: { + width: 42, + height: 26, + borderRadius: 15, + padding: 4, + borderWidth: 1, + marginRight: 10, + }, + circleFill: { + width: 16, + height: 16, + borderRadius: 10, + }, + circleFillSelected: { + marginLeft: 16, + }, + label: { + flex: 1, + }, +}) |