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