Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393import React, {PropsWithChildren, useMemo, useRef} from 'react'import { Dimensions, GestureResponderEvent, Insets, StyleProp, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, useWindowDimensions, View, ViewStyle,} from 'react-native'import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated'import RootSiblings from 'react-native-root-siblings'import {IconProp} from '@fortawesome/fontawesome-svg-core'import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'import {msg} from '@lingui/macro'import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'import {usePalette} from '#/lib/hooks/usePalette'import {colors} from '#/lib/styles'import {useTheme} from '#/lib/ThemeContext'import {isWeb} from '#/platform/detection'import {native} from '#/alf'import {FullWindowOverlay} from '#/components/FullWindowOverlay'import {Text} from '../text/Text'import {Button, ButtonType} from './Button'
const ESTIMATED_BTN_HEIGHT = 50const ESTIMATED_SEP_HEIGHT = 16const ESTIMATED_HEADING_HEIGHT = 60
export interface DropdownItemButton { testID?: string icon?: IconProp label: string onPress: () => void}export interface DropdownItemSeparator { sep: true}export interface DropdownItemHeading { heading: true label: string}export type DropdownItem = | DropdownItemButton | DropdownItemSeparator | DropdownItemHeadingtype MaybeDropdownItem = DropdownItem | false | undefined
export type DropdownButtonType = ButtonType | 'bare'
interface DropdownButtonProps { testID?: string type?: DropdownButtonType style?: StyleProp<ViewStyle> items: MaybeDropdownItem[] label?: string menuWidth?: number children?: React.ReactNode openToRight?: boolean openUpwards?: boolean rightOffset?: number bottomOffset?: number hitSlop?: Insets accessibilityLabel?: string accessibilityHint?: string}
export function DropdownButton({ testID, type = 'bare', style, items, label, menuWidth, children, openToRight = false, openUpwards = false, rightOffset = 0, bottomOffset = 0, hitSlop = HITSLOP_10, accessibilityLabel,}: PropsWithChildren<DropdownButtonProps>) { const {_} = useLingui()
const ref1 = useRef<View>(null) const ref2 = useRef<View>(null)
const onPress = (e: GestureResponderEvent) => { const ref = ref1.current || ref2.current const {height: winHeight} = Dimensions.get('window') const pressY = e.nativeEvent.pageY ref?.measure( ( _x: number, _y: number, width: number, _height: number, pageX: number, pageY: number, ) => { if (!menuWidth) { menuWidth = 200 } let estimatedMenuHeight = 0 for (const item of items) { if (item && isSep(item)) { estimatedMenuHeight += ESTIMATED_SEP_HEIGHT } else if (item && isBtn(item)) { estimatedMenuHeight += ESTIMATED_BTN_HEIGHT } else if (item && isHeading(item)) { estimatedMenuHeight += ESTIMATED_HEADING_HEIGHT } } const newX = openToRight ? pageX + width + rightOffset : pageX + width - menuWidth
// Add a bit of additional room let newY = pressY + bottomOffset + 20 if (openUpwards || newY + estimatedMenuHeight > winHeight) { newY -= estimatedMenuHeight } createDropdownMenu( newX, newY, pageY, menuWidth, items.filter(v => !!v) as DropdownItem[], openUpwards, ) }, ) }
const numItems = useMemo( () => items.filter(item => { if (item === undefined || item === false) { return false }
return isBtn(item) }).length, [items], )
if (type === 'bare') { return ( <TouchableOpacity testID={testID} style={style} onPress={onPress} hitSlop={hitSlop} ref={ref1} accessibilityRole="button" accessibilityLabel={ accessibilityLabel || _(msg`Opens ${numItems} options`) } accessibilityHint=""> {children} </TouchableOpacity> ) } return ( <View ref={ref2}> <Button type={type} testID={testID} onPress={onPress} style={style} label={label}> {children} </Button> </View> )}
function createDropdownMenu( x: number, y: number, pageY: number, width: number, items: DropdownItem[], opensUpwards = false,): RootSiblings { const onPressItem = (index: number) => { sibling.destroy() const item = items[index] if (isBtn(item)) { item.onPress() } } const onOuterPress = () => sibling.destroy() const sibling = new RootSiblings( ( <DropdownItems onOuterPress={onOuterPress} x={x} y={y} pageY={pageY} width={width} items={items} onPressItem={onPressItem} openUpwards={opensUpwards} /> ), ) return sibling}
type DropDownItemProps = { onOuterPress: () => void x: number y: number pageY: number width: number items: DropdownItem[] onPressItem: (index: number) => void openUpwards: boolean}
const DropdownItems = ({ onOuterPress, x, y, pageY, width, items, onPressItem, openUpwards,}: DropDownItemProps) => { const pal = usePalette('default') const theme = useTheme() const {_} = useLingui() const {height: screenHeight} = useWindowDimensions() const dropDownBackgroundColor = theme.colorScheme === 'dark' ? pal.btn : pal.view const separatorColor = theme.colorScheme === 'dark' ? pal.borderDark : pal.border
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 ( <FullWindowOverlay> {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */} <TouchableWithoutFeedback onPress={onOuterPress} accessibilityLabel={_(msg`Toggle dropdown`)} accessibilityHint=""> <Animated.View entering={FadeIn} style={[ styles.bg, // On web we need to adjust the top and bottom relative to the scroll position isWeb ? { top: -pageY, bottom: pageY - screenHeight, } : { top: 0, bottom: 0, }, ]} /> </TouchableWithoutFeedback> <Animated.View entering={native( openUpwards ? FadeInDown.springify(1000) : FadeInUp.springify(1000), )} style={[ styles.menu, {left: x, top: y, width}, dropDownBackgroundColor, ]}> {items.map((item, index) => { if (isBtn(item)) { return ( <TouchableOpacity testID={item.testID} key={index} style={[styles.menuItem]} onPress={() => onPressItem(index)} accessibilityRole="button" accessibilityLabel={item.label} accessibilityHint={_( msg`Selects option ${index + 1} of ${numItems}`, )}> {item.icon && ( <FontAwesomeIcon style={styles.icon} icon={item.icon} color={pal.text.color as string} /> )} <Text style={[styles.label, pal.text]}>{item.label}</Text> </TouchableOpacity> ) } else if (isSep(item)) { return ( <View key={index} style={[styles.separator, separatorColor]} /> ) } else if (isHeading(item)) { return ( <View style={[styles.heading, pal.border]} key={index}> <Text style={[pal.text, styles.headingLabel]}> {item.label} </Text> </View> ) } return null })} </Animated.View> </FullWindowOverlay> )}
function isSep(item: DropdownItem): item is DropdownItemSeparator { return 'sep' in item && item.sep}function isHeading(item: DropdownItem): item is DropdownItemHeading { return 'heading' in item && item.heading}function isBtn(item: DropdownItem): item is DropdownItemButton { return !isSep(item) && !isHeading(item)}
const styles = StyleSheet.create({ bg: { position: 'absolute', left: 0, width: '100%', backgroundColor: 'rgba(0, 0, 0, 0.1)', }, menu: { position: 'absolute', backgroundColor: '#fff', borderRadius: 14, 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: 2, marginRight: 8, flexShrink: 0, }, label: { fontSize: 18, flexShrink: 1, flexGrow: 1, }, separator: { borderTopWidth: 1, marginVertical: 8, }, heading: { flexDirection: 'row', justifyContent: 'center', paddingVertical: 10, paddingLeft: 15, paddingRight: 20, borderBottomWidth: 1, marginBottom: 6, }, headingLabel: { fontSize: 18, fontWeight: '600', },})