mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at verify-code 9.8 kB view raw
1import React, {PropsWithChildren, useMemo, useRef} from 'react' 2import { 3 Dimensions, 4 GestureResponderEvent, 5 StyleProp, 6 StyleSheet, 7 TouchableOpacity, 8 TouchableWithoutFeedback, 9 useWindowDimensions, 10 View, 11 ViewStyle, 12} from 'react-native' 13import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated' 14import RootSiblings from 'react-native-root-siblings' 15import {IconProp} from '@fortawesome/fontawesome-svg-core' 16import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 17import {msg} from '@lingui/macro' 18import {useLingui} from '@lingui/react' 19 20import {HITSLOP_10} from 'lib/constants' 21import {usePalette} from 'lib/hooks/usePalette' 22import {colors} from 'lib/styles' 23import {useTheme} from 'lib/ThemeContext' 24import {isWeb} from 'platform/detection' 25import {native} from '#/alf' 26import {FullWindowOverlay} from '#/components/FullWindowOverlay' 27import {Text} from '../text/Text' 28import {Button, ButtonType} from './Button' 29 30const ESTIMATED_BTN_HEIGHT = 50 31const ESTIMATED_SEP_HEIGHT = 16 32const ESTIMATED_HEADING_HEIGHT = 60 33 34export interface DropdownItemButton { 35 testID?: string 36 icon?: IconProp 37 label: string 38 onPress: () => void 39} 40export interface DropdownItemSeparator { 41 sep: true 42} 43export interface DropdownItemHeading { 44 heading: true 45 label: string 46} 47export type DropdownItem = 48 | DropdownItemButton 49 | DropdownItemSeparator 50 | DropdownItemHeading 51type MaybeDropdownItem = DropdownItem | false | undefined 52 53export type DropdownButtonType = ButtonType | 'bare' 54 55interface DropdownButtonProps { 56 testID?: string 57 type?: DropdownButtonType 58 style?: StyleProp<ViewStyle> 59 items: MaybeDropdownItem[] 60 label?: string 61 menuWidth?: number 62 children?: React.ReactNode 63 openToRight?: boolean 64 openUpwards?: boolean 65 rightOffset?: number 66 bottomOffset?: number 67 accessibilityLabel?: string 68 accessibilityHint?: string 69} 70 71export function DropdownButton({ 72 testID, 73 type = 'bare', 74 style, 75 items, 76 label, 77 menuWidth, 78 children, 79 openToRight = false, 80 openUpwards = false, 81 rightOffset = 0, 82 bottomOffset = 0, 83 accessibilityLabel, 84}: PropsWithChildren<DropdownButtonProps>) { 85 const {_} = useLingui() 86 87 const ref1 = useRef<TouchableOpacity>(null) 88 const ref2 = useRef<View>(null) 89 90 const onPress = (e: GestureResponderEvent) => { 91 const ref = ref1.current || ref2.current 92 const {height: winHeight} = Dimensions.get('window') 93 const pressY = e.nativeEvent.pageY 94 ref?.measure( 95 ( 96 _x: number, 97 _y: number, 98 width: number, 99 _height: number, 100 pageX: number, 101 pageY: number, 102 ) => { 103 if (!menuWidth) { 104 menuWidth = 200 105 } 106 let estimatedMenuHeight = 0 107 for (const item of items) { 108 if (item && isSep(item)) { 109 estimatedMenuHeight += ESTIMATED_SEP_HEIGHT 110 } else if (item && isBtn(item)) { 111 estimatedMenuHeight += ESTIMATED_BTN_HEIGHT 112 } else if (item && isHeading(item)) { 113 estimatedMenuHeight += ESTIMATED_HEADING_HEIGHT 114 } 115 } 116 const newX = openToRight 117 ? pageX + width + rightOffset 118 : pageX + width - menuWidth 119 120 // Add a bit of additional room 121 let newY = pressY + bottomOffset + 20 122 if (openUpwards || newY + estimatedMenuHeight > winHeight) { 123 newY -= estimatedMenuHeight 124 } 125 createDropdownMenu( 126 newX, 127 newY, 128 pageY, 129 menuWidth, 130 items.filter(v => !!v) as DropdownItem[], 131 openUpwards, 132 ) 133 }, 134 ) 135 } 136 137 const numItems = useMemo( 138 () => 139 items.filter(item => { 140 if (item === undefined || item === false) { 141 return false 142 } 143 144 return isBtn(item) 145 }).length, 146 [items], 147 ) 148 149 if (type === 'bare') { 150 return ( 151 <TouchableOpacity 152 testID={testID} 153 style={style} 154 onPress={onPress} 155 hitSlop={HITSLOP_10} 156 ref={ref1} 157 accessibilityRole="button" 158 accessibilityLabel={ 159 accessibilityLabel || _(msg`Opens ${numItems} options`) 160 } 161 accessibilityHint=""> 162 {children} 163 </TouchableOpacity> 164 ) 165 } 166 return ( 167 <View ref={ref2}> 168 <Button 169 type={type} 170 testID={testID} 171 onPress={onPress} 172 style={style} 173 label={label}> 174 {children} 175 </Button> 176 </View> 177 ) 178} 179 180function createDropdownMenu( 181 x: number, 182 y: number, 183 pageY: number, 184 width: number, 185 items: DropdownItem[], 186 opensUpwards = false, 187): RootSiblings { 188 const onPressItem = (index: number) => { 189 sibling.destroy() 190 const item = items[index] 191 if (isBtn(item)) { 192 item.onPress() 193 } 194 } 195 const onOuterPress = () => sibling.destroy() 196 const sibling = new RootSiblings( 197 ( 198 <DropdownItems 199 onOuterPress={onOuterPress} 200 x={x} 201 y={y} 202 pageY={pageY} 203 width={width} 204 items={items} 205 onPressItem={onPressItem} 206 openUpwards={opensUpwards} 207 /> 208 ), 209 ) 210 return sibling 211} 212 213type DropDownItemProps = { 214 onOuterPress: () => void 215 x: number 216 y: number 217 pageY: number 218 width: number 219 items: DropdownItem[] 220 onPressItem: (index: number) => void 221 openUpwards: boolean 222} 223 224const DropdownItems = ({ 225 onOuterPress, 226 x, 227 y, 228 pageY, 229 width, 230 items, 231 onPressItem, 232 openUpwards, 233}: DropDownItemProps) => { 234 const pal = usePalette('default') 235 const theme = useTheme() 236 const {_} = useLingui() 237 const {height: screenHeight} = useWindowDimensions() 238 const dropDownBackgroundColor = 239 theme.colorScheme === 'dark' ? pal.btn : pal.view 240 const separatorColor = 241 theme.colorScheme === 'dark' ? pal.borderDark : pal.border 242 243 const numItems = items.filter(isBtn).length 244 245 // TODO: Refactor dropdown components to: 246 // - (On web, if not handled by React Native) use semantic <select /> 247 // and <option /> elements for keyboard navigation out of the box 248 // - (On mobile) be buttons by default, accept `label` and `nativeID` 249 // props, and always have an explicit label 250 return ( 251 <FullWindowOverlay> 252 {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */} 253 <TouchableWithoutFeedback 254 onPress={onOuterPress} 255 accessibilityLabel={_(msg`Toggle dropdown`)} 256 accessibilityHint=""> 257 <Animated.View 258 entering={FadeIn} 259 style={[ 260 styles.bg, 261 // On web we need to adjust the top and bottom relative to the scroll position 262 isWeb 263 ? { 264 top: -pageY, 265 bottom: pageY - screenHeight, 266 } 267 : { 268 top: 0, 269 bottom: 0, 270 }, 271 ]} 272 /> 273 </TouchableWithoutFeedback> 274 <Animated.View 275 entering={native( 276 openUpwards ? FadeInDown.springify(1000) : FadeInUp.springify(1000), 277 )} 278 style={[ 279 styles.menu, 280 {left: x, top: y, width}, 281 dropDownBackgroundColor, 282 ]}> 283 {items.map((item, index) => { 284 if (isBtn(item)) { 285 return ( 286 <TouchableOpacity 287 testID={item.testID} 288 key={index} 289 style={[styles.menuItem]} 290 onPress={() => onPressItem(index)} 291 accessibilityRole="button" 292 accessibilityLabel={item.label} 293 accessibilityHint={_(msg`Option ${index + 1} of ${numItems}`)}> 294 {item.icon && ( 295 <FontAwesomeIcon 296 style={styles.icon} 297 icon={item.icon} 298 color={pal.text.color as string} 299 /> 300 )} 301 <Text style={[styles.label, pal.text]}>{item.label}</Text> 302 </TouchableOpacity> 303 ) 304 } else if (isSep(item)) { 305 return ( 306 <View key={index} style={[styles.separator, separatorColor]} /> 307 ) 308 } else if (isHeading(item)) { 309 return ( 310 <View style={[styles.heading, pal.border]} key={index}> 311 <Text style={[pal.text, styles.headingLabel]}> 312 {item.label} 313 </Text> 314 </View> 315 ) 316 } 317 return null 318 })} 319 </Animated.View> 320 </FullWindowOverlay> 321 ) 322} 323 324function isSep(item: DropdownItem): item is DropdownItemSeparator { 325 return 'sep' in item && item.sep 326} 327function isHeading(item: DropdownItem): item is DropdownItemHeading { 328 return 'heading' in item && item.heading 329} 330function isBtn(item: DropdownItem): item is DropdownItemButton { 331 return !isSep(item) && !isHeading(item) 332} 333 334const styles = StyleSheet.create({ 335 bg: { 336 position: 'absolute', 337 left: 0, 338 width: '100%', 339 backgroundColor: 'rgba(0, 0, 0, 0.1)', 340 }, 341 menu: { 342 position: 'absolute', 343 backgroundColor: '#fff', 344 borderRadius: 14, 345 paddingVertical: 6, 346 }, 347 menuItem: { 348 flexDirection: 'row', 349 alignItems: 'center', 350 paddingVertical: 10, 351 paddingLeft: 15, 352 paddingRight: 40, 353 }, 354 menuItemBorder: { 355 borderTopWidth: 1, 356 borderTopColor: colors.gray1, 357 marginTop: 4, 358 paddingTop: 12, 359 }, 360 icon: { 361 marginLeft: 2, 362 marginRight: 8, 363 flexShrink: 0, 364 }, 365 label: { 366 fontSize: 18, 367 flexShrink: 1, 368 flexGrow: 1, 369 }, 370 separator: { 371 borderTopWidth: 1, 372 marginVertical: 8, 373 }, 374 heading: { 375 flexDirection: 'row', 376 justifyContent: 'center', 377 paddingVertical: 10, 378 paddingLeft: 15, 379 paddingRight: 20, 380 borderBottomWidth: 1, 381 marginBottom: 6, 382 }, 383 headingLabel: { 384 fontSize: 18, 385 fontWeight: '500', 386 }, 387})