mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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})