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